Files
rvbox/internal/domain/reconcile.go
T

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
}