Documentation
¶
Overview ¶
Package store wraps the NATS JetStream KV buckets and streams that hold all Packtrail state: executions, ownership leases, visibility indexes and the domain event stream. Every state transition is a CAS (optimistic concurrency) write.
Index ¶
- Constants
- Variables
- func InputKey(execID string) string
- func OutputKey(execID, node string) string
- func OutputVersionKey(execID, node, version string) string
- func SignalKey(execID, name string, seq uint64) string
- type ActivityResult
- type BranchState
- type DeadLetter
- type Event
- type Execution
- func (e *Execution) Active() bool
- func (e *Execution) AddOutput(node string)
- func (e *Execution) AppendSched(item json.RawMessage, at time.Time)
- func (e *Execution) AppendWork(item json.RawMessage)
- func (e *Execution) Archivable() bool
- func (e *Execution) ClearOutput(node string)
- func (e *Execution) DropOutbox(flushed map[uint64]bool) (changed bool)
- func (e *Execution) OutputVersion(node string) string
- func (e *Execution) SetOutput(node, version string)
- type Lease
- type OutboxItem
- type Store
- func (s *Store) AcquireLease(ctx context.Context, execID, owner string, ttl time.Duration) (bool, error)
- func (s *Store) ArchiveTerminal(ctx context.Context) (int, error)
- func (s *Store) ArchivedExecution(ctx context.Context, id string) (*Execution, bool, error)
- func (s *Store) CASConflicts() uint64
- func (s *Store) Create(ctx context.Context, e *Execution) (uint64, error)
- func (s *Store) CreatePayload(ctx context.Context, key string, data json.RawMessage) (existing json.RawMessage, err error)
- func (s *Store) DeadLetterCount(ctx context.Context) (uint64, error)
- func (s *Store) DeadLetters() uint64
- func (s *Store) DeletePayloads(ctx context.Context, execID string) error
- func (s *Store) DeletePayloadsOlderThan(ctx context.Context, execID string, cutoff time.Time) error
- func (s *Store) EmitDeadLetter(ctx context.Context, dl DeadLetter) error
- func (s *Store) EmitEvent(ctx context.Context, e *Execution) error
- func (s *Store) EnableArchive(ctx context.Context, retention time.Duration) error
- func (s *Store) EnableHistory(ctx context.Context, retention time.Duration) error
- func (s *Store) ForEachArchivedExecution(ctx context.Context, fn func(*Execution) error) error
- func (s *Store) ForEachExecution(ctx context.Context, fn func(*Execution) error) error
- func (s *Store) ForEachExecutionKey(ctx context.Context, fn func(string) error) error
- func (s *Store) Get(ctx context.Context, id string) (*Execution, error)
- func (s *Store) GetPayload(ctx context.Context, key string) (json.RawMessage, error)
- func (s *Store) History(ctx context.Context, execID string, limit int) ([]Event, error)
- func (s *Store) IdxFlow() jetstream.KeyValue
- func (s *Store) IdxStatus() jetstream.KeyValue
- func (s *Store) JS() jetstream.JetStream
- func (s *Store) LeaseHeld(ctx context.Context, execID string, ttl time.Duration) (bool, error)
- func (s *Store) ListExecutionKeys(ctx context.Context) ([]string, error)
- func (s *Store) Mutate(ctx context.Context, id string, fn func(*Execution) error) (*Execution, error)
- func (s *Store) Names() names.Names
- func (s *Store) PutPayload(ctx context.Context, key string, data json.RawMessage) error
- func (s *Store) RecentDeadLetters(ctx context.Context, limit int) ([]DeadLetter, error)
- func (s *Store) ReleaseLease(ctx context.Context, execID, owner string) error
- func (s *Store) SetMaxDocumentBytes(n int)
- func (s *Store) SetMaxPayloadBytes(n int)
Constants ¶
const ( StatusRunning = "running" StatusWaiting = "waiting" StatusCompleted = "completed" StatusFailed = "failed" StatusCancelled = "cancelled" )
Execution status values.
const ( BranchPending = "pending" BranchCompleted = "completed" BranchFailed = "failed" )
Branch status values.
const ( OutboxWork = "work" // publish to the execution's work subject now OutboxSched = "sched" // schedule via the Message Scheduler to fire at At )
Outbox item kinds: how a committed follow-on message reaches NATS.
const ( DeadLetterWork = "work" // the execution work consumer DeadLetterSchedule = "schedule" // the fired-schedule consumer (e.g. a removed-flow cron tick) DeadLetterSignal = "signal" // the external-signal consumer DeadLetterAsync = "async" // an async-invoker worker completion )
Dead-letter source kinds — which durable consumer dropped the poisoned work.
const DefaultMaxDocumentBytes = 768 << 10 // 768 KiB
DefaultMaxDocumentBytes caps the serialized execution control document by default. Unlike a data-plane payload, the document is small control metadata, but a very wide fanout (a BranchState per branch) or a large transient outbox can grow it toward NATS's 1 MiB ceiling — where it would otherwise fail as an opaque NATS publish error. The guard rejects it first with the typed ErrDocumentTooLarge. It sits below 1 MiB with headroom for KV/message overhead. Override with SetMaxDocumentBytes; a non-positive limit disables it.
const DefaultMaxPayloadBytes = 512 << 10 // 512 KiB
DefaultMaxPayloadBytes caps a single data-plane entry (a start input, one node's output, one signal payload) by default. It sits well below NATS's 1 MiB max message size. Override with SetMaxPayloadBytes; a non-positive limit disables the guard.
Variables ¶
var ErrAlreadyExists = errors.New("store: already exists")
ErrAlreadyExists is returned by Create when an execution with the same id already exists. Callers use it to make Create idempotent (e.g. a caller-supplied execution id that dedups a retried Start).
var ErrConflict = errors.New("store: revision conflict")
ErrConflict is returned when a CAS write loses to a concurrent writer and the caller's revision is stale.
var ErrDocumentTooLarge = errors.New("store: control document exceeds max size")
ErrDocumentTooLarge is returned when a control-document write would exceed the configured max size. Like ErrPayloadTooLarge it is rejected before it reaches NATS, so the last within-limit document stays persisted and a caller can still record a (small) failure against it.
var ErrNotFound = errors.New("store: not found")
ErrNotFound is returned when an execution key does not exist.
var ErrPayloadTooLarge = errors.New("store: payload exceeds max size")
ErrPayloadTooLarge is returned when a write would persist an execution whose payload exceeds the configured max size. The write is rejected before it reaches NATS, so the previously persisted (within-limit) state is preserved and callers can still record a failure against it.
Functions ¶
func OutputVersionKey ¶ added in v0.1.0
OutputVersionKey is the data-plane key of a candidate task or branch output. The execution document commits exactly one version per output node; uncommitted versions are harmless orphans swept with the execution's other payloads.
func SignalKey ¶ added in v0.1.0
SignalKey is the data-plane key of a received signal's payload. It is versioned by the signal's stream sequence: the control plane commits LastSeq[name] via CAS, and the payload for exactly that sequence was written first — so two deliveries of the same signal racing across instances can never leave the committed sequence pointing at the other delivery's payload. Superseded entries are garbage until DeletePayloads sweeps the execution.
Types ¶
type ActivityResult ¶
type ActivityResult struct {
Node string `json:"node"`
Generation uint64 `json:"generation,omitempty"`
Attempt int `json:"attempt"`
Status string `json:"status"`
Payload json.RawMessage `json:"payload,omitempty"`
Error string `json:"error,omitempty"`
}
ActivityResult is an async activity completion stored on the execution when it arrives before the dispatching task has persisted its waiting state (the "completion before wait" race). The parking task consumes it instead of waiting. Status mirrors the invoker status string ("ok"/"error"/"retry").
type BranchState ¶
type BranchState struct {
NodeID string `json:"node_id"`
Status string `json:"status"`
Generation uint64 `json:"generation,omitempty"` // fanout visit generation that dispatched this branch
Attempt int `json:"attempt,omitempty"` // attempts spent on this branch (async retries)
Error string `json:"error,omitempty"`
}
BranchState is the persisted control state of a single fanout branch; a completed branch's result lives in the data plane and is selected by Execution.OutputVersions when versioned.
type DeadLetter ¶ added in v0.1.0
type DeadLetter struct {
Kind string `json:"kind"`
Key string `json:"key"`
Reason string `json:"reason"`
Deliveries uint64 `json:"deliveries,omitempty"`
Time time.Time `json:"time"`
}
DeadLetter is a durable record of a poisoned work item that a consumer gave up on (Term'd) — a terminal error or an exhausted delivery cap. It is appended to the packtrail-deadletter stream so dropped work is observable (queryable and alertable) rather than vanishing into a log line. Kind identifies the source consumer; Key is its routing token (an execution id, a schedule key, or "<exec>/<node>" for an async completion).
type Event ¶
type Event struct {
ExecID string `json:"exec_id"`
FlowName string `json:"flow_name"`
Status string `json:"status"`
Node string `json:"node"`
Error string `json:"error,omitempty"`
Revision uint64 `json:"revision"`
Time time.Time `json:"time"`
}
Event is a domain event appended to the packtrail-events stream and consumed by the visibility indexer. Revision is the KV revision of the execution at the time the event was emitted, used for idempotent, per-revision indexing.
type Execution ¶
type Execution struct {
ID string `json:"id"`
FlowName string `json:"flow_name"`
InputHash string `json:"input_hash,omitempty"`
CurrentNode string `json:"current_node"`
Status string `json:"status"`
// execution-scoped visit generation for CurrentNode; increments on node entry
NodeGeneration uint64 `json:"node_generation,omitempty"`
// attempts spent on CurrentNode (task retries)
Attempt int `json:"attempt"`
// node ids with a stored output, in settle order
Outputs []string `json:"outputs,omitempty"`
OutputVersions map[string]string `json:"output_versions,omitempty"` // committed versioned output keys, per node
Branches map[string]BranchState `json:"branches,omitempty"` // active fanout/fanin branches
LastSeq map[string]uint64 `json:"last_seq,omitempty"` // last applied JetStream seq, per signal_name
// received-but-unconsumed markers, per signal_name
Signals map[string]bool `json:"signals,omitempty"`
WaitSignal string `json:"wait_signal,omitempty"` // signal_name currently awaited
Activity *ActivityResult `json:"activity,omitempty"` //nolint:lll // async completion that arrived before the task parked
Error string `json:"error,omitempty"`
RetryAt time.Time `json:"retry_at,omitzero"` //nolint:lll // when the scheduled retry of CurrentNode fires (running + Attempt > 0)
Outbox []OutboxItem `json:"outbox,omitempty"` //nolint:lll // follow-on messages committed with the last transition, pending publish
OutboxSeq uint64 `json:"outbox_seq,omitempty"`
// ArchivedRevision is persisted only in cold archive records so archived
// reconciliation can preserve event ordering from the original hot bucket.
ArchivedRevision uint64 `json:"archived_revision,omitempty"`
Revision uint64 `json:"-"` // current KV revision, for CAS (not persisted in hot values)
UpdatedAt time.Time `json:"updated_at"`
}
Execution is the runtime instance of a Flow: the control plane. It is the single source of truth for an execution's progress and is persisted in the packtrail-executions KV bucket. Payloads (start input, node outputs, signal payloads) live in the separate payloads bucket — the data plane — keyed per entry (see payloads.go); the document carries only which entries exist, so its size grows with nodes visited, never with payload bytes.
func (*Execution) AddOutput ¶ added in v0.1.0
AddOutput records that node's legacy output exists in the data plane. Call inside the Mutate callback that commits the settle; idempotent per node.
func (*Execution) AppendSched ¶ added in v0.1.0
func (e *Execution) AppendSched(item json.RawMessage, at time.Time)
AppendSched adds a scheduled delivery (firing at the absolute time at) to the execution's outbox. Call inside the Mutate callback that commits the transition requiring it.
func (*Execution) AppendWork ¶ added in v0.1.0
func (e *Execution) AppendWork(item json.RawMessage)
AppendWork adds a work-stream publish to the execution's outbox. Call inside the Mutate callback that commits the transition requiring it.
func (*Execution) Archivable ¶ added in v0.1.0
Archivable reports whether the execution is terminal and will never be mutated again, so it can be swept from the hot bucket into the cold archive. Completed and cancelled qualify; failed does not, because Resume can revive a failed execution — it must stay hot and mutable. Keeping cancelled (terminal, non-resumable) hot would otherwise accumulate forever, bloating the hot bucket and every full Reconcile scan.
func (*Execution) ClearOutput ¶ added in v0.1.0
ClearOutput removes node's selected output from the control plane. The payload object itself may remain in the data plane until execution cleanup; without the selection Results will not read it.
func (*Execution) DropOutbox ¶ added in v0.1.0
DropOutbox removes the outbox items whose Seq is in flushed, keeping any appended since (a concurrent transition may have added more).
func (*Execution) OutputVersion ¶ added in v0.1.0
OutputVersion returns the committed version for node, or "" for legacy output entries written before output versioning was introduced.
type OutboxItem ¶ added in v0.1.0
type OutboxItem struct {
Kind string `json:"kind"`
Item json.RawMessage `json:"item"` // fully-marshaled work item
At time.Time `json:"at,omitzero"` // sched only: absolute fire time (late flushes keep the original deadline)
Seq uint64 `json:"seq"` // per-execution monotonic id
}
OutboxItem is a follow-on message committed atomically with the state transition that requires it (the transactional-outbox pattern). The item is published *after* the CAS write and then cleared; a crash in between leaves it durably on the document, where any later touch (work redelivery, a completion, the stall watchdog) re-publishes it. Publishes carry a per-execution msg-id ("<execID>.<Seq>") so a re-flush inside the stream's dedup window is dropped; beyond the window a duplicate is state-safe against the guarded transitions.
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
Store provides access to all Packtrail KV buckets and streams.
func Open ¶
Open ensures every bucket and stream exists, under the given namespace, and returns a ready Store.
func (*Store) AcquireLease ¶
func (s *Store) AcquireLease(ctx context.Context, execID, owner string, ttl time.Duration) (bool, error)
AcquireLease attempts to take or renew ownership of execID for owner with the given TTL. It succeeds if the key is absent, already owned by owner, or held by another owner whose lease revision has remained unchanged for a full TTL as measured by this process's monotonic clock. The CAS guarantees that at most one distinct owner wins a single acquisition race. It returns true if the lease is now held by owner.
This bounds concurrency but is not a hard lock across time: once a lease revision stops advancing for a TTL (its holder paused or crashed) another instance can acquire it while the original is still mid-invocation. Callers must therefore treat node invocation as at-least-once (the engine renews via heartbeat and aborts on a detected loss to narrow, not eliminate, the overlap window).
func (*Store) ArchiveTerminal ¶ added in v0.1.0
ArchiveTerminal moves every archivable execution (terminal and non-resumable: completed or cancelled — see Execution.Archivable) out of the hot bucket into the cold archive, returning how many it moved. Failed executions remain hot so they can still be resumed. Each execution is written to the archive before it is deleted from the hot bucket, so a crash mid-sweep can duplicate but never lose one; a later sweep re-archives idempotently. It is a no-op when archival is disabled.
func (*Store) ArchivedExecution ¶ added in v0.1.0
ArchivedExecution loads id from the cold archive if it is currently retained. It intentionally ignores any hot-bucket value: callers use it to avoid repairing or recreating transient hot duplicates while an archived execution still owns the id.
func (*Store) CASConflicts ¶ added in v0.1.0
CASConflicts returns the cumulative number of CAS-conflict retries observed across all Mutate calls. It is a monotonic counter useful for observing write-contention pressure (e.g. wide fanout on a single execution key).
func (*Store) Create ¶
Create persists a new execution and returns its initial revision. The id is deduped against the cold archive as well as the hot bucket: a retried StartWithID whose original execution was already swept into the archive must return ErrAlreadyExists, not silently re-run the whole flow under the same idempotency key. Because the archive and hot bucket cannot be checked atomically, Create checks the archive both before and after the hot Create: if an archive sweep moved the id in the gap, the just-created hot key is deleted at its create revision and ErrAlreadyExists is returned.
func (*Store) CreatePayload ¶ added in v0.1.0
func (s *Store) CreatePayload( ctx context.Context, key string, data json.RawMessage, ) (existing json.RawMessage, err error)
CreatePayload stores a data-plane entry only if absent — first write wins. Used for the start input: an idempotent Start retry carrying a different payload must not overwrite the input the original execution runs on. When the key already exists it is left untouched and its current value is returned, so the caller can detect an id being reused with different data instead of silently binding the control plane to another caller's payload; existing is nil when this call created the entry.
func (*Store) DeadLetterCount ¶ added in v0.1.0
DeadLetterCount returns the durable number of dead-letter records currently retained in the packtrail-deadletter stream (bounded by its retention). Unlike DeadLetters it is cross-instance and survives restarts.
func (*Store) DeadLetters ¶ added in v0.1.0
DeadLetters returns the cumulative number of dead-letter records this Store instance has emitted since start. It is an in-process counter (resets on restart); for a durable, cross-instance total use DeadLetterCount, which reads the dead-letter stream's depth.
func (*Store) DeletePayloads ¶ added in v0.1.0
DeletePayloads removes every data-plane entry of one execution. Used by the archive sweep: an archived execution keeps its control metadata readable but drops its data plane.
func (*Store) DeletePayloadsOlderThan ¶ added in v0.1.0
DeletePayloadsOlderThan removes an execution's data-plane entries created before cutoff, each via a revision-guarded delete. It is the race-safe counterpart to DeletePayloads for the visibility GC, which prunes an id only after confirming the execution is gone from both hot and archive: if that id was meanwhile *recreated* (a re-Start binds the same id), the new generation's entries are young (created after cutoff, so not selected) and/or at a bumped revision (so the guarded delete no-ops), and are never wiped. A non-positive staleness (cutoff in the future) selects every entry but still revision-guards each delete, so a delete that races a recreation still no-ops.
func (*Store) EmitDeadLetter ¶ added in v0.1.0
func (s *Store) EmitDeadLetter(ctx context.Context, dl DeadLetter) error
EmitDeadLetter appends a dead-letter record to the packtrail-deadletter stream and bumps the in-process counter, so a consumer that gives up on poisoned work (Term) leaves a durable, queryable trace instead of only a log line.
func (*Store) EnableArchive ¶ added in v0.1.0
EnableArchive creates (or attaches to) the cold archive bucket with a bucket-wide retention TTL, so swept terminal executions are kept queryable for roughly retention and then expire automatically. It must be called before the archive is used; ArchiveTerminal and Get's fallback are no-ops until it runs.
func (*Store) EnableHistory ¶ added in v0.1.0
EnableHistory creates (or attaches to) the per-execution history stream with the given retention and turns on history emission: from here on every emitted domain event is also appended, best-effort, to the execution's history subject. Until it runs, EmitEvent skips history and History returns nothing. The trace is observability, not operational truth — the execution document and the events stream stay authoritative.
func (*Store) ForEachArchivedExecution ¶ added in v0.1.0
ForEachArchivedExecution streams every execution retained in the cold archive to fn. It is a no-op when archival is disabled.
func (*Store) ForEachExecution ¶ added in v0.1.0
ForEachExecution streams every stored execution to fn via a single last-per-key watch over the executions bucket, so the caller reads the whole set in one server round-trip instead of a key listing followed by a Get per id. The watch delivers each key's latest value; a nil update marks the end of the current set. A value that fails to unmarshal is skipped rather than aborting the scan. fn must not retain the *Execution past its call.
func (*Store) ForEachExecutionKey ¶ added in v0.1.0
ForEachExecutionKey streams the id of every execution in the hot bucket to fn via a metadata-only last-per-key watch, without collecting them into a slice. fn returning a non-nil error stops the scan and propagates the error, so a caller can cap the number it reads by returning a sentinel after N.
func (*Store) Get ¶
Get loads an execution and populates its Revision from the KV entry. If an archive record exists, it owns the id and wins over any transient hot duplicate left by an archive/Create race; otherwise Get reads the hot bucket. Reads of aged-out terminal executions still succeed from the archive until retention expires. The hot bucket remains the source of truth for mutations; archived executions are terminal and never mutated.
func (*Store) GetPayload ¶ added in v0.1.0
GetPayload loads one data-plane entry, or ErrNotFound.
func (*Store) History ¶ added in v0.1.0
History returns execID's transition records, oldest first, up to limit (non-positive = a generous default). Records live for the retention passed to EnableHistory; with history disabled it returns nothing.
func (*Store) JS ¶
JS exposes the underlying JetStream context for packages that manage their own consumers/streams (runtime, scheduler, signal, visibility).
func (*Store) LeaseHeld ¶ added in v0.1.0
LeaseHeld reports whether execID's ownership lease should be treated as held by a live owner — i.e. the lease key exists and its revision has not remained unchanged for a full TTL as observed by this process. Used by the stall watchdog to avoid re-driving an execution whose work is legitimately in flight.
func (*Store) ListExecutionKeys ¶
ListExecutionKeys returns all execution ids currently stored. Used by the visibility reconciler.
func (*Store) Mutate ¶
func (s *Store) Mutate(ctx context.Context, id string, fn func(*Execution) error) (*Execution, error)
Mutate runs a read-modify-write CAS loop: it loads the execution, applies fn, and writes it back, retrying the whole cycle on a concurrent-write conflict. The mutated execution (with its new revision) is returned. Mutate reads the hot bucket only (no archive fallback): archived executions are terminal and immutable, so mutating one returns ErrNotFound.
func (*Store) Names ¶
Names returns the resource names this store was opened with, so dependent packages share the same namespace.
func (*Store) PutPayload ¶ added in v0.1.0
PutPayload stores one data-plane entry, enforcing the per-entry size guard (ErrPayloadTooLarge) before the write reaches NATS.
func (*Store) RecentDeadLetters ¶ added in v0.1.0
RecentDeadLetters returns up to limit of the most recent dead-letter records, oldest-first (limit <= 0 uses a default cap). It reads the tail of the dead-letter stream via an ordered consumer, so the cost is bounded by limit, not by total volume.
func (*Store) ReleaseLease ¶
ReleaseLease drops ownership of execID if held by owner. Releasing a lease not owned by owner is a no-op.
The delete is guarded on the revision read above, so if our own heartbeat renewed the lease between the Get and the Delete the delete no-ops and the lease lingers until its Expires (≤ LeaseTTL). That is self-healing — the lease still frees on expiry — it only briefly delays a legitimate takeover; it never deletes a lease a different owner has taken over (the owner check and the revision guard both protect that).
func (*Store) SetMaxDocumentBytes ¶ added in v0.1.0
SetMaxDocumentBytes sets the maximum serialized size, in bytes, of an execution control document. A write that exceeds it is rejected with ErrDocumentTooLarge before it reaches NATS (see update). A non-positive limit disables the guard. Call before the store is used (it is not safe to change concurrently with writes).
func (*Store) SetMaxPayloadBytes ¶ added in v0.1.0
SetMaxPayloadBytes sets the maximum size, in bytes, of a single data-plane entry (a start input, one node's output, one signal payload). A write that exceeds it is rejected with ErrPayloadTooLarge before it reaches NATS (see PutPayload). A non-positive limit disables the guard. Call before the store is used (it is not safe to change concurrently with writes).