128 lines
5.7 KiB
Go
128 lines
5.7 KiB
Go
package domain
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
|
|
rvboxv1 "github.com/rvbox/rvbox/gen/go/rvbox/v1"
|
|
)
|
|
|
|
var ErrReconcileContradiction = errors.New("reconciliation evidence contradicts durable state")
|
|
|
|
type ClientEvidenceKind uint8
|
|
|
|
const (
|
|
ClientEvidenceAbsent ClientEvidenceKind = iota
|
|
ClientEvidenceRetained
|
|
ClientEvidenceTombstone
|
|
)
|
|
|
|
type ReconcileAction uint8
|
|
|
|
const (
|
|
ReconcileNoop ReconcileAction = iota
|
|
ReconcileRequeue
|
|
ReconcileResumeDelivery
|
|
ReconcileInterruptServerSuppressReplay
|
|
ReconcileInterruptServerClientStateLoss
|
|
ReconcileTerminateLocal
|
|
ReconcileDiscardLocalTerminal
|
|
ReconcileRecordContradiction
|
|
)
|
|
|
|
type ReconcileInput struct {
|
|
ServerPresent bool
|
|
ServerLifecycle rvboxv1.CommandLifecycle
|
|
ServerRevision CommandRevision
|
|
ServerLastEventSeq uint64
|
|
ServerHasTombstone bool
|
|
ClientEvidence ClientEvidenceKind
|
|
ClientLifecycle rvboxv1.CommandLifecycle
|
|
ClientRevision CommandRevision
|
|
ClientLastEventSeq uint64
|
|
ImmutableHashMatches bool
|
|
}
|
|
|
|
type ReconcileDecision struct {
|
|
Action ReconcileAction
|
|
EffectiveRevision CommandRevision
|
|
RecordIncident bool
|
|
SuppressReplay bool
|
|
}
|
|
|
|
// DecideReconciliation implements the explicit server/client evidence matrix.
|
|
// Persistence and message emission remain the caller's atomic responsibility.
|
|
func DecideReconciliation(input ReconcileInput) (ReconcileDecision, error) {
|
|
if input.ClientEvidence > ClientEvidenceTombstone {
|
|
return ReconcileDecision{}, fmt.Errorf("%w: unknown client evidence", ErrReconcileContradiction)
|
|
}
|
|
if input.ServerPresent {
|
|
if input.ServerLifecycle == rvboxv1.CommandLifecycle_COMMAND_LIFECYCLE_UNSPECIFIED || input.ServerRevision == 0 {
|
|
return ReconcileDecision{}, fmt.Errorf("%w: invalid server row", ErrReconcileContradiction)
|
|
}
|
|
}
|
|
if input.ClientEvidence != ClientEvidenceAbsent {
|
|
if input.ClientRevision == 0 || !input.ImmutableHashMatches {
|
|
return ReconcileDecision{}, fmt.Errorf("%w: invalid revision or immutable hash", ErrReconcileContradiction)
|
|
}
|
|
}
|
|
|
|
if !input.ServerPresent {
|
|
if input.ClientEvidence == ClientEvidenceAbsent {
|
|
return ReconcileDecision{Action: ReconcileNoop}, nil
|
|
}
|
|
if input.ClientEvidence == ClientEvidenceTombstone || IsTerminal(input.ClientLifecycle) {
|
|
return ReconcileDecision{Action: ReconcileDiscardLocalTerminal, EffectiveRevision: input.ClientRevision, SuppressReplay: true}, nil
|
|
}
|
|
if input.ServerHasTombstone {
|
|
return ReconcileDecision{Action: ReconcileTerminateLocal, EffectiveRevision: input.ClientRevision, RecordIncident: true, SuppressReplay: true}, nil
|
|
}
|
|
return ReconcileDecision{Action: ReconcileTerminateLocal, EffectiveRevision: input.ClientRevision, RecordIncident: true, SuppressReplay: true}, nil
|
|
}
|
|
|
|
if IsTerminal(input.ServerLifecycle) {
|
|
if input.ClientEvidence == ClientEvidenceAbsent {
|
|
return ReconcileDecision{Action: ReconcileNoop, EffectiveRevision: input.ServerRevision}, nil
|
|
}
|
|
if input.ClientEvidence == ClientEvidenceTombstone || IsTerminal(input.ClientLifecycle) {
|
|
if input.ClientEvidence == ClientEvidenceRetained && input.ClientLifecycle != input.ServerLifecycle {
|
|
return ReconcileDecision{Action: ReconcileRecordContradiction, EffectiveRevision: maxRevision(input.ServerRevision, input.ClientRevision), RecordIncident: true, SuppressReplay: true}, fmt.Errorf("%w: terminal lifecycle mismatch", ErrReconcileContradiction)
|
|
}
|
|
return ReconcileDecision{Action: ReconcileDiscardLocalTerminal, EffectiveRevision: maxRevision(input.ServerRevision, input.ClientRevision), SuppressReplay: true}, nil
|
|
}
|
|
return ReconcileDecision{Action: ReconcileTerminateLocal, EffectiveRevision: maxRevision(input.ServerRevision, input.ClientRevision), RecordIncident: true, SuppressReplay: true}, nil
|
|
}
|
|
|
|
if input.ClientEvidence == ClientEvidenceTombstone {
|
|
return ReconcileDecision{Action: ReconcileInterruptServerSuppressReplay, EffectiveRevision: maxRevision(input.ServerRevision, input.ClientRevision), RecordIncident: true, SuppressReplay: true}, nil
|
|
}
|
|
if input.ClientEvidence == ClientEvidenceAbsent {
|
|
switch input.ServerLifecycle {
|
|
case rvboxv1.CommandLifecycle_COMMAND_QUEUED, rvboxv1.CommandLifecycle_COMMAND_DISPATCHED:
|
|
return ReconcileDecision{Action: ReconcileRequeue, EffectiveRevision: input.ServerRevision}, nil
|
|
case rvboxv1.CommandLifecycle_COMMAND_ACCEPTED, rvboxv1.CommandLifecycle_COMMAND_RUNNING:
|
|
return ReconcileDecision{Action: ReconcileInterruptServerClientStateLoss, EffectiveRevision: input.ServerRevision, RecordIncident: true, SuppressReplay: true}, nil
|
|
default:
|
|
return ReconcileDecision{}, fmt.Errorf("%w: unsupported non-terminal server state", ErrReconcileContradiction)
|
|
}
|
|
}
|
|
|
|
if input.ClientLifecycle == rvboxv1.CommandLifecycle_COMMAND_LIFECYCLE_UNSPECIFIED {
|
|
return ReconcileDecision{}, fmt.Errorf("%w: invalid client lifecycle", ErrReconcileContradiction)
|
|
}
|
|
if input.ClientLastEventSeq < input.ServerLastEventSeq {
|
|
return ReconcileDecision{Action: ReconcileRecordContradiction, EffectiveRevision: maxRevision(input.ServerRevision, input.ClientRevision), RecordIncident: true, SuppressReplay: true}, fmt.Errorf("%w: client lost server-confirmed events", ErrReconcileContradiction)
|
|
}
|
|
if input.ServerLifecycle == rvboxv1.CommandLifecycle_COMMAND_QUEUED {
|
|
return ReconcileDecision{Action: ReconcileRecordContradiction, EffectiveRevision: maxRevision(input.ServerRevision, input.ClientRevision), RecordIncident: true, SuppressReplay: true}, fmt.Errorf("%w: queued server command exists at client", ErrReconcileContradiction)
|
|
}
|
|
return ReconcileDecision{Action: ReconcileResumeDelivery, EffectiveRevision: maxRevision(input.ServerRevision, input.ClientRevision)}, nil
|
|
}
|
|
|
|
func maxRevision(left, right CommandRevision) CommandRevision {
|
|
if left > right {
|
|
return left
|
|
}
|
|
return right
|
|
}
|