Files
kandev/apps/backend/internal/task/models/ceiling_entry.go
T
2026-09-17 15:59:28 +01:00

163 lines
5.9 KiB
Go

package models
import (
"encoding/json"
"fmt"
"time"
)
// CeilingWorkflowEntryBinding identifies the workflow entry that owns a
// deferred launch. The binding is nested in the replay payload so the payload
// remains one immutable launch description while the record's bookkeeping can
// change during capacity retries.
type CeilingWorkflowEntryBinding struct {
WorkflowID string `json:"workflow_id"`
DestinationStepID string `json:"destination_step_id"`
RouteOperationID string `json:"route_operation_id"`
EntryIdentity string `json:"entry_identity"`
DestinationSessionID string `json:"destination_session_id,omitempty"`
}
// Valid reports whether the binding has the identity needed to compare a
// deferred workflow entry with the current committed route. A session ID is
// optional for session-less step starts, where the destination is created only
// after admission.
func (b CeilingWorkflowEntryBinding) Valid() bool {
return b.WorkflowID != "" &&
b.DestinationStepID != "" &&
b.RouteOperationID != "" &&
b.EntryIdentity != ""
}
// ReadCeilingWorkflowEntryBinding decodes the optional nested binding from a
// replay payload. An absent binding is not an error because older records are
// handled by the legacy unambiguous-route recovery path.
func ReadCeilingWorkflowEntryBinding(payload map[string]interface{}) (CeilingWorkflowEntryBinding, bool, error) {
if payload == nil {
return CeilingWorkflowEntryBinding{}, false, nil
}
raw, ok := payload[CeilingLaunchEntryBindingKey]
if !ok || raw == nil {
return CeilingWorkflowEntryBinding{}, false, nil
}
encoded, err := json.Marshal(raw)
if err != nil {
return CeilingWorkflowEntryBinding{}, true, fmt.Errorf("encode workflow entry binding: %w", err)
}
var binding CeilingWorkflowEntryBinding
if err := json.Unmarshal(encoded, &binding); err != nil {
return CeilingWorkflowEntryBinding{}, true, fmt.Errorf("decode workflow entry binding: %w", err)
}
if !binding.Valid() {
return CeilingWorkflowEntryBinding{}, true, fmt.Errorf("workflow entry binding is incomplete")
}
return binding, true, nil
}
// CeilingLaunchClaim is the short-lived ownership lease for one ceiling
// launch. A zero deadline is treated as expired so records written before
// leases were introduced remain recoverable.
type CeilingLaunchClaim struct {
ID string
Owner string
ExpiresAt time.Time
}
func (c CeilingLaunchClaim) Expired(now time.Time) bool {
return c.ExpiresAt.IsZero() || !c.ExpiresAt.After(now)
}
// ReadCeilingLaunchClaimDetails reads the short-lived CAS claim and its lease
// deadline from a ceiling record. Unknown or malformed values are treated as
// absent so a damaged claim cannot make an otherwise valid launch permanently
// undispatchable.
func ReadCeilingLaunchClaimDetails(record map[string]interface{}) (CeilingLaunchClaim, bool) {
if record == nil {
return CeilingLaunchClaim{}, false
}
raw, ok := record[CeilingLaunchClaimKey]
if !ok || raw == nil {
return CeilingLaunchClaim{}, false
}
encoded, err := json.Marshal(raw)
if err != nil {
return CeilingLaunchClaim{}, false
}
var claim struct {
ID string `json:"id"`
Owner string `json:"owner"`
ExpiresAt string `json:"expires_at"`
}
if err := json.Unmarshal(encoded, &claim); err != nil {
return CeilingLaunchClaim{}, false
}
if claim.ID == "" || claim.Owner == "" {
return CeilingLaunchClaim{}, false
}
var expiresAt time.Time
if claim.ExpiresAt != "" {
expiresAt, _ = time.Parse(time.RFC3339Nano, claim.ExpiresAt)
}
return CeilingLaunchClaim{ID: claim.ID, Owner: claim.Owner, ExpiresAt: expiresAt}, true
}
// ReadCeilingLaunchClaim preserves the compact claim reader used by callers
// that only need the owner identity.
func ReadCeilingLaunchClaim(record map[string]interface{}) (claimID, owner string, ok bool) {
claim, ok := ReadCeilingLaunchClaimDetails(record)
if !ok {
return "", "", false
}
return claim.ID, claim.Owner, true
}
// CeilingDeferralSessionID returns the durable destination session for a
// ceiling launch when one exists. Seam-1 start records predate a session and
// therefore resolve their destination from the task's committed route.
func CeilingDeferralSessionID(task *Task, deferral CeilingDeferral) string {
if binding, present, err := ReadCeilingWorkflowEntryBinding(deferral.Payload); err == nil && present {
if binding.DestinationSessionID != "" {
return binding.DestinationSessionID
}
} else if err != nil {
return ""
}
if deferral.Kind != CeilingLaunchStart {
value, _ := deferral.Payload["session_id"].(string)
return value
}
if task == nil {
return ""
}
route, present := LoadWorkflowSessionRoute(task.Metadata)
if !present {
return ""
}
return route.DestinationID
}
// CeilingDeferralTargetsSession checks the destination and, when a nested
// binding is present, the committed route identity. It is used by message
// admission as well as replay so an old task snapshot cannot preserve REVIEW
// or consume a queue entry for a successor workflow entry.
func CeilingDeferralTargetsSession(task *Task, deferral CeilingDeferral, sessionID string) bool {
if task == nil || sessionID == "" || CeilingDeferralSessionID(task, deferral) != sessionID {
return false
}
binding, present, err := ReadCeilingWorkflowEntryBinding(deferral.Payload)
if err != nil || !present {
return err == nil
}
route, routePresent := LoadWorkflowSessionRoute(task.Metadata)
if !routePresent {
// A bound record must be compared with the committed route. The task's
// step fields are not an entry identity and can already describe a
// successor after the original route was replaced.
return false
}
return route.OperationID == binding.RouteOperationID &&
route.DestinationStepID == binding.DestinationStepID &&
route.EntryIdentity == binding.EntryIdentity &&
(route.DestinationID == "" || binding.DestinationSessionID == "" || route.DestinationID == binding.DestinationSessionID)
}