@smthrs/journal 0.0.0-stage → 1.0.0-rc.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +149 -0
- package/LICENSE +21 -0
- package/README.md +206 -2
- package/dist/cjs/CallFact.d.ts +129 -0
- package/dist/cjs/CallFact.d.ts.map +1 -0
- package/dist/cjs/CallFact.js +106 -0
- package/dist/cjs/CallFact.js.map +7 -0
- package/dist/cjs/Consensus.d.ts +440 -0
- package/dist/cjs/Consensus.d.ts.map +1 -0
- package/dist/cjs/Consensus.js +251 -0
- package/dist/cjs/Consensus.js.map +7 -0
- package/dist/cjs/EngineEvent.d.ts +890 -0
- package/dist/cjs/EngineEvent.d.ts.map +1 -0
- package/dist/cjs/EngineEvent.js +383 -0
- package/dist/cjs/EngineEvent.js.map +7 -0
- package/dist/cjs/ExecutionFact.d.ts +216 -0
- package/dist/cjs/ExecutionFact.d.ts.map +1 -0
- package/dist/cjs/ExecutionFact.js +303 -0
- package/dist/cjs/ExecutionFact.js.map +7 -0
- package/dist/cjs/Journal.d.ts +617 -0
- package/dist/cjs/Journal.d.ts.map +1 -0
- package/dist/cjs/Journal.js +190 -0
- package/dist/cjs/Journal.js.map +7 -0
- package/dist/cjs/JournalEvent.d.ts +421 -0
- package/dist/cjs/JournalEvent.d.ts.map +1 -0
- package/dist/cjs/JournalEvent.js +281 -0
- package/dist/cjs/JournalEvent.js.map +7 -0
- package/dist/cjs/JournalGeneration.d.ts +34 -0
- package/dist/cjs/JournalGeneration.d.ts.map +1 -0
- package/dist/cjs/JournalGeneration.js +72 -0
- package/dist/cjs/JournalGeneration.js.map +7 -0
- package/dist/cjs/JournalMetrics.d.ts +72 -0
- package/dist/cjs/JournalMetrics.d.ts.map +1 -0
- package/dist/cjs/JournalMetrics.js +55 -0
- package/dist/cjs/JournalMetrics.js.map +7 -0
- package/dist/cjs/Migrations.d.ts +42 -0
- package/dist/cjs/Migrations.d.ts.map +1 -0
- package/dist/cjs/Migrations.js +58 -0
- package/dist/cjs/Migrations.js.map +7 -0
- package/dist/cjs/OwnerId.d.ts +40 -0
- package/dist/cjs/OwnerId.d.ts.map +1 -0
- package/dist/cjs/OwnerId.js +40 -0
- package/dist/cjs/OwnerId.js.map +7 -0
- package/dist/cjs/Projection.d.ts +29 -0
- package/dist/cjs/Projection.d.ts.map +1 -0
- package/dist/cjs/Projection.js +25 -0
- package/dist/cjs/Projection.js.map +7 -0
- package/dist/cjs/RedactedLogger.d.ts +121 -0
- package/dist/cjs/RedactedLogger.d.ts.map +1 -0
- package/dist/cjs/RedactedLogger.js +235 -0
- package/dist/cjs/RedactedLogger.js.map +7 -0
- package/dist/cjs/Redaction.d.ts +350 -0
- package/dist/cjs/Redaction.d.ts.map +1 -0
- package/dist/cjs/Redaction.js +928 -0
- package/dist/cjs/Redaction.js.map +7 -0
- package/dist/cjs/SqlConsensus.d.ts +44 -0
- package/dist/cjs/SqlConsensus.d.ts.map +1 -0
- package/dist/cjs/SqlConsensus.js +243 -0
- package/dist/cjs/SqlConsensus.js.map +7 -0
- package/dist/cjs/SqlJournal.d.ts +173 -0
- package/dist/cjs/SqlJournal.d.ts.map +1 -0
- package/dist/cjs/SqlJournal.js +1568 -0
- package/dist/cjs/SqlJournal.js.map +7 -0
- package/dist/cjs/StepFact.d.ts +66 -0
- package/dist/cjs/StepFact.d.ts.map +1 -0
- package/dist/cjs/StepFact.js +68 -0
- package/dist/cjs/StepFact.js.map +7 -0
- package/dist/cjs/index.d.ts +95 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +63 -0
- package/dist/cjs/index.js.map +7 -0
- package/dist/cjs/internal/JournalInternalFault.d.ts +37 -0
- package/dist/cjs/internal/JournalInternalFault.d.ts.map +1 -0
- package/dist/cjs/internal/JournalInternalFault.js +43 -0
- package/dist/cjs/internal/JournalInternalFault.js.map +7 -0
- package/dist/cjs/migrations/0001_initial.d.ts +15 -0
- package/dist/cjs/migrations/0001_initial.d.ts.map +1 -0
- package/dist/cjs/migrations/0001_initial.js +54 -0
- package/dist/cjs/migrations/0001_initial.js.map +7 -0
- package/dist/cjs/migrations/0002_checkpoints.d.ts +27 -0
- package/dist/cjs/migrations/0002_checkpoints.d.ts.map +1 -0
- package/dist/cjs/migrations/0002_checkpoints.js +48 -0
- package/dist/cjs/migrations/0002_checkpoints.js.map +7 -0
- package/dist/cjs/migrations/0003_startup_index.d.ts +15 -0
- package/dist/cjs/migrations/0003_startup_index.d.ts.map +1 -0
- package/dist/cjs/migrations/0003_startup_index.js +40 -0
- package/dist/cjs/migrations/0003_startup_index.js.map +7 -0
- package/dist/cjs/migrations/0004_dedup.d.ts +15 -0
- package/dist/cjs/migrations/0004_dedup.d.ts.map +1 -0
- package/dist/cjs/migrations/0004_dedup.js +57 -0
- package/dist/cjs/migrations/0004_dedup.js.map +7 -0
- package/dist/cjs/migrations/0005_run_event_type.d.ts +15 -0
- package/dist/cjs/migrations/0005_run_event_type.d.ts.map +1 -0
- package/dist/cjs/migrations/0005_run_event_type.js +40 -0
- package/dist/cjs/migrations/0005_run_event_type.js.map +7 -0
- package/dist/cjs/migrations/0006_consensus.d.ts +22 -0
- package/dist/cjs/migrations/0006_consensus.d.ts.map +1 -0
- package/dist/cjs/migrations/0006_consensus.js +84 -0
- package/dist/cjs/migrations/0006_consensus.js.map +7 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/test/Notifying.d.ts +65 -0
- package/dist/cjs/test/Notifying.d.ts.map +1 -0
- package/dist/cjs/test/Notifying.js +62 -0
- package/dist/cjs/test/Notifying.js.map +7 -0
- package/dist/cjs/test/TestJournal.d.ts +43 -0
- package/dist/cjs/test/TestJournal.d.ts.map +1 -0
- package/dist/cjs/test/TestJournal.js +43 -0
- package/dist/cjs/test/TestJournal.js.map +7 -0
- package/dist/esm/CallFact.d.ts +129 -0
- package/dist/esm/CallFact.d.ts.map +1 -0
- package/dist/esm/CallFact.js +96 -0
- package/dist/esm/CallFact.js.map +1 -0
- package/dist/esm/Consensus.d.ts +440 -0
- package/dist/esm/Consensus.d.ts.map +1 -0
- package/dist/esm/Consensus.js +454 -0
- package/dist/esm/Consensus.js.map +1 -0
- package/dist/esm/EngineEvent.d.ts +890 -0
- package/dist/esm/EngineEvent.d.ts.map +1 -0
- package/dist/esm/EngineEvent.js +614 -0
- package/dist/esm/EngineEvent.js.map +1 -0
- package/dist/esm/ExecutionFact.d.ts +216 -0
- package/dist/esm/ExecutionFact.d.ts.map +1 -0
- package/dist/esm/ExecutionFact.js +374 -0
- package/dist/esm/ExecutionFact.js.map +1 -0
- package/dist/esm/Journal.d.ts +617 -0
- package/dist/esm/Journal.d.ts.map +1 -0
- package/dist/esm/Journal.js +323 -0
- package/dist/esm/Journal.js.map +1 -0
- package/dist/esm/JournalEvent.d.ts +421 -0
- package/dist/esm/JournalEvent.d.ts.map +1 -0
- package/dist/esm/JournalEvent.js +517 -0
- package/dist/esm/JournalEvent.js.map +1 -0
- package/dist/esm/JournalGeneration.d.ts +34 -0
- package/dist/esm/JournalGeneration.d.ts.map +1 -0
- package/dist/esm/JournalGeneration.js +65 -0
- package/dist/esm/JournalGeneration.js.map +1 -0
- package/dist/esm/JournalMetrics.d.ts +72 -0
- package/dist/esm/JournalMetrics.d.ts.map +1 -0
- package/dist/esm/JournalMetrics.js +79 -0
- package/dist/esm/JournalMetrics.js.map +1 -0
- package/dist/esm/Migrations.d.ts +42 -0
- package/dist/esm/Migrations.d.ts.map +1 -0
- package/dist/esm/Migrations.js +59 -0
- package/dist/esm/Migrations.js.map +1 -0
- package/dist/esm/OwnerId.d.ts +40 -0
- package/dist/esm/OwnerId.d.ts.map +1 -0
- package/dist/esm/OwnerId.js +33 -0
- package/dist/esm/OwnerId.js.map +1 -0
- package/dist/esm/Projection.d.ts +29 -0
- package/dist/esm/Projection.d.ts.map +1 -0
- package/dist/esm/Projection.js +13 -0
- package/dist/esm/Projection.js.map +1 -0
- package/dist/esm/RedactedLogger.d.ts +121 -0
- package/dist/esm/RedactedLogger.d.ts.map +1 -0
- package/dist/esm/RedactedLogger.js +445 -0
- package/dist/esm/RedactedLogger.js.map +1 -0
- package/dist/esm/Redaction.d.ts +350 -0
- package/dist/esm/Redaction.d.ts.map +1 -0
- package/dist/esm/Redaction.js +1584 -0
- package/dist/esm/Redaction.js.map +1 -0
- package/dist/esm/SqlConsensus.d.ts +44 -0
- package/dist/esm/SqlConsensus.d.ts.map +1 -0
- package/dist/esm/SqlConsensus.js +252 -0
- package/dist/esm/SqlConsensus.js.map +1 -0
- package/dist/esm/SqlJournal.d.ts +173 -0
- package/dist/esm/SqlJournal.d.ts.map +1 -0
- package/dist/esm/SqlJournal.js +1858 -0
- package/dist/esm/SqlJournal.js.map +1 -0
- package/dist/esm/StepFact.d.ts +66 -0
- package/dist/esm/StepFact.d.ts.map +1 -0
- package/dist/esm/StepFact.js +50 -0
- package/dist/esm/StepFact.js.map +1 -0
- package/dist/esm/index.d.ts +95 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +95 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/internal/JournalInternalFault.d.ts +37 -0
- package/dist/esm/internal/JournalInternalFault.d.ts.map +1 -0
- package/dist/esm/internal/JournalInternalFault.js +28 -0
- package/dist/esm/internal/JournalInternalFault.js.map +1 -0
- package/dist/esm/migrations/0001_initial.d.ts +15 -0
- package/dist/esm/migrations/0001_initial.d.ts.map +1 -0
- package/dist/esm/migrations/0001_initial.js +32 -0
- package/dist/esm/migrations/0001_initial.js.map +1 -0
- package/dist/esm/migrations/0002_checkpoints.d.ts +27 -0
- package/dist/esm/migrations/0002_checkpoints.d.ts.map +1 -0
- package/dist/esm/migrations/0002_checkpoints.js +38 -0
- package/dist/esm/migrations/0002_checkpoints.js.map +1 -0
- package/dist/esm/migrations/0003_startup_index.d.ts +15 -0
- package/dist/esm/migrations/0003_startup_index.d.ts.map +1 -0
- package/dist/esm/migrations/0003_startup_index.js +18 -0
- package/dist/esm/migrations/0003_startup_index.js.map +1 -0
- package/dist/esm/migrations/0004_dedup.d.ts +15 -0
- package/dist/esm/migrations/0004_dedup.d.ts.map +1 -0
- package/dist/esm/migrations/0004_dedup.js +38 -0
- package/dist/esm/migrations/0004_dedup.js.map +1 -0
- package/dist/esm/migrations/0005_run_event_type.d.ts +15 -0
- package/dist/esm/migrations/0005_run_event_type.d.ts.map +1 -0
- package/dist/esm/migrations/0005_run_event_type.js +18 -0
- package/dist/esm/migrations/0005_run_event_type.js.map +1 -0
- package/dist/esm/migrations/0006_consensus.d.ts +22 -0
- package/dist/esm/migrations/0006_consensus.d.ts.map +1 -0
- package/dist/esm/migrations/0006_consensus.js +69 -0
- package/dist/esm/migrations/0006_consensus.js.map +1 -0
- package/dist/esm/test/Notifying.d.ts +65 -0
- package/dist/esm/test/Notifying.d.ts.map +1 -0
- package/dist/esm/test/Notifying.js +62 -0
- package/dist/esm/test/Notifying.js.map +1 -0
- package/dist/esm/test/TestJournal.d.ts +43 -0
- package/dist/esm/test/TestJournal.d.ts.map +1 -0
- package/dist/esm/test/TestJournal.js +27 -0
- package/dist/esm/test/TestJournal.js.map +1 -0
- package/docs/README.md +164 -0
- package/docs/api.md +639 -0
- package/docs/concepts/compaction.md +157 -0
- package/docs/concepts/idempotency.md +127 -0
- package/docs/concepts/owner-fence.md +124 -0
- package/docs/concepts/redaction.md +218 -0
- package/docs/concepts/state-event-authority.md +159 -0
- package/docs/concepts/two-channels.md +167 -0
- package/docs/guides/commit-state-and-entry.md +114 -0
- package/docs/guides/compact-a-run.md +134 -0
- package/docs/guides/fold-a-projection.md +106 -0
- package/docs/guides/read-a-run.md +139 -0
- package/docs/guides/redact-log-output.md +94 -0
- package/docs/guides/testing.md +124 -0
- package/docs/guides/write-lifecycle-events.md +110 -0
- package/docs/installation.md +133 -0
- package/docs/quickstart.md +143 -0
- package/docs/troubleshooting.md +214 -0
- package/package.json +263 -3
- package/src/CallFact.ts +117 -0
- package/src/Consensus.ts +683 -0
- package/src/EngineEvent.ts +823 -0
- package/src/ExecutionFact.ts +433 -0
- package/src/Journal.ts +688 -0
- package/src/JournalEvent.ts +654 -0
- package/src/JournalGeneration.ts +77 -0
- package/src/JournalMetrics.ts +89 -0
- package/src/Migrations.ts +62 -0
- package/src/OwnerId.ts +42 -0
- package/src/Projection.ts +31 -0
- package/src/RedactedLogger.ts +483 -0
- package/src/Redaction.ts +1784 -0
- package/src/SqlConsensus.ts +344 -0
- package/src/SqlJournal.ts +2732 -0
- package/src/StepFact.ts +69 -0
- package/src/index.ts +109 -0
- package/src/internal/JournalInternalFault.ts +40 -0
- package/src/migrations/0001_initial.ts +41 -0
- package/src/migrations/0002_checkpoints.ts +46 -0
- package/src/migrations/0003_startup_index.ts +19 -0
- package/src/migrations/0004_dedup.ts +43 -0
- package/src/migrations/0005_run_event_type.ts +19 -0
- package/src/migrations/0006_consensus.ts +83 -0
- package/src/test/Notifying.ts +101 -0
- package/src/test/TestJournal.ts +52 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "State and event authority"
|
|
3
|
+
description: "Bounded identities, versioned engine history, explicit consumer admission, and the limits of projection recovery."
|
|
4
|
+
sidebar:
|
|
5
|
+
order: 7
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
The journal owns committed history. Engine stores still own executable run,
|
|
9
|
+
attempt, deferred and clock state. The additive `EngineEvent` contracts make
|
|
10
|
+
history safer to consume; they do not move recovery into the journal.
|
|
11
|
+
|
|
12
|
+
| Durable concern | Authority | History relationship |
|
|
13
|
+
| ----------------------------------------- | -------------------------------- | -------------------------------------------------------------- |
|
|
14
|
+
| Control admission, approvals and signals | Control records and local events | Separate outbox and receipt contract. |
|
|
15
|
+
| Execution, attempts, deferreds and clocks | Engine-owned stores | State and required events share a `DurableWriter` transaction. |
|
|
16
|
+
| Ownership leases and fences | Arbitration store | Never restored from an old projection. |
|
|
17
|
+
| Engine history and time-travel evidence | Typed journal families | Consumers validate family, version, source and lineage. |
|
|
18
|
+
| Cache and artifacts | Validated content and provenance | Cache loss does not erase authoritative completion. |
|
|
19
|
+
| UI and listing summaries | Read projections | Rebuildability must be demonstrated for each projection. |
|
|
20
|
+
|
|
21
|
+
## Shared boundary primitives
|
|
22
|
+
|
|
23
|
+
`JournalEvent` exports `RunId`, `LineageId`, `WaitId`, `CommandId`, `PlanId`,
|
|
24
|
+
`DispatchId` and `ArtifactId`. Each offers a validating `.make(value)`
|
|
25
|
+
constructor and a named Effect decoder, such as `decodeRunId(unknown)`.
|
|
26
|
+
Identifiers preserve bytes, require 1 through 1,024 UTF-16 code units, and
|
|
27
|
+
refuse NUL and unpaired surrogates. Plan, dispatch and artifact brands are
|
|
28
|
+
distinct, even when their encoded strings happen to match.
|
|
29
|
+
|
|
30
|
+
`NonNegativeQuantity` and `TimestampMs` admit safe integers from zero through
|
|
31
|
+
`Number.MAX_SAFE_INTEGER`. `PositiveQuantity` starts at one. Fractions,
|
|
32
|
+
infinity, string coercion and missing-value defaults are refused. Existing
|
|
33
|
+
journal sequences retain their stricter exclusive upper bound.
|
|
34
|
+
|
|
35
|
+
## Engine families and consumer admission
|
|
36
|
+
|
|
37
|
+
`@smthrs/journal/EngineEvent` keeps the journal's generic envelope open while
|
|
38
|
+
defining two version-2 families:
|
|
39
|
+
|
|
40
|
+
| Event type | Typed payload |
|
|
41
|
+
| ----------------------------------- | ------------------------------------------------------------------------------------------ |
|
|
42
|
+
| `flows.engine.v2.attempt-lifecycle` | Execution, dispatch, attempt number and running, suspended, succeeded or failed lifecycle. |
|
|
43
|
+
| `flows.engine.v2.state-event` | Execution lifecycle, deferred completion or an absolute clock schedule. |
|
|
44
|
+
|
|
45
|
+
Both payloads require version, run, lineage id, root run, round and parent
|
|
46
|
+
where applicable. The committed journal envelope supplies global sequence,
|
|
47
|
+
source identity, source sequence and emission time. Root runs have round zero
|
|
48
|
+
and no parent; derived runs name a distinct parent and root. A continuation
|
|
49
|
+
requires a positive round. Diagnostic `meta` is encoded JSON and grants no
|
|
50
|
+
semantic authority.
|
|
51
|
+
|
|
52
|
+
`decodeEntry(input, consumer)` requires an explicit expected run, lineage,
|
|
53
|
+
root, round, parent and source allowlist. It returns a typed `Attempt` or
|
|
54
|
+
`State`. Conflicting identity fails `EventError` with code `foreign`.
|
|
55
|
+
Malformed supported families fail `malformed`, preserving the original
|
|
56
|
+
schema or accessor error in `cause`. Unsupported families and versions in
|
|
57
|
+
`flows.engine.*` fail `unsupported`. Other namespaces follow the consumer's
|
|
58
|
+
explicit `unknown: "ignore" | "surface"` policy. Source and run admission
|
|
59
|
+
still applies to those extensions.
|
|
60
|
+
|
|
61
|
+
Running and suspended attempts cannot contain completion fields. A terminal
|
|
62
|
+
attempt requires its timestamp and a matching encoded success or failure.
|
|
63
|
+
Failure reasons distinguish typed error, defect, interruption and encoding
|
|
64
|
+
failure. Values must be JSON after the value's own codec has run; a class
|
|
65
|
+
instance accepted by `Schema.Unknown` is not an encoded result. Wall clocks
|
|
66
|
+
can move backwards, so completion timestamps need not exceed start time.
|
|
67
|
+
Suspended executions require at least one typed wait; completed executions
|
|
68
|
+
require a result. Ownership and cancellation remain separate facts.
|
|
69
|
+
|
|
70
|
+
## Node and plan records
|
|
71
|
+
|
|
72
|
+
Two executors drive a graph: the plan scheduler and the flow interpreter. Both
|
|
73
|
+
write the same six records, and `EngineEvent` publishes a schema for each so a
|
|
74
|
+
reader decodes one shape rather than guessing per writer.
|
|
75
|
+
|
|
76
|
+
| Event type | Typed payload |
|
|
77
|
+
| -------------------------------- | ---------------------------------------------------------------- |
|
|
78
|
+
| `flows.engine.plan-recorded` | `PlanRecordedPayload`: the flow, the generation, the node count. |
|
|
79
|
+
| `flows.engine.subgraph-appended` | `SubgraphAppendedPayload`: the ids a generation or a page added. |
|
|
80
|
+
| `flows.engine.node-scheduled` | `NodeScheduledPayload`: the node, its kind, attempt and tag. |
|
|
81
|
+
| `flows.engine.node-settled` | `NodeSettledPayload`: the outcome, the attempts, the join. |
|
|
82
|
+
| `flows.engine.node-invalidated` | `NodeInvalidatedPayload`: the re-keying and the reason for it. |
|
|
83
|
+
| `flows.engine.node-reconciled` | `NodeReconciledPayload`: the verdict for one deviation. |
|
|
84
|
+
|
|
85
|
+
`nodes` on a plan record is the node COUNT it has always been. The node list
|
|
86
|
+
rides beside it as `graph`, optional and paged, because a journal entry has a
|
|
87
|
+
byte bound and a record that clipped its list would lose nodes silently.
|
|
88
|
+
`graph.edges` is optional for the same reason a fact is optional anywhere here:
|
|
89
|
+
a plan names its edges through each node's `dependsOn` and knows no reason for
|
|
90
|
+
them, while an interpreted graph knows the reason it drew each one.
|
|
91
|
+
|
|
92
|
+
A node's `declaredAt` is where its action was written. It is repo-relative by
|
|
93
|
+
contract and the schema refuses an absolute path: a journal is read on machines
|
|
94
|
+
that did not write it, so an absolute path is at best noise and at worst an
|
|
95
|
+
operator's home directory published into a run's history. A writer that cannot
|
|
96
|
+
make a path relative omits the field. `EngineEvent.relativePath(root, path)` is
|
|
97
|
+
how a writer obeys that rule: it answers the path relative to the root, or
|
|
98
|
+
nothing at all for a path outside the root, for no root, and for a root that
|
|
99
|
+
names the whole filesystem. Every writer of a declaration site uses it, the
|
|
100
|
+
engine's node records and a control plan card alike.
|
|
101
|
+
|
|
102
|
+
The five settlement words are `built`, `clean`, `failed`, `skipped` and
|
|
103
|
+
`deferred`. `clean` means a recorded result served the node and no executor
|
|
104
|
+
ran, which covers same-run durable replay as well as a cross-run cache hit.
|
|
105
|
+
|
|
106
|
+
A settlement's `attempts` is the executor's own count. For the plan scheduler
|
|
107
|
+
it is the node's; for the interpreter it is the highest durable attempt any of
|
|
108
|
+
the node's dispatches ran as, because the interpreter settles each node once
|
|
109
|
+
and a retry happens underneath it inside one dispatch.
|
|
110
|
+
|
|
111
|
+
`stepKeyDigests` is the join. An attempt record carries a step key digest and
|
|
112
|
+
no node id, so before it an attempt belonged to no node and "attempt 2" could
|
|
113
|
+
not be attributed. A settlement now names the distinct step keys its node
|
|
114
|
+
dispatched under, and an attempt record belongs to the node of the same
|
|
115
|
+
execution whose settlement claims its digest. A retried dispatch keeps ONE
|
|
116
|
+
digest, because the attempt is folded into no key, so the list counts
|
|
117
|
+
dispatches and not attempts. A node that dispatched nothing records an empty
|
|
118
|
+
list, which is a different statement from a writer that derives no digests and
|
|
119
|
+
omits the field.
|
|
120
|
+
|
|
121
|
+
`result` is what the node settled with: the success value for `built` and
|
|
122
|
+
`clean`, the typed failure for `failed`. It is a bounded, redacted `preview`
|
|
123
|
+
of the value's JSON, the `bytes` of the encoding that preview was cut from,
|
|
124
|
+
and a `truncated` flag that is also the warning that the text is a prefix of
|
|
125
|
+
JSON and no longer parses. The writer redacts before it truncates, because
|
|
126
|
+
cutting first can split a credential across the boundary and the textual rules
|
|
127
|
+
would no longer recognise what is left; a value too large to redact at all is
|
|
128
|
+
named by the size of its own encoding, with an empty preview. A writer that
|
|
129
|
+
kept no summary omits the field.
|
|
130
|
+
|
|
131
|
+
## Additive cutover and retained history
|
|
132
|
+
|
|
133
|
+
Current engine writers are unchanged. `decodeCurrentAttempt` validates their
|
|
134
|
+
actual started/finished markers, using the recorded metadata lineage. It does
|
|
135
|
+
not invent a root, round, timestamp or result absent from an old row. That
|
|
136
|
+
adapter is explicitly separate from version-2 admission. Generic store
|
|
137
|
+
extension points remain generic.
|
|
138
|
+
|
|
139
|
+
A writer cutover must persist complete lineage and completion evidence,
|
|
140
|
+
introduce the new family identity, and write state plus event in the same
|
|
141
|
+
transaction. Old histories retain their original bytes and decoder. Backfill
|
|
142
|
+
requires authoritative state evidence and an explicit migration; an old
|
|
143
|
+
finished marker alone cannot backfill a result. No database column or journal
|
|
144
|
+
migration is needed for the additive schemas themselves.
|
|
145
|
+
|
|
146
|
+
The engine's additive attempt projection demonstrates full-history and
|
|
147
|
+
snapshot-plus-suffix equality against real attempt rows at 2 and 70 attempts.
|
|
148
|
+
Its tests drop the projection in a subprocess, kill that process, reopen
|
|
149
|
+
SQLite with a fresh connection and rebuild it. A version-2 snapshot binds its
|
|
150
|
+
rows to lineage and a covering sequence; duplicate identities, foreign runs
|
|
151
|
+
and rows past the covering sequence are refused. Compaction requires that
|
|
152
|
+
validated snapshot before reading the surviving suffix.
|
|
153
|
+
|
|
154
|
+
This proof covers the disclosed attempt projection. Journal redaction can
|
|
155
|
+
change result content, so it does not prove executable recovery of arbitrary
|
|
156
|
+
private values. Retain the authoritative stores, their backup, and operational
|
|
157
|
+
fences. A concrete sync consumer still owns atomic snapshot application and
|
|
158
|
+
its applied cursor. Extending retention to a public sync projection requires
|
|
159
|
+
its own authorization and retention policy.
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "The two channels"
|
|
3
|
+
description: "Why the journal has a lossless lifecycle channel and a lossy telemetry channel, what each receipt means, and why sequence gaps are valid."
|
|
4
|
+
sidebar:
|
|
5
|
+
order: 1
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
The journal has one table and two ways into it. The split is deliberate: a
|
|
9
|
+
lifecycle fact and a telemetry sample have different costs when they are lost,
|
|
10
|
+
so they get different guarantees and the caller picks.
|
|
11
|
+
|
|
12
|
+
## The durable channel
|
|
13
|
+
|
|
14
|
+
`emitDurable(input, owner)` and `emitDurableUnfenced(input)` open a write
|
|
15
|
+
transaction inline, allocate the sequence inside it, insert the row, and return
|
|
16
|
+
only after the transaction commits. The returned `seq` is therefore already on
|
|
17
|
+
disk.
|
|
18
|
+
|
|
19
|
+
The receipt type says the rest. `DurableReceipt` is `Accepted | Duplicate`, so
|
|
20
|
+
a dropped lifecycle event is unrepresentable: the write commits, collapses onto
|
|
21
|
+
an identical earlier write, or fails with a typed `JournalError`.
|
|
22
|
+
|
|
23
|
+
Use this channel for anything a run's correctness depends on. A durable
|
|
24
|
+
boundary must not advance a run or expose its result until the lifecycle entry
|
|
25
|
+
that describes the move has committed.
|
|
26
|
+
|
|
27
|
+
## The lossy channel
|
|
28
|
+
|
|
29
|
+
`emitLossy(input)` validates the event, allocates its sequences from an
|
|
30
|
+
in-memory clock, and hands it to a bounded queue that one scoped writer drains
|
|
31
|
+
in batches. The call returns before SQL commits. Its `seq` is provisional until
|
|
32
|
+
commit; `sourceSeq` remains the stable producer identity.
|
|
33
|
+
|
|
34
|
+
`EmitReceipt` is `Accepted | Duplicate | Dropped`, and which of those you can
|
|
35
|
+
see depends on the layer's `overflow` policy:
|
|
36
|
+
|
|
37
|
+
| `overflow` | A full queue produces |
|
|
38
|
+
| ------------- | -------------------------------------------------------------------- |
|
|
39
|
+
| `reject` | a `queue_overflow` failure |
|
|
40
|
+
| `drop-newest` | a `Dropped` receipt for the event just offered |
|
|
41
|
+
| `drop-oldest` | an `Accepted` receipt whose `evicted` field counts what it displaced |
|
|
42
|
+
|
|
43
|
+
Use this channel for telemetry: spans, progress, per-frame samples. A process
|
|
44
|
+
crash can lose an accepted but unwritten entry, and that is the trade the
|
|
45
|
+
channel exists to make.
|
|
46
|
+
|
|
47
|
+
`emitLossy` opens no transaction and performs no deduplication lookup in SQL.
|
|
48
|
+
New allocation is read-free only when its allocation floors are warmed. On a cold
|
|
49
|
+
run, it reads `MAX(seq) + 1` from the run's durable entries. If `sourceSeq` is
|
|
50
|
+
omitted and the producer floor is uncached, it also reads
|
|
51
|
+
`MAX(source_seq) + 1` for `(runId, sourceId)`. An explicit `sourceSeq` avoids
|
|
52
|
+
the producer-floor read, but does not warm the run floor by itself.
|
|
53
|
+
|
|
54
|
+
A retained duplicate needs no allocation floors and returns from the identity
|
|
55
|
+
cache even when those floors are cold. For a new admission, cold floor reads
|
|
56
|
+
run outside the run permit. Admission then checks the compaction gate,
|
|
57
|
+
rechecks the producer identity, and takes the maximum of each read floor and
|
|
58
|
+
its current in-memory floor before reserving sequences. A batch holding the
|
|
59
|
+
database transaction can therefore drain while a new producer waits for SQL.
|
|
60
|
+
Top-level compaction drains accepted work with admission open, then acquires
|
|
61
|
+
its SQL transaction before its run locks. It checks the drain again before closing the
|
|
62
|
+
admission gate and reading the checkpoint. If work arrived while it waited for
|
|
63
|
+
SQL, compaction releases the transaction and drains again. It never holds a run
|
|
64
|
+
permit or a closed gate while waiting for a database connection. Producers in
|
|
65
|
+
an enclosing transaction can therefore finish admission and release SQL before
|
|
66
|
+
compaction closes the gate. Continuous admissions can postpone compaction until
|
|
67
|
+
the run drains; accepted work takes precedence over truncation.
|
|
68
|
+
|
|
69
|
+
Compaction inside an enclosing managed or raw SQL transaction already owns the
|
|
70
|
+
connection. It cannot wait for the queued writer. Instead, it inserts this run's
|
|
71
|
+
pending lossy entries inline before truncation, including the batch waiting for
|
|
72
|
+
SQL. It uses the same transactional insert path as the batch writer. Queue
|
|
73
|
+
settlement stays with that writer: an outer rollback leaves the entries queued,
|
|
74
|
+
and an outer commit makes their later batch attempts collapse by identity.
|
|
75
|
+
Managed transactions publish only after the outer commit; raw SQL transactions
|
|
76
|
+
leave replay authoritative, as with durable emits.
|
|
77
|
+
|
|
78
|
+
A pending reservation below the chosen checkpoint moves above the durable tail
|
|
79
|
+
when inserted inline. It survives replay after the checkpoint, with unchanged
|
|
80
|
+
producer identity and content checks. Reservations below a previously compacted
|
|
81
|
+
floor retain the existing loss behavior. Durable writers register as active
|
|
82
|
+
only after acquiring SQL; a writer waiting for the compactor's connection has
|
|
83
|
+
allocated no canonical sequence and inserts above the surviving tail later.
|
|
84
|
+
|
|
85
|
+
Inside `Journal.transact` or `DurableWriter.write` using the same SQL client,
|
|
86
|
+
these floor reads join the caller's transaction. A raw `sql.withTransaction`
|
|
87
|
+
on that client also supplies the read transaction, but lossy admission remains
|
|
88
|
+
a separate queued write: it is not atomic with the caller's commit or rollback.
|
|
89
|
+
For atomic state and event writes, use
|
|
90
|
+
[`transact` with `emitDurable`](../guides/commit-state-and-entry.md).
|
|
91
|
+
|
|
92
|
+
If another transaction holds the shared connection without passing its SQL
|
|
93
|
+
transaction context to the admission, a cold floor read can wait on that
|
|
94
|
+
connection while its holder waits on the caller. Only warmed allocations avoid
|
|
95
|
+
that read dependency. Admit outside that transaction when the required floors
|
|
96
|
+
may be cold, including after truncation invalidates them. Do not wait for
|
|
97
|
+
`flush` while holding a transaction the queued writer needs.
|
|
98
|
+
|
|
99
|
+
## flush is the lossy barrier only
|
|
100
|
+
|
|
101
|
+
`flush` waits for everything currently queued to reach disk. It says nothing
|
|
102
|
+
about the durable channel, which was already committed when its receipt
|
|
103
|
+
arrived.
|
|
104
|
+
|
|
105
|
+
A producer still waiting for a cold allocation-floor read has not queued an
|
|
106
|
+
entry. Await its `emitLossy` receipt before flushing to cover that entry. If
|
|
107
|
+
the journal closes during this admission wait, the emit fails with
|
|
108
|
+
`journal_closed` after the wait instead of offering to the closed queue.
|
|
109
|
+
|
|
110
|
+
A batch the writer cannot persist is lost and reported once: to the `flush`
|
|
111
|
+
waiters that covered it, to live `stream` consumers that were following at the
|
|
112
|
+
time, and, if nobody was waiting, to the next `flush`. The writer fiber
|
|
113
|
+
survives the failure. Entries queued behind the lost batch stay outstanding, so
|
|
114
|
+
a later `flush` still waits for them rather than vouching for unpersisted work.
|
|
115
|
+
|
|
116
|
+
Never call `flush` inside `transact`. It waits on the queued writer while your
|
|
117
|
+
transaction holds the write lock the writer needs.
|
|
118
|
+
|
|
119
|
+
## Two sequence domains
|
|
120
|
+
|
|
121
|
+
Every admitted event gets two numbers, and they answer different questions.
|
|
122
|
+
|
|
123
|
+
- `seq` is the run's canonical durable order. Replay, paging, streams, and
|
|
124
|
+
projections all use it, and it is shared by both channels: a durable entry
|
|
125
|
+
and a lossy entry from the same run interleave in one history.
|
|
126
|
+
- `sourceSeq` is per `(runId, sourceId)`. It identifies a producer's retries
|
|
127
|
+
and is the idempotency key. A producer may supply its own; otherwise the
|
|
128
|
+
journal allocates the next one.
|
|
129
|
+
|
|
130
|
+
## Commit ordering
|
|
131
|
+
|
|
132
|
+
Both channels allocate canonical `seq` inside the write transaction, above the
|
|
133
|
+
run's committed tail. The lossy writer uses its admission reservation as a
|
|
134
|
+
minimum. If a durable commit or another journal instance overtakes that
|
|
135
|
+
reservation, the queued entry moves forward when it commits. For example,
|
|
136
|
+
lossy admission `0`, followed by durable commit `1`, commits the lossy entry at
|
|
137
|
+
`2`. Replay and followers then deliver `1, 2`.
|
|
138
|
+
|
|
139
|
+
The durable tail is therefore a committed-prefix watermark: a pending entry
|
|
140
|
+
cannot later commit below it. Readers can advance to the last returned row
|
|
141
|
+
without skipping later commits, including commits from independent connections.
|
|
142
|
+
No persisted reservation or local pending-writer registry is needed.
|
|
143
|
+
|
|
144
|
+
Use committed entries or committed duplicate receipts for paging and checkpoint
|
|
145
|
+
cursors. An `Accepted` lossy receipt or a pending `Duplicate` carries the
|
|
146
|
+
provisional reservation, not a durable cursor. `sourceSeq` and event identity do
|
|
147
|
+
not change when the canonical sequence moves. The compaction guard still drops
|
|
148
|
+
queued reservations at or below an already compacted boundary and reports the
|
|
149
|
+
loss through `flush`.
|
|
150
|
+
|
|
151
|
+
## Gaps are valid
|
|
152
|
+
|
|
153
|
+
A rejected or dropped admission still consumes both allocations. Allocation is
|
|
154
|
+
`MAX(seq) + 1` and replay is `ORDER BY seq`, so nothing reads a gap as
|
|
155
|
+
anything: a missing number is not a missing entry, and no reader should treat
|
|
156
|
+
it as one.
|
|
157
|
+
|
|
158
|
+
The one receipt that consumes neither allocation is a `Duplicate` the
|
|
159
|
+
in-process producer index still holds. It returns the original event's
|
|
160
|
+
`seq`, provisional while pending and canonical after commit.
|
|
161
|
+
|
|
162
|
+
## Where the durable channel is fenced
|
|
163
|
+
|
|
164
|
+
`emitDurable` requires an `OwnerId` and refuses to write behind a live
|
|
165
|
+
successor. That is the subject of [the owner fence](./owner-fence.md).
|
|
166
|
+
`emitDurableUnfenced` is the same durability with no fence, for an admission
|
|
167
|
+
that is genuinely ownerless.
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Commit state and its entry together"
|
|
3
|
+
description: "Use Journal.transact to append a lifecycle entry in the same write transaction as the state transition it describes, and learn the three rules that keeps."
|
|
4
|
+
sidebar:
|
|
5
|
+
order: 2
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
Committing an entry makes that entry durable. It does not by itself make a
|
|
9
|
+
host's whole view crash-consistent, because the executable state lives in other
|
|
10
|
+
stores: `RunStore` and `AttemptStore` in
|
|
11
|
+
[`@smthrs/run-store`](/api/run-store), `CacheStore` in
|
|
12
|
+
[`@smthrs/step-cache`](/api/step-cache), and `DurableEngineState` in
|
|
13
|
+
[`@smthrs/engine-store`](/api/engine-store).
|
|
14
|
+
|
|
15
|
+
`transact` closes that seam. It runs a state transition and the `emitDurable`
|
|
16
|
+
calls describing it in one write transaction, so either both land or neither
|
|
17
|
+
does.
|
|
18
|
+
|
|
19
|
+
## Wrap the transition and the entry
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { Journal, JournalEvent } from "@smthrs/journal"
|
|
23
|
+
import { RunStore } from "@smthrs/run-store"
|
|
24
|
+
import * as Effect from "effect/Effect"
|
|
25
|
+
|
|
26
|
+
const finish = Effect.gen(function*() {
|
|
27
|
+
const journal = yield* Journal.Journal
|
|
28
|
+
const runs = yield* RunStore.RunStore
|
|
29
|
+
|
|
30
|
+
return yield* journal.transact(Effect.gen(function*() {
|
|
31
|
+
const receipt = yield* journal.emitDurable({
|
|
32
|
+
runId,
|
|
33
|
+
sourceId,
|
|
34
|
+
sourceSeq: 7 as JournalEvent.SourceSeq,
|
|
35
|
+
eventType: "run.finished",
|
|
36
|
+
payload: { outcome: "succeeded" }
|
|
37
|
+
}, owner)
|
|
38
|
+
const result = yield* runs.transitionOwned(runId, owner, "completed")
|
|
39
|
+
if (result._tag !== "Transitioned") return yield* Effect.fail(result)
|
|
40
|
+
return receipt
|
|
41
|
+
}))
|
|
42
|
+
})
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The fenced append runs while the owned row is still running. Transitioning to
|
|
46
|
+
`completed` first invalidates the journal fence. Both stores write through
|
|
47
|
+
the same `DurableWriter`, so the entry and terminal transition join this
|
|
48
|
+
transaction as savepoints and commit or roll back together. If the transition
|
|
49
|
+
is rejected, the entry rolls back too.
|
|
50
|
+
|
|
51
|
+
## Three rules the transaction keeps
|
|
52
|
+
|
|
53
|
+
**Publication follows commit.** Inside a transaction, `emitDurable` returning
|
|
54
|
+
means a savepoint was released, not that anything is visible. The `changes` and
|
|
55
|
+
`stream` publish and the in-process producer index update are parked until the
|
|
56
|
+
outermost transaction commits. A subscriber therefore never sees an entry that
|
|
57
|
+
later rolls back, and a rolled-back producer identity stays re-emittable
|
|
58
|
+
instead of deduplicating against a sequence that does not exist.
|
|
59
|
+
|
|
60
|
+
**Only storage work belongs inside.** The transaction is held for the whole
|
|
61
|
+
body. No flow bodies, no host calls, no waits. In particular, never call
|
|
62
|
+
`flush` inside `transact`: it waits on the lossy writer, which is waiting on
|
|
63
|
+
the transaction you are holding.
|
|
64
|
+
|
|
65
|
+
**Nesting is a savepoint.** An inner `transact` becomes a savepoint of the
|
|
66
|
+
outer one and defers its publication to the outermost commit. If its failure
|
|
67
|
+
is caught by the outer transaction, its publication is discarded even when
|
|
68
|
+
the outer transaction succeeds. This also holds when another store opens the
|
|
69
|
+
outer transaction through the same `DurableWriter` and SQL client.
|
|
70
|
+
|
|
71
|
+
Projections can register short, non-failing cache updates with the service's
|
|
72
|
+
`journal.whenCommitted(update)`. It uses the shared database commit owner and
|
|
73
|
+
is bound to this journal's database: an unrelated database's nested commit
|
|
74
|
+
cannot publish its updates. Outside a transaction the update runs immediately;
|
|
75
|
+
inside a managed transaction it waits for the outer commit. Its `true` result
|
|
76
|
+
means the update was accepted, not necessarily already executed. A `false`
|
|
77
|
+
result means ownership is unknown; skip optional cache updates then. Raw
|
|
78
|
+
`sql.withTransaction` and raw savepoints are not owned by this hook. Journal
|
|
79
|
+
writes in them remain durable on commit, but skip process-local publication;
|
|
80
|
+
`entries` and the polling `stream` still read the authoritative SQL history.
|
|
81
|
+
Use `transact` or `DurableWriter.write` when immediate `changes` notifications
|
|
82
|
+
are required.
|
|
83
|
+
|
|
84
|
+
The lower-level `Journal.afterCommit` re-export has the database helper's
|
|
85
|
+
different outside-transaction contract: it returns `false` without publishing.
|
|
86
|
+
Test doubles implementing transactions must preserve commit/rollback semantics
|
|
87
|
+
for `whenCommitted`, not run updates immediately from transaction bodies.
|
|
88
|
+
|
|
89
|
+
## What a transaction does not buy
|
|
90
|
+
|
|
91
|
+
A crash before COMMIT loses the whole unit, so work that had already run, an
|
|
92
|
+
action body for instance, re-executes on the next drive. Design the body to
|
|
93
|
+
tolerate that.
|
|
94
|
+
|
|
95
|
+
And no local transaction makes a remote effect atomic. External effects still
|
|
96
|
+
need idempotency keys, fencing tokens, or compensation.
|
|
97
|
+
|
|
98
|
+
## A note on retries
|
|
99
|
+
|
|
100
|
+
The pinned Node SQLite driver uses `BEGIN IMMEDIATE`, acquiring its writer
|
|
101
|
+
lock before reading allocation floors. A caller-supplied SQL client may use
|
|
102
|
+
DEFERRED transactions; under WAL its lock upgrade can fail with
|
|
103
|
+
`SQLITE_BUSY_SNAPSHOT`. The shared writer retries transient contention at the
|
|
104
|
+
whole-transaction boundary in either case, floor read included.
|
|
105
|
+
|
|
106
|
+
A `transact` body must therefore be safe to run more than once. Process-local
|
|
107
|
+
publication belongs in `afterCommit`, outside that retry loop.
|
|
108
|
+
|
|
109
|
+
## Next steps
|
|
110
|
+
|
|
111
|
+
- [The two channels](../concepts/two-channels.md) for why the durable channel
|
|
112
|
+
returns only after commit.
|
|
113
|
+
- [Durable execution](/docs/concepts/durable-execution/) for the execution
|
|
114
|
+
model this transaction supports.
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Compact a long-running run"
|
|
3
|
+
description: "Checkpoint a run's replay state, truncate the entries below it, resync a reader that fails with compacted, and hand the work to the automatic policy."
|
|
4
|
+
sidebar:
|
|
5
|
+
order: 5
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
Compaction drops the entries a run no longer needs to replay. It is off by
|
|
9
|
+
default, so nothing here happens unless you ask for it.
|
|
10
|
+
|
|
11
|
+
Every step is fenced: `checkpoint` and `compact` both take an `OwnerId` and
|
|
12
|
+
both need the run claimed and activated through the journal's `Consensus`
|
|
13
|
+
strategy, which [`@smthrs/run-store`](/api/run-store) drives. See
|
|
14
|
+
[Installation](../installation.md#what-a-fenced-write-needs).
|
|
15
|
+
|
|
16
|
+
## Checkpoint the replay state
|
|
17
|
+
|
|
18
|
+
Capture whatever your reader needs to resume, and pin it to a committed
|
|
19
|
+
sequence:
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { Journal } from "@smthrs/journal"
|
|
23
|
+
import * as Effect from "effect/Effect"
|
|
24
|
+
|
|
25
|
+
const checkpointTail = Effect.gen(function*() {
|
|
26
|
+
const journal = yield* Journal.Journal
|
|
27
|
+
|
|
28
|
+
const page = yield* journal.entries({ runId, limit: 1000 })
|
|
29
|
+
const tip = page.entries.at(-1)
|
|
30
|
+
if (tip === undefined) return
|
|
31
|
+
|
|
32
|
+
yield* journal.checkpoint({ runId, seq: tip.seq, state: { started: 3, finished: 2 } }, owner)
|
|
33
|
+
})
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`state` round-trips verbatim; the journal never interprets it and never redacts
|
|
37
|
+
it. A value that must not persist belongs in a `Redacted` field of your own
|
|
38
|
+
state schema.
|
|
39
|
+
|
|
40
|
+
The sequence must name a committed entry and must lie above the run's current
|
|
41
|
+
floor, or the write fails `checkpoint_invalid`. Re-checkpointing an uncompacted
|
|
42
|
+
sequence replaces its state.
|
|
43
|
+
|
|
44
|
+
## Truncate below it
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
const truncate = Effect.gen(function*() {
|
|
48
|
+
const journal = yield* Journal.Journal
|
|
49
|
+
const compacted = yield* journal.compact({ runId }, owner)
|
|
50
|
+
console.log(compacted.deleted, "entries removed below", compacted.checkpointSeq)
|
|
51
|
+
})
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Omit `upTo` to compact to the run's latest checkpoint, or pass a specific
|
|
55
|
+
sequence. The deletion and the floor advance commit atomically. The checkpointed
|
|
56
|
+
entry itself survives: it holds the run's allocation floor, so a restarted
|
|
57
|
+
process can never re-allocate a truncated sequence.
|
|
58
|
+
|
|
59
|
+
A retried compaction returns `deleted: 0` rather than failing.
|
|
60
|
+
|
|
61
|
+
## Handle a refusal
|
|
62
|
+
|
|
63
|
+
`compact` refuses in three typed ways, and each asks for something different:
|
|
64
|
+
|
|
65
|
+
- `checkpoint_invalid`: the run has no checkpoint to truncate below, or none at
|
|
66
|
+
the sequence you named. Write one first.
|
|
67
|
+
- `reader_behind`: a live in-process `stream` still needs a sequence the
|
|
68
|
+
truncation would delete. `checkpointSeq` carries the checkpoint it declined
|
|
69
|
+
to truncate below. Wait for the reader, or drop it.
|
|
70
|
+
- `fence_lost`: another process owns the run. Stop; a zombie owner must not
|
|
71
|
+
truncate history behind a live successor.
|
|
72
|
+
|
|
73
|
+
## Resync a reader
|
|
74
|
+
|
|
75
|
+
A reader whose cursor is below the floor fails with `compacted`, carrying the
|
|
76
|
+
floor in `checkpointSeq`. Recovery is the checkpoint plus the tail after it:
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
import * as Option from "effect/Option"
|
|
80
|
+
import * as Stream from "effect/Stream"
|
|
81
|
+
|
|
82
|
+
const resync = Effect.gen(function*() {
|
|
83
|
+
const journal = yield* Journal.Journal
|
|
84
|
+
return yield* journal.stream({ runId }).pipe(
|
|
85
|
+
Stream.catchTag("@smthrs/journal/JournalError", (failure) =>
|
|
86
|
+
failure.code === "compacted"
|
|
87
|
+
? Stream.unwrap(Effect.gen(function*() {
|
|
88
|
+
const checkpoint = yield* journal.latestCheckpoint(runId)
|
|
89
|
+
if (Option.isNone(checkpoint)) return Stream.fail(failure)
|
|
90
|
+
return journal.stream({ runId, afterSequence: checkpoint.value.seq })
|
|
91
|
+
}))
|
|
92
|
+
: Stream.fail(failure)),
|
|
93
|
+
Stream.take(100),
|
|
94
|
+
Stream.runCollect
|
|
95
|
+
)
|
|
96
|
+
})
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`latestCheckpoint` returns the run's most recent checkpoint whether or not it
|
|
100
|
+
has been compacted, so the same call serves both cases.
|
|
101
|
+
|
|
102
|
+
## Hand it to the policy
|
|
103
|
+
|
|
104
|
+
Set `compaction` on the layer to make this automatic:
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
import { SqlJournal } from "@smthrs/journal"
|
|
108
|
+
|
|
109
|
+
const layer = SqlJournal.layer({
|
|
110
|
+
capacity: 1024,
|
|
111
|
+
overflow: "reject",
|
|
112
|
+
compaction: {
|
|
113
|
+
entryThreshold: 5_000,
|
|
114
|
+
capture: (runId, upTo) => captureReplayState(runId, upTo)
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Once a run's committed entry count reaches `entryThreshold`, the journal
|
|
120
|
+
captures your replay state at the run's durable tail, checkpoints it, and
|
|
121
|
+
compacts below it. The policy holds no fence, so it needs no owner and no
|
|
122
|
+
lease.
|
|
123
|
+
|
|
124
|
+
Write `capture` to finish quickly and to tolerate being abandoned: it is
|
|
125
|
+
interrupted after 30 seconds, and a failed or refused attempt is logged at
|
|
126
|
+
warning, damped for `entryThreshold` further entries, and never surfaced to the
|
|
127
|
+
emit that triggered it.
|
|
128
|
+
|
|
129
|
+
## Next steps
|
|
130
|
+
|
|
131
|
+
- [Checkpoints and compaction](../concepts/compaction.md) for the model behind
|
|
132
|
+
these calls.
|
|
133
|
+
- [Run a read-only follower](/docs/guides/sync-followers/) for a follower that
|
|
134
|
+
meets the floor.
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Fold a run into a projection"
|
|
3
|
+
description: "Write a reproducible reducer over journal entries, run it with project, and bound a live projection so it terminates."
|
|
4
|
+
sidebar:
|
|
5
|
+
order: 4
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
A projection is a fold over a run's entries: `{ name, initial, reduce }`. It has
|
|
9
|
+
no durable state of its own, so replaying the same entries through the same
|
|
10
|
+
reducer must reproduce the same result. That is the whole contract, and it is
|
|
11
|
+
what lets a served view be recomputed rather than patched.
|
|
12
|
+
|
|
13
|
+
## Write the reducer
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { Projection } from "@smthrs/journal"
|
|
17
|
+
import * as Effect from "effect/Effect"
|
|
18
|
+
|
|
19
|
+
interface StepCounts {
|
|
20
|
+
readonly started: number
|
|
21
|
+
readonly finished: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const counts: Projection.Projection<StepCounts> = Projection.make({
|
|
25
|
+
name: "docs/step-counts",
|
|
26
|
+
initial: { started: 0, finished: 0 },
|
|
27
|
+
reduce: (state, entry) =>
|
|
28
|
+
Effect.succeed(
|
|
29
|
+
entry.eventType === "step.started"
|
|
30
|
+
? { ...state, started: state.started + 1 }
|
|
31
|
+
: entry.eventType === "step.finished"
|
|
32
|
+
? { ...state, finished: state.finished + 1 }
|
|
33
|
+
: state
|
|
34
|
+
)
|
|
35
|
+
})
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`reduce` returns an `Effect`, so a projection may read a service. Keep it
|
|
39
|
+
deterministic anyway: an entry that is not yours returns the state unchanged,
|
|
40
|
+
as the fall-through above does.
|
|
41
|
+
|
|
42
|
+
## Run it
|
|
43
|
+
|
|
44
|
+
`project` folds `stream` through the reducer and emits a state per step. It
|
|
45
|
+
emits `initial` first, then one state for each entry:
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
import { Journal } from "@smthrs/journal"
|
|
49
|
+
import * as Stream from "effect/Stream"
|
|
50
|
+
|
|
51
|
+
const snapshot = Effect.gen(function*() {
|
|
52
|
+
const journal = yield* Journal.Journal
|
|
53
|
+
return yield* journal.project(counts, { runId }).pipe(
|
|
54
|
+
Stream.take(4),
|
|
55
|
+
Stream.runCollect
|
|
56
|
+
)
|
|
57
|
+
})
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
For a run with three entries, that collects:
|
|
61
|
+
|
|
62
|
+
```text
|
|
63
|
+
[
|
|
64
|
+
{ started: 0, finished: 0 },
|
|
65
|
+
{ started: 1, finished: 0 },
|
|
66
|
+
{ started: 1, finished: 1 },
|
|
67
|
+
{ started: 2, finished: 1 }
|
|
68
|
+
]
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`afterSequence` resumes a fold from a cursor, and then only the entries after
|
|
72
|
+
it are folded.
|
|
73
|
+
|
|
74
|
+
## Bound the stream
|
|
75
|
+
|
|
76
|
+
`project` inherits `stream`'s behavior: it replays and then follows, so it
|
|
77
|
+
never completes on its own. A `Stream.runCollect` or `Stream.runLast` with no
|
|
78
|
+
bound hangs.
|
|
79
|
+
|
|
80
|
+
Bound it one of three ways, by what you actually want:
|
|
81
|
+
|
|
82
|
+
- A **snapshot**: `Stream.take(n)` where `n` is `1` plus the entry count you
|
|
83
|
+
read from `entries`.
|
|
84
|
+
- A **live view**: fork the stream and interrupt the fiber when the view is torn
|
|
85
|
+
down. Interruption is clean.
|
|
86
|
+
- A **condition**: `Stream.takeUntil` on the state you were waiting for.
|
|
87
|
+
|
|
88
|
+
## Handle a failing reducer
|
|
89
|
+
|
|
90
|
+
A reducer that fails ends the stream with `projection_failed`. The journal is
|
|
91
|
+
unaffected: later emissions still succeed, and a fresh `project` call folds the
|
|
92
|
+
same history again from the start.
|
|
93
|
+
|
|
94
|
+
## Where projections are used
|
|
95
|
+
|
|
96
|
+
[`@smthrs/notifications`](/api/notifications) derives its pending-notification
|
|
97
|
+
queue this way, and [`@smthrs/gateway`](/api/gateway) recomputes a selector's
|
|
98
|
+
rows from accumulated events rather than patching them, precisely because a
|
|
99
|
+
reproducible fold is the only delta that cannot disagree with a fresh
|
|
100
|
+
snapshot.
|
|
101
|
+
|
|
102
|
+
## Next steps
|
|
103
|
+
|
|
104
|
+
- [Read and follow a run](./read-a-run.md) for the reads underneath `project`.
|
|
105
|
+
- [Checkpoints and compaction](../concepts/compaction.md) for what a projection
|
|
106
|
+
does when its cursor falls below the floor.
|