@kairos-es/read 0.0.0
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/LICENSE +28 -0
- package/README.md +538 -0
- package/dist/cjs/EventLogDurability.js +184 -0
- package/dist/cjs/EventLogDurability.js.map +1 -0
- package/dist/cjs/ProjectionRunner.js +478 -0
- package/dist/cjs/ProjectionRunner.js.map +1 -0
- package/dist/cjs/ProjectionStore.js +233 -0
- package/dist/cjs/ProjectionStore.js.map +1 -0
- package/dist/cjs/foldIntoRef.js +36 -0
- package/dist/cjs/foldIntoRef.js.map +1 -0
- package/dist/cjs/inMemoryProjectionStore.js +138 -0
- package/dist/cjs/inMemoryProjectionStore.js.map +1 -0
- package/dist/cjs/index.js +128 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/projectionWiringFault.js +532 -0
- package/dist/cjs/projectionWiringFault.js.map +1 -0
- package/dist/cjs/runProjection.js +117 -0
- package/dist/cjs/runProjection.js.map +1 -0
- package/dist/cjs/runProjections.js +144 -0
- package/dist/cjs/runProjections.js.map +1 -0
- package/dist/cjs/superviseOnProgress.js +580 -0
- package/dist/cjs/superviseOnProgress.js.map +1 -0
- package/dist/cjs/testing.js +143 -0
- package/dist/cjs/testing.js.map +1 -0
- package/dist/dts/EventLogDurability.d.ts +182 -0
- package/dist/dts/EventLogDurability.d.ts.map +1 -0
- package/dist/dts/ProjectionRunner.d.ts +557 -0
- package/dist/dts/ProjectionRunner.d.ts.map +1 -0
- package/dist/dts/ProjectionStore.d.ts +475 -0
- package/dist/dts/ProjectionStore.d.ts.map +1 -0
- package/dist/dts/foldIntoRef.d.ts +39 -0
- package/dist/dts/foldIntoRef.d.ts.map +1 -0
- package/dist/dts/inMemoryProjectionStore.d.ts +11 -0
- package/dist/dts/inMemoryProjectionStore.d.ts.map +1 -0
- package/dist/dts/index.d.ts +185 -0
- package/dist/dts/index.d.ts.map +1 -0
- package/dist/dts/projectionWiringFault.d.ts +260 -0
- package/dist/dts/projectionWiringFault.d.ts.map +1 -0
- package/dist/dts/runProjection.d.ts +185 -0
- package/dist/dts/runProjection.d.ts.map +1 -0
- package/dist/dts/runProjections.d.ts +480 -0
- package/dist/dts/runProjections.d.ts.map +1 -0
- package/dist/dts/superviseOnProgress.d.ts +587 -0
- package/dist/dts/superviseOnProgress.d.ts.map +1 -0
- package/dist/dts/testing.d.ts +207 -0
- package/dist/dts/testing.d.ts.map +1 -0
- package/dist/esm/EventLogDurability.js +175 -0
- package/dist/esm/EventLogDurability.js.map +1 -0
- package/dist/esm/ProjectionRunner.js +468 -0
- package/dist/esm/ProjectionRunner.js.map +1 -0
- package/dist/esm/ProjectionStore.js +223 -0
- package/dist/esm/ProjectionStore.js.map +1 -0
- package/dist/esm/foldIntoRef.js +29 -0
- package/dist/esm/foldIntoRef.js.map +1 -0
- package/dist/esm/inMemoryProjectionStore.js +131 -0
- package/dist/esm/inMemoryProjectionStore.js.map +1 -0
- package/dist/esm/index.js +185 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/package.json +4 -0
- package/dist/esm/projectionWiringFault.js +524 -0
- package/dist/esm/projectionWiringFault.js.map +1 -0
- package/dist/esm/runProjection.js +109 -0
- package/dist/esm/runProjection.js.map +1 -0
- package/dist/esm/runProjections.js +137 -0
- package/dist/esm/runProjections.js.map +1 -0
- package/dist/esm/superviseOnProgress.js +571 -0
- package/dist/esm/superviseOnProgress.js.map +1 -0
- package/dist/esm/testing.js +133 -0
- package/dist/esm/testing.js.map +1 -0
- package/package.json +41 -0
- package/src/EventLogDurability.ts +201 -0
- package/src/ProjectionRunner.ts +923 -0
- package/src/ProjectionStore.ts +528 -0
- package/src/foldIntoRef.ts +63 -0
- package/src/inMemoryProjectionStore.ts +163 -0
- package/src/index.ts +218 -0
- package/src/projectionWiringFault.ts +694 -0
- package/src/runProjection.ts +270 -0
- package/src/runProjections.ts +623 -0
- package/src/superviseOnProgress.ts +897 -0
- package/src/testing.ts +290 -0
- package/testing/package.json +6 -0
|
@@ -0,0 +1,897 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `superviseOnProgress` — the projection runner's SUPERVISOR: retry an effect for
|
|
3
|
+
* as long as an EXTERNALLY OBSERVED progress signal keeps moving, and give up when
|
|
4
|
+
* it stops.
|
|
5
|
+
*
|
|
6
|
+
* ## The mechanism
|
|
7
|
+
*
|
|
8
|
+
* A daemon that fails has to be restarted, and something has to decide when
|
|
9
|
+
* restarting has become pointless. The usual answer counts failures, which
|
|
10
|
+
* cannot tell "this keeps failing and nothing is happening" from "this keeps
|
|
11
|
+
* failing and the work is getting done anyway" — and those two want opposite
|
|
12
|
+
* decisions. So this module keys the decision on a SIGNAL the supervised effect
|
|
13
|
+
* does not own: a `Position` read from somewhere else, monotone by construction,
|
|
14
|
+
* whose movement means work landed no matter who landed it. Two mechanisms, kept
|
|
15
|
+
* deliberately apart:
|
|
16
|
+
*
|
|
17
|
+
* - **The schedule decides HOW FAST to restart** — a capped, jittered,
|
|
18
|
+
* sawtoothing exponential that never terminates on its own.
|
|
19
|
+
* - **The circuit-breaker decides WHETHER to keep restarting** — by re-reading
|
|
20
|
+
* `progress` after every failure and counting only the CONSECUTIVE restarts
|
|
21
|
+
* that moved it not at all.
|
|
22
|
+
*
|
|
23
|
+
* Splitting them is what makes a competing writer behave. Two processes
|
|
24
|
+
* accidentally maintaining one materialisation — a deploy overlapping its
|
|
25
|
+
* predecessor, one store passed twice — both fail on every batch, the loser's
|
|
26
|
+
* guarded checkpoint advance being superseded every time; but the WINNER keeps
|
|
27
|
+
* advancing the shared stored position, so every one of the loser's restarts
|
|
28
|
+
* observes movement, its counter resets, and it resynchronises from the winner's
|
|
29
|
+
* position for as long as the winner keeps working: bounded duplicate effort, at
|
|
30
|
+
* the schedule's rate, with no hot loop and no spurious give-up. Only a signal
|
|
31
|
+
* that is genuinely stuck — a poison event whose apply always fails, a
|
|
32
|
+
* persistently broken view store — trips the breaker and fails
|
|
33
|
+
* `ProjectionStalled`.
|
|
34
|
+
*
|
|
35
|
+
* "Accidentally" is the word and it is doing work, because ONE read model
|
|
36
|
+
* materialised N ways is a DELIBERATE shape (`runProjections`) and is not this case
|
|
37
|
+
* at all. Those N runners share a `ProjectionId` but each owns its own checkpoint in
|
|
38
|
+
* its OWN store, so they never contend, no guard is ever lost between them, and
|
|
39
|
+
* nothing here is being relied on to make them safe. The tolerated fault above is
|
|
40
|
+
* two writers over ONE cursor, which is what the construction gate's collision rung
|
|
41
|
+
* refuses when it is a wiring rather than an overlapping deploy — this mechanism
|
|
42
|
+
* degrades gracefully under it and is deliberately not the place it is caught.
|
|
43
|
+
*
|
|
44
|
+
* ## Two observability channels, and which one is primary
|
|
45
|
+
*
|
|
46
|
+
* Every supervision outcome is reported twice: to a HOOK — `onRestart` on every
|
|
47
|
+
* restart, `onStalled` once at the give-up — and to a LOG LINE beside it. The
|
|
48
|
+
* hooks are the primary channel and carry the fault ITSELF, untouched. The log
|
|
49
|
+
* lines are the thin lossy default: every annotation on them is a string or a
|
|
50
|
+
* number — JSON-SAFE, that is, rather than uniformly stringy, because
|
|
51
|
+
* `effect@3.22`'s `Logger.json` throws outright on a `bigint` nested in an
|
|
52
|
+
* annotation value and `CheckpointSuperseded` carries one (the measurement, for
|
|
53
|
+
* each shipped logger, is at the annotations in `shouldRestart`). The one numeric
|
|
54
|
+
* annotation, `noProgressRestarts`, stays numeric on purpose: a serialiser has no
|
|
55
|
+
* quarrel with it and an aggregator can filter and graph on it, both of which a
|
|
56
|
+
* `String` around it would give up for nothing. The fault goes out through
|
|
57
|
+
* `String`, which is to say through the FAULT: `Error.prototype.toString` is
|
|
58
|
+
* `name: message`, so a fault with something to say sets `message` — as all four of
|
|
59
|
+
* the runner's own faults do, `CheckpointSuperseded` included, its `bigint`
|
|
60
|
+
* `expected` interpolated into a `string` and so hidden from every serialiser — and
|
|
61
|
+
* one with nothing to say beyond its tag renders as its tag. The supervisor needs
|
|
62
|
+
* no knowledge of the supervised effect's `E` to label it — it is generic in `E` —
|
|
63
|
+
* and no caller has to supply anything for the purpose.
|
|
64
|
+
*
|
|
65
|
+
* ## Why it is a module of its own, and why it is PACKAGE-INTERNAL
|
|
66
|
+
*
|
|
67
|
+
* Not because the mechanism is on offer for reuse. It is deliberately absent from
|
|
68
|
+
* `@kairos-es/read`'s entry point, and its signature says what it supervises: a
|
|
69
|
+
* `CheckpointKey` names the supervised thing, a `Position` is the signal, a
|
|
70
|
+
* `ProjectionStoreError` is what reading that signal can fail with, and
|
|
71
|
+
* `ProjectionStalled` is a projection-shaped failure on
|
|
72
|
+
* `ProjectionRunner.fibre`'s error channel.
|
|
73
|
+
*
|
|
74
|
+
* It is separate because it is the hardest thing in this package to test THROUGH
|
|
75
|
+
* the pipeline, and the repo has the before and after. Pinning the five-minute
|
|
76
|
+
* default no-progress budget — a quantity about supervision and nothing else —
|
|
77
|
+
* cost ~122 lines while the mechanism lived inside `runProjection`: an in-memory
|
|
78
|
+
* event store, a view store decorated never to commit, a read model, a log-capture
|
|
79
|
+
* layer, and a hand-driven loop stepping the clock some 1200 times. Against a stub
|
|
80
|
+
* it costs 52, and 22 of those are code: one `Effect` yielding a `Position`, one
|
|
81
|
+
* effect that fails, one `TestClock.adjust`, and the rest of the case is the
|
|
82
|
+
* arithmetic it asserts written out. (The figure is re-measured rather than
|
|
83
|
+
* inherited — it read ~37 until the three restart `Duration`s became explicit
|
|
84
|
+
* arguments and the case gained a comment saying why. The ratio is what the
|
|
85
|
+
* argument turns on, and that has not moved.) Every other subtle behaviour is
|
|
86
|
+
* pinned the same way — the
|
|
87
|
+
* `ORIGIN` seeding, the breaker being consulted before the sleep, the interruption
|
|
88
|
+
* trap on the re-read, the counter resetting on progress — because this module
|
|
89
|
+
* knows nothing of `subscribe`, of decoding, of micro-batching, of committing, of
|
|
90
|
+
* slices or of the `Serializer`. Those cases are three suites over one stub
|
|
91
|
+
* (`test/superviseOnProgress.{pacing,breaker,observability}.test.ts`, sharing
|
|
92
|
+
* `test/superviseOnProgress.fixture.ts`), split along the two mechanisms this
|
|
93
|
+
* section keeps apart plus the channel their outcomes leave through. A SEPARATE
|
|
94
|
+
* module bought all of that; a GENERAL one was never needed for any of it.
|
|
95
|
+
*
|
|
96
|
+
* And there is no second consumer here, nor one in prospect. The mechanism needs a
|
|
97
|
+
* caller with a failing effect worth restarting AND a progress signal owned by
|
|
98
|
+
* somebody else; in this library only a projection has both. A command runner's
|
|
99
|
+
* progress IS its own success. The store's poll retry lives in
|
|
100
|
+
* `@kairos-es/store-postgres` and already has a bounded retry with no supervisor
|
|
101
|
+
* over it. A lease renewer for competing consumers is out of scope by ADR-0007.
|
|
102
|
+
*
|
|
103
|
+
* Should such a consumer appear, generalising then is a smaller change than
|
|
104
|
+
* maintaining the claim now — so the cost is recorded here rather than
|
|
105
|
+
* re-derived: `key` becomes a bare `string` label, losing the two discrete
|
|
106
|
+
* `projection`/`partition` annotations an operator greps on (or forcing a generic
|
|
107
|
+
* annotation shape onto every caller); `Position`'s `!==` becomes an
|
|
108
|
+
* `Equivalence` on the signal type; `ProjectionStoreError` becomes a fourth type
|
|
109
|
+
* parameter, and collapsing it to `unknown` instead is what would cost the
|
|
110
|
+
* interruption trap its argument, since "`catchAll` touches only the typed
|
|
111
|
+
* `ProjectionStoreError`" stops being a claim a reader can check against the
|
|
112
|
+
* signature; and `ProjectionStalled` has to be renamed or made generic.
|
|
113
|
+
*
|
|
114
|
+
* ## Platform-neutral
|
|
115
|
+
*
|
|
116
|
+
* No `node:*`, no `Date`, no host RNG: the backoff is `Duration`s scheduled
|
|
117
|
+
* through the `Clock` and the jitter is Effect's `Random` service, so
|
|
118
|
+
* `TestClock` drives all of it.
|
|
119
|
+
*/
|
|
120
|
+
import { ORIGIN, type Position } from '@kairos-es/core'
|
|
121
|
+
import { Data, type Duration, Effect, Ref, Schedule, Schema } from 'effect'
|
|
122
|
+
import type { CheckpointKey, ProjectionStoreError } from './ProjectionStore'
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Consecutive no-progress restarts before the supervisor gives up: a branded
|
|
126
|
+
* POSITIVE integer.
|
|
127
|
+
*
|
|
128
|
+
* Branded because at `0` the breaker's `restarts >= maxNoProgressRestarts` test
|
|
129
|
+
* is true on the FIRST failure, so the supervisor gives up before it has retried
|
|
130
|
+
* once and before `onRestart` has ever fired — a `ProjectionStalled` for a fault
|
|
131
|
+
* that a single 100-millisecond retry would have cleared, and the flat
|
|
132
|
+
* contradiction of the five-minute budget `DEFAULT_MAX_NO_PROGRESS_RESTARTS`
|
|
133
|
+
* documents. Negative and fractional counts fail the same test the same way.
|
|
134
|
+
*
|
|
135
|
+
* `>= 1` rather than `>= 0` for that reason: "give up immediately" is not a
|
|
136
|
+
* supervision policy this module offers, and a caller who wants one has
|
|
137
|
+
* `Fiber.interrupt`.
|
|
138
|
+
*
|
|
139
|
+
* A distinct brand from `BatchSize` despite the identical refinement, because
|
|
140
|
+
* the two are still writable side by side in one flat options literal, are both
|
|
141
|
+
* bare integers, and differ by an order of magnitude in meaning — nominality is
|
|
142
|
+
* what makes a transposition fail to typecheck rather than silently retune the
|
|
143
|
+
* runner. That the two now tune different mechanisms in different modules is a
|
|
144
|
+
* second reason for the distinction, not a replacement for the first.
|
|
145
|
+
*
|
|
146
|
+
* Construct with `MaxNoProgressRestarts.make(n)` — and retune it by the WALL
|
|
147
|
+
* CLOCK the figure buys, per `DEFAULT_MAX_NO_PROGRESS_RESTARTS`.
|
|
148
|
+
*/
|
|
149
|
+
export const MaxNoProgressRestarts = Schema.Int.pipe(
|
|
150
|
+
Schema.positive(),
|
|
151
|
+
Schema.brand('MaxNoProgressRestarts'),
|
|
152
|
+
)
|
|
153
|
+
export type MaxNoProgressRestarts = typeof MaxNoProgressRestarts.Type
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Fast enough that a transient blip is invisible, slow enough not to spin.
|
|
157
|
+
*
|
|
158
|
+
* Exported — like the two below and unlike `DEFAULT_MAX_NO_PROGRESS_RESTARTS` —
|
|
159
|
+
* because `resolveProjection` is what RESOLVES it, for both entry points: the
|
|
160
|
+
* construction gate judges the
|
|
161
|
+
* figure that will actually run, so the resolution has to happen before the
|
|
162
|
+
* supervisor is built and the default has to be readable from there. The
|
|
163
|
+
* documentation stays HERE, against the sawtooth arithmetic the three of them
|
|
164
|
+
* define together, because that is what the figure is chosen for.
|
|
165
|
+
*/
|
|
166
|
+
export const DEFAULT_RESTART_MIN_DELAY: Duration.DurationInput = '100 millis'
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The store's own poll retry exhausts after roughly 40 seconds of sustained
|
|
170
|
+
* transient faults before `subscribe` dies, so a 30-second ceiling keeps a
|
|
171
|
+
* restart in the same order of magnitude as the fault it is recovering from —
|
|
172
|
+
* long enough not to hammer a struggling database, short enough that recovery is
|
|
173
|
+
* not measured in minutes.
|
|
174
|
+
*
|
|
175
|
+
* Exported for the reason `DEFAULT_RESTART_MIN_DELAY` gives.
|
|
176
|
+
*/
|
|
177
|
+
export const DEFAULT_RESTART_MAX_DELAY: Duration.DurationInput = '30 seconds'
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* A minute of ELAPSED retrying, after which the pacing starts over from
|
|
181
|
+
* `restartMinDelay`.
|
|
182
|
+
*
|
|
183
|
+
* `Schedule.resetAfter` keys on `Schedule.elapsed` — the time since the schedule
|
|
184
|
+
* (or its own last reset) began, NOT the time since the last decision, verified
|
|
185
|
+
* against `effect@3.22`'s `internal/schedule.ts` — and both readings of that are
|
|
186
|
+
* wanted. A run that stays healthy for a minute pushes `elapsed` past the
|
|
187
|
+
* threshold on its way to the next fault, so its backoff starts from
|
|
188
|
+
* `restartMinDelay` again instead of inheriting a ceiling from a fault it
|
|
189
|
+
* recovered from hours ago. And a SUSTAINED outage, whose attempts fail fast,
|
|
190
|
+
* sawtooths back down to `restartMinDelay` every cycle rather than pinning at
|
|
191
|
+
* `restartMaxDelay` for ever, so a store that comes back is noticed within
|
|
192
|
+
* `restartMinDelay` rather than up to `restartMaxDelay` later.
|
|
193
|
+
*
|
|
194
|
+
* Exported for the reason `DEFAULT_RESTART_MIN_DELAY` gives.
|
|
195
|
+
*/
|
|
196
|
+
export const DEFAULT_RESTART_RESET_AFTER: Duration.DurationInput = '60 seconds'
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* FORTY zero-progress restarts, chosen for the WALL CLOCK it buys — roughly five
|
|
200
|
+
* minutes — rather than for the count, which on its own means nothing.
|
|
201
|
+
*
|
|
202
|
+
* What the figure has to satisfy is `ProjectionStalled`'s own claim: a view store
|
|
203
|
+
* "broken rather than merely unavailable". So the budget must be long enough that
|
|
204
|
+
* a merely-unavailable view store — a failover, a rolling restart, a brief
|
|
205
|
+
* partition — is back inside it, and comfortably longer than the ~40 seconds the
|
|
206
|
+
* event store itself spends retrying before `subscribe` dies. The arithmetic is
|
|
207
|
+
* written out rather than asserted, because the pacing above is not obvious and
|
|
208
|
+
* the next reader should be able to CHECK the claim:
|
|
209
|
+
*
|
|
210
|
+
* Unjittered, the restart schedule sleeps `0.1, 0.2, 0.4, 0.8, 1.6, 3.2, 6.4,
|
|
211
|
+
* 12.8, 25.6, 30` seconds — the exponential, then the `restartMaxDelay` cap — and
|
|
212
|
+
* then starts over: by the eleventh decision 81.1s have elapsed, past
|
|
213
|
+
* `restartResetAfter`, so `resetAfter` resets the schedule. The pacing is
|
|
214
|
+
* therefore a SAWTOOTH of ten sleeps summing 81.1s. A give-up at the Nth
|
|
215
|
+
* consecutive no-progress failure has slept N-1 times (the breaker is consulted
|
|
216
|
+
* BEFORE each sleep), so 40 restarts is three whole cycles (243.3s) plus the
|
|
217
|
+
* first nine of a fourth (51.1s): 294.4s ≈ 4.9 minutes, which the ±20% jitter
|
|
218
|
+
* spreads to roughly 4.5–5.3. `superviseOnProgress.pacing.test.ts` pins that budget
|
|
219
|
+
* under a `TestClock` — and, beside it, the N-1 that this arithmetic turns on — so a
|
|
220
|
+
* retuning cannot quietly shrink either.
|
|
221
|
+
*
|
|
222
|
+
* Five restarts — the figure this shipped with first — bought 1.5s, which made a
|
|
223
|
+
* ten-second blip a PERMANENT stall and flatly contradicted the documentation
|
|
224
|
+
* above. That is the mistake this figure exists to avoid: if you lower it,
|
|
225
|
+
* recompute the wall clock.
|
|
226
|
+
*
|
|
227
|
+
* Two things make the effective budget longer still. When it is the SUBSCRIPTION
|
|
228
|
+
* that keeps dying rather than the view store refusing to answer, each attempt
|
|
229
|
+
* also burns the store's own ~40-second poll-retry budget before dying. And
|
|
230
|
+
* restarts that DO move the progress signal never count at all, so a busy
|
|
231
|
+
* projection that restarts often can never trip this however long it runs.
|
|
232
|
+
*
|
|
233
|
+
* PRIVATE, where the three restart `Duration`s beside it are exported: the count is
|
|
234
|
+
* BRANDED, so `MaxNoProgressRestarts.make(n)` refused a bad value at the caller's
|
|
235
|
+
* own line and there is nothing left for the construction gate to judge. Nobody
|
|
236
|
+
* outside needs to resolve it, so it is resolved here.
|
|
237
|
+
*/
|
|
238
|
+
const DEFAULT_MAX_NO_PROGRESS_RESTARTS: MaxNoProgressRestarts =
|
|
239
|
+
MaxNoProgressRestarts.make(40)
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* How the supervisor is tuned: how fast it restarts, how long it keeps trying,
|
|
243
|
+
* and where each of its two OUTCOMES is reported — a restart to `onRestart`, a
|
|
244
|
+
* give-up to `onStalled`.
|
|
245
|
+
*
|
|
246
|
+
* Separated from the pipeline's own tuning (`ProjectionPipelineOptions`:
|
|
247
|
+
* `batchSize`, `batchWindow`) because they are dials on different machines —
|
|
248
|
+
* one sizes a transaction and a latency ceiling, the other sizes a RECOVERY
|
|
249
|
+
* POLICY measured in minutes of outage — and reading them as one undifferentiated
|
|
250
|
+
* bag is how `maxNoProgressRestarts` gets retuned as though it were a throughput
|
|
251
|
+
* knob. `ProjectionRunnerOptions` still composes the two into one flat object, so
|
|
252
|
+
* no call site pays for the distinction; the types are what carry it.
|
|
253
|
+
*
|
|
254
|
+
* Every figure is a build-time DEFAULT, not a contract: the mechanisms are fixed
|
|
255
|
+
* by ADR-0007, the numbers are judgement calls sized against
|
|
256
|
+
* `@kairos-es/store-postgres`'s own defaults and are meant to be overridden by a
|
|
257
|
+
* deployment that knows its own latency budget (and by tests, which want tiny
|
|
258
|
+
* delays).
|
|
259
|
+
*
|
|
260
|
+
* A judgement call is still not a free choice, though: zero, negative,
|
|
261
|
+
* fractional and infinite figures are not "aggressive tuning", they are wirings
|
|
262
|
+
* whose failure mode is a hot loop or a permanently stalled daemon with nothing
|
|
263
|
+
* on any error channel. So this surface fails at its boundary, by TWO mechanisms
|
|
264
|
+
* because the field types differ — the count is BRANDED, so a bad value is
|
|
265
|
+
* refused at the caller's own `.make(...)`, and the three durations are judged by
|
|
266
|
+
* the read side's construction gate (`projectionWiringFault`, which says why they
|
|
267
|
+
* cannot be branded), called by `runProjection` and `runProjections` alike before a
|
|
268
|
+
* supervisor, an attempt or a fibre exists, where the fault lands as a defect exactly as
|
|
269
|
+
* `assertServableQuery`'s does.
|
|
270
|
+
*
|
|
271
|
+
* Neither mechanism is this module's own, and that is deliberate: by the time a
|
|
272
|
+
* figure reaches `superviseOnProgress` it has been resolved and judged, which is
|
|
273
|
+
* why the three appear again as REQUIRED fields on `SuperviseOnProgressOptions`.
|
|
274
|
+
*/
|
|
275
|
+
export interface ProjectionSupervisionOptions {
|
|
276
|
+
/**
|
|
277
|
+
* First restart delay. Default `'100 millis'`. Positive and finite, checked at
|
|
278
|
+
* construction — it is the base `Schedule.exponential` multiplies, so a zero
|
|
279
|
+
* here zeroes the whole backoff.
|
|
280
|
+
*/
|
|
281
|
+
readonly restartMinDelay?: Duration.DurationInput
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Restart delay ceiling. Default `'30 seconds'` — the same order of magnitude
|
|
285
|
+
* as the store's own ~40-second poll-retry budget, so the runner does not back
|
|
286
|
+
* off far past the lifetime of the fault it is recovering from.
|
|
287
|
+
*
|
|
288
|
+
* Positive, finite, and not below `restartMinDelay`; all three are checked at
|
|
289
|
+
* construction.
|
|
290
|
+
*/
|
|
291
|
+
readonly restartMaxDelay?: Duration.DurationInput
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* How long a run must stay up before the backoff resets to `restartMinDelay`.
|
|
295
|
+
* Default `'60 seconds'`. Without this, a projection that hits one fault an hour
|
|
296
|
+
* would still be waiting `restartMaxDelay` a day later.
|
|
297
|
+
*
|
|
298
|
+
* Positive and finite, checked at construction: both degenerate ends dissolve
|
|
299
|
+
* the sawtooth that `DEFAULT_MAX_NO_PROGRESS_RESTARTS`' budget is computed
|
|
300
|
+
* against.
|
|
301
|
+
*/
|
|
302
|
+
readonly restartResetAfter?: Duration.DurationInput
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Consecutive restarts that moved the progress signal NOT AT ALL before the
|
|
306
|
+
* supervisor gives up with `ProjectionStalled`. Default `40`, which on the
|
|
307
|
+
* default pacing is about five minutes of retrying — long enough that a view
|
|
308
|
+
* store that is merely unavailable is back inside it.
|
|
309
|
+
*
|
|
310
|
+
* Retune it by the WALL CLOCK it buys rather than by the count: with the
|
|
311
|
+
* sawtoothing backoff the two are not proportional, and the arithmetic is set
|
|
312
|
+
* out on `DEFAULT_MAX_NO_PROGRESS_RESTARTS`.
|
|
313
|
+
*
|
|
314
|
+
* A branded positive integer: write `MaxNoProgressRestarts.make(80)`, not
|
|
315
|
+
* `80`.
|
|
316
|
+
*/
|
|
317
|
+
readonly maxNoProgressRestarts?: MaxNoProgressRestarts
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Supervision observability seam for the NON-TERMINAL outcome: called on every
|
|
321
|
+
* restart, BEFORE the backoff sleep, so a metric or a trace records the restart
|
|
322
|
+
* at the moment it is decided rather than after the delay.
|
|
323
|
+
*
|
|
324
|
+
* THE PRIMARY CHANNEL for a restart, not a supplement to the log line beside
|
|
325
|
+
* it. `reason` is the RAW fault; the `reason` on the log line is that fault
|
|
326
|
+
* `String`ed, because a serialiser cannot take the `bigint` a
|
|
327
|
+
* `CheckpointSuperseded` carries (the measurement is at the annotations, in
|
|
328
|
+
* `shouldRestart`). So anything that needs to match on a tag, read a payload or
|
|
329
|
+
* count by fault type takes this hook, and the log line is the lossy convenience
|
|
330
|
+
* default for everyone else.
|
|
331
|
+
*
|
|
332
|
+
* Infallible and requirement-free by signature, so a hook cannot FAIL the
|
|
333
|
+
* daemon or widen the supervised effect's requirements. It carries no
|
|
334
|
+
* `position`, unlike `onStalled`: a restart is about to re-read the signal
|
|
335
|
+
* anyway, so where it stood at the restart is a number in flight rather than a
|
|
336
|
+
* fact. `onStalled` states the full rule the two share, including what happens
|
|
337
|
+
* when a hook dies.
|
|
338
|
+
*/
|
|
339
|
+
readonly onRestart?: (info: {
|
|
340
|
+
readonly key: CheckpointKey
|
|
341
|
+
readonly reason: unknown
|
|
342
|
+
readonly noProgressRestarts: number
|
|
343
|
+
}) => Effect.Effect<void>
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Supervision observability seam for the TERMINAL outcome: called ONCE, at the
|
|
347
|
+
* give-up, immediately after the error is logged and immediately before
|
|
348
|
+
* `ProjectionStalled` is raised. An application can fail a health check, exit
|
|
349
|
+
* non-zero, or page somebody.
|
|
350
|
+
*
|
|
351
|
+
* ## WHY, given `onRestart` already exists — the restart is the metric, the
|
|
352
|
+
* stall is the page
|
|
353
|
+
*
|
|
354
|
+
* The two outcomes are not equally important, and the asymmetry runs the
|
|
355
|
+
* OPPOSITE way to the one the older surface implied. A restart RESOLVES ITSELF:
|
|
356
|
+
* it is a rate to graph, and to alert on only if it climbs. A give-up does not —
|
|
357
|
+
* `ProjectionStalled` means a poison event or a view store broken rather than
|
|
358
|
+
* merely unavailable, and its own doc says both need a human. Yet under the
|
|
359
|
+
* wiring this library recommends (`projectionLayer`, which is
|
|
360
|
+
* `Layer.scopedDiscard`, so the daemon fibre is discarded ON PURPOSE — a
|
|
361
|
+
* projection is a background process and nothing should depend on it as a
|
|
362
|
+
* service) the terminal event's only route out of the process was the
|
|
363
|
+
* `Effect.logError` beside this call. A seam for the event that fixes itself and
|
|
364
|
+
* none for the event that does not is backwards; this is the missing half.
|
|
365
|
+
*
|
|
366
|
+
* ## The payload is the `ProjectionStalled` about to be raised
|
|
367
|
+
*
|
|
368
|
+
* The same four fields, carrying the same values, so a hook and a caller
|
|
369
|
+
* awaiting the daemon see one story rather than two. `position` is the STUCK
|
|
370
|
+
* signal — the position everything is failing just after, so the offending event
|
|
371
|
+
* is one query away — `noProgressRestarts` the budget that was spent, and
|
|
372
|
+
* `reason` the last underlying failure as the RAW fault rather than the reduced
|
|
373
|
+
* log label, because a programmatic callback can inspect a `bigint` where a log
|
|
374
|
+
* line cannot.
|
|
375
|
+
*
|
|
376
|
+
* With `onRestart` that makes the hooks the PRIMARY observability channel for
|
|
377
|
+
* both supervision outcomes, and the two log lines the thin lossy default: every
|
|
378
|
+
* value on those is a string or a number — JSON-SAFE, by measurement rather than
|
|
379
|
+
* by superstition (see the annotations in `shouldRestart`), which is the real
|
|
380
|
+
* bound and not "stringify everything" — whereas a hook gets the fault untouched.
|
|
381
|
+
*
|
|
382
|
+
* ## Infallible by signature, NOT defect-trapped — the same rule as `onRestart`
|
|
383
|
+
*
|
|
384
|
+
* `Effect<void>`, so a hook can neither fail the daemon nor widen the supervised
|
|
385
|
+
* effect's requirements. A DEFECT from a hook is a different matter and is
|
|
386
|
+
* deliberately left alone: it takes the fibre down exactly as one from
|
|
387
|
+
* `onRestart` does, because absorbing it would hide a bug in the application's
|
|
388
|
+
* own pager on the one path whose entire job is to make failures visible. What
|
|
389
|
+
* makes that safe is the ORDER — the `Effect.logError` has already landed by the
|
|
390
|
+
* time this is called, so the guaranteed route to an operator is never hostage
|
|
391
|
+
* to the hook, and a broken hook surfaces as a further defect rather than as
|
|
392
|
+
* silence. The one cost is that such a fibre dies with the hook's defect instead
|
|
393
|
+
* of failing `ProjectionStalled`, which is the honest report: the stall was
|
|
394
|
+
* announced, and then the announcer broke.
|
|
395
|
+
*/
|
|
396
|
+
readonly onStalled?: (info: {
|
|
397
|
+
readonly key: CheckpointKey
|
|
398
|
+
readonly noProgressRestarts: number
|
|
399
|
+
readonly position: Position
|
|
400
|
+
readonly reason: unknown
|
|
401
|
+
}) => Effect.Effect<void>
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* The three restart figures that are RESOLVED before a supervisor exists:
|
|
406
|
+
* `resolveProjection` applies each default and the construction gate judges the
|
|
407
|
+
* result, so what reaches `superviseOnProgress` has been both settled and checked.
|
|
408
|
+
*
|
|
409
|
+
* A type of its own rather than three fields restated wherever they are needed,
|
|
410
|
+
* because it is the BOUNDARY between the two halves of the surface above and both
|
|
411
|
+
* halves are computed from it: `SuperviseOnProgressOptions` requires exactly these,
|
|
412
|
+
* and `UnresolvedSupervisionOptions` below is everything else. `ResolvedProjection`
|
|
413
|
+
* in `ProjectionRunner.ts` EXTENDS it, so "which figures the resolve step owns" is
|
|
414
|
+
* ONE declaration serving both ends rather than two that happen to agree today — a
|
|
415
|
+
* fourth resolved figure added here is a compile error in the resolve step until it
|
|
416
|
+
* produces one, and another at the forwarding call site until it names one.
|
|
417
|
+
*
|
|
418
|
+
* PACKAGE-INTERNAL, like `SuperviseOnProgressOptions` itself. The half a caller
|
|
419
|
+
* writes is `ProjectionSupervisionOptions`, where all three stay optional.
|
|
420
|
+
*/
|
|
421
|
+
export interface ResolvedSupervisionTuning {
|
|
422
|
+
/** First restart delay, already resolved and judged. */
|
|
423
|
+
readonly restartMinDelay: Duration.DurationInput
|
|
424
|
+
|
|
425
|
+
/** Restart delay ceiling, already resolved and judged. */
|
|
426
|
+
readonly restartMaxDelay: Duration.DurationInput
|
|
427
|
+
|
|
428
|
+
/** Backoff reset threshold, already resolved and judged. */
|
|
429
|
+
readonly restartResetAfter: Duration.DurationInput
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* The half of `ProjectionSupervisionOptions` that NOTHING resolves — the two hooks
|
|
434
|
+
* and the branded count — forwarded to the supervisor exactly as the caller wrote
|
|
435
|
+
* it.
|
|
436
|
+
*
|
|
437
|
+
* Defined by SUBTRACTION rather than by listing its members, and that is the whole
|
|
438
|
+
* value of it. `buildAndFork` spreads the forwardable remainder of the caller's own
|
|
439
|
+
* options — this type plus the two PIPELINE figures, which are inert here because
|
|
440
|
+
* nothing below reads them by name — and names the resolved three beside it, so the
|
|
441
|
+
* split between the two halves is carried by the types: a new OPTIONAL supervision
|
|
442
|
+
* field lands in `ProjectionSupervisionOptions`, arrives here
|
|
443
|
+
* automatically and travels with that spread untouched, while a new RESOLVED figure
|
|
444
|
+
* lands in `ResolvedSupervisionTuning`, drops OUT of this type, and leaves the spread
|
|
445
|
+
* unable to supply it — a missing required property at that one call site, rather
|
|
446
|
+
* than an unjudged raw value winning silently on spread order.
|
|
447
|
+
*
|
|
448
|
+
* `Omit` rather than a second `extends` clause, and not by preference: an interface
|
|
449
|
+
* cannot extend both `ProjectionSupervisionOptions` and `ResolvedSupervisionTuning`,
|
|
450
|
+
* because the same property optional in one parent and required in the other is a
|
|
451
|
+
* conflict TypeScript refuses outright (TS2320, "not identical").
|
|
452
|
+
*/
|
|
453
|
+
export type UnresolvedSupervisionOptions = Omit<
|
|
454
|
+
ProjectionSupervisionOptions,
|
|
455
|
+
keyof ResolvedSupervisionTuning
|
|
456
|
+
>
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* The supervisor gave up: `noProgressRestarts` consecutive restarts moved the
|
|
460
|
+
* progress signal not at all.
|
|
461
|
+
*
|
|
462
|
+
* This is the only way a supervised daemon's fibre ever fails, and for a
|
|
463
|
+
* projection it means one of two things: a POISON EVENT (an `apply` or a decode
|
|
464
|
+
* that fails deterministically on the event at the checkpoint, so every restart
|
|
465
|
+
* re-reads it and dies again), or a view store still not answering after the
|
|
466
|
+
* WHOLE no-progress budget — broken, that is, rather than merely unavailable.
|
|
467
|
+
* That distinction is only as true as the budget makes it, which is why the
|
|
468
|
+
* default is sized at roughly five minutes of retrying rather than at a tidy
|
|
469
|
+
* restart count (`DEFAULT_MAX_NO_PROGRESS_RESTARTS`). Both need a human; neither
|
|
470
|
+
* is fixed by retrying, which is why the supervisor stops instead of retrying for
|
|
471
|
+
* ever and calling that resilience.
|
|
472
|
+
*
|
|
473
|
+
* Getting it in FRONT of that human is `onStalled`'s job, and the reason that
|
|
474
|
+
* hook exists: this failure lands on the daemon's error channel, and the wiring
|
|
475
|
+
* this library recommends discards the daemon. Beside the hook there is one
|
|
476
|
+
* `Effect.logError`, and nothing else.
|
|
477
|
+
*
|
|
478
|
+
* `position` is the stuck signal — the position everything is failing just
|
|
479
|
+
* after — so the offending event is one query away. `reason` is the last
|
|
480
|
+
* underlying failure, kept as `unknown` because it spans the supervised effect's
|
|
481
|
+
* whole `E`, which this module is generic in.
|
|
482
|
+
*/
|
|
483
|
+
export class ProjectionStalled extends Data.TaggedError('ProjectionStalled')<{
|
|
484
|
+
readonly key: CheckpointKey
|
|
485
|
+
readonly noProgressRestarts: number
|
|
486
|
+
readonly position: Position
|
|
487
|
+
readonly reason: unknown
|
|
488
|
+
}> {
|
|
489
|
+
/**
|
|
490
|
+
* The two facts that make a stall diagnosable — how much budget was spent, and
|
|
491
|
+
* where it was spent — as the `Error` field that says why, so the fault renders
|
|
492
|
+
* ITSELF.
|
|
493
|
+
*
|
|
494
|
+
* This class needs the field more than any other fault the read side raises,
|
|
495
|
+
* because of WHERE it lands. `ProjectionStalled` is what `ProjectionRunner.fibre`
|
|
496
|
+
* fails with, which makes it the only one of them a consumer meets without going
|
|
497
|
+
* anywhere near the `ProjectionStore` port: `CheckpointSuperseded` and
|
|
498
|
+
* `ProjectionStoreError` sit on `commit`'s public signature too, but reaching them
|
|
499
|
+
* means calling or implementing that port, whereas the `Fiber.await` route the
|
|
500
|
+
* runner's own documentation recommends ends at whatever this getter says — from a
|
|
501
|
+
* daemon the application only forked.
|
|
502
|
+
*
|
|
503
|
+
* `Data.TaggedError` sets `name` to the tag and leaves `message` empty, and
|
|
504
|
+
* `Cause.pretty` substitutes its own `'An error has occurred'` for an empty one —
|
|
505
|
+
* so unfilled, a caller who did exactly what they were told to do reads
|
|
506
|
+
* `'ProjectionStalled: An error has occurred'` about the single failure this whole
|
|
507
|
+
* module exists to report. Filled, the same call yields a sentence naming the
|
|
508
|
+
* budget that was spent and the position everything is failing just after, which
|
|
509
|
+
* is the first thing an operator queries the log at.
|
|
510
|
+
*
|
|
511
|
+
* ## Why THESE two fields, and why interpolating them is safe
|
|
512
|
+
*
|
|
513
|
+
* `noProgressRestarts` is a `number`. `position` is a branded `bigint`, and
|
|
514
|
+
* TEMPLATE INTERPOLATION of a `bigint` yields its decimal digits — so what leaves
|
|
515
|
+
* this getter is typed `string` and carries no `bigint` for any serialiser to
|
|
516
|
+
* meet. That distinction is worth being exact about, because this package
|
|
517
|
+
* measures a real `bigint` hazard and it is easy to over-apply: `Logger.json`
|
|
518
|
+
* throws on a `bigint` NESTED IN AN ANNOTATION VALUE and loses the whole line, so
|
|
519
|
+
* the reduction in `shouldRestart` hands the logger `String(stored)`. The hazard
|
|
520
|
+
* is about the values a logger is handed, never about a `string` rendered from
|
|
521
|
+
* one — and `String(fault)` is precisely how a fault carrying a `bigint` field
|
|
522
|
+
* crosses that boundary intact.
|
|
523
|
+
*
|
|
524
|
+
* `reason` is deliberately NOT in the label. It spans the supervised effect's
|
|
525
|
+
* whole `E` and arrives as `unknown`, so anything this class said about it would
|
|
526
|
+
* be a guess at somebody else's value — and there is no need to guess: the raw
|
|
527
|
+
* fault reaches `onStalled` untouched, and the give-up log line beside it already
|
|
528
|
+
* carries `String(reason)`, which is the same self-rendering rule applied one
|
|
529
|
+
* level down.
|
|
530
|
+
*
|
|
531
|
+
* A GETTER over two existing fields rather than a `message` field, so neither can
|
|
532
|
+
* disagree with the label. Why a prototype accessor is safe here, how far filling
|
|
533
|
+
* the field reaches and the structured shape it leaves a machine to parse are set
|
|
534
|
+
* out once for all four of these getters on `ProjectionStoreError` in
|
|
535
|
+
* `ProjectionStore.ts`.
|
|
536
|
+
*/
|
|
537
|
+
override get message(): string {
|
|
538
|
+
return `no checkpoint progress in ${this.noProgressRestarts} consecutive restarts (stuck at ${this.position})`
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Everything one supervision needs beyond the effect being supervised: WHAT is
|
|
544
|
+
* supervised (`key`), WHERE its progress is observed (`progress`), and the tuning
|
|
545
|
+
* and hooks `ProjectionSupervisionOptions` documents.
|
|
546
|
+
*
|
|
547
|
+
* One FLAT object, extending the two tuning interfaces rather than nesting either,
|
|
548
|
+
* for the reason `ProjectionRunnerOptions` gives at length: the
|
|
549
|
+
* `{ ...OPTIONS, oneField }` override this repo writes everywhere is a shallow
|
|
550
|
+
* spread, so a nested half would lose its siblings silently.
|
|
551
|
+
*
|
|
552
|
+
* Those two are exactly the halves of `ProjectionSupervisionOptions`:
|
|
553
|
+
* `UnresolvedSupervisionOptions`, which `buildAndFork` forwards by SPREADING the
|
|
554
|
+
* caller's own object, and `ResolvedSupervisionTuning`, which it names one by one
|
|
555
|
+
* beside that spread. So flatness costs this module nothing and hides nothing. A
|
|
556
|
+
* caller's whole unresolved half travels through in one expression — an optional
|
|
557
|
+
* field added there needs no edit anywhere to arrive — and a figure this module
|
|
558
|
+
* requires cannot arrive from that half at all: the spread's TYPE no longer carries
|
|
559
|
+
* one, and the OBJECT does not either, `buildAndFork` discarding the three by
|
|
560
|
+
* rest-destructure before it spreads what is left. The split is the types' to keep
|
|
561
|
+
* rather than an enumeration's, and that call site is where the runtime half of it
|
|
562
|
+
* is argued.
|
|
563
|
+
*
|
|
564
|
+
* FIVE fields are REQUIRED here where every field of the half a caller writes is
|
|
565
|
+
* optional, for two different reasons that happen to arrive at the same modifier.
|
|
566
|
+
*
|
|
567
|
+
* `key` and `progress` have no defensible default: a supervisor with no key could
|
|
568
|
+
* not name the thing it is reporting on, and one with no signal would be counting
|
|
569
|
+
* failures — the very thing this module exists not to do.
|
|
570
|
+
*
|
|
571
|
+
* The three restart `Duration`s do have defaults, and are required anyway, because
|
|
572
|
+
* by the time a figure arrives here `resolveProjection` has RESOLVED it against the
|
|
573
|
+
* defaults above and the construction gate has JUDGED the resolved value.
|
|
574
|
+
* Re-admitting an absent field would mean a second `?? DEFAULT` in the body below,
|
|
575
|
+
* and then the figure that RUNS could differ from the figure that was checked —
|
|
576
|
+
* which is the one failure mode a construction-time check cannot survive, since it
|
|
577
|
+
* would report a wiring sound and then run a different one. Required, this module
|
|
578
|
+
* cannot be handed a duration nothing validated. `maxNoProgressRestarts` is the
|
|
579
|
+
* exception that shows the rule: it stays optional and is defaulted below, because
|
|
580
|
+
* its brand did the judging at the caller's own `.make(...)` and there is no
|
|
581
|
+
* resolution for the gate to be inconsistent with.
|
|
582
|
+
*/
|
|
583
|
+
export interface SuperviseOnProgressOptions
|
|
584
|
+
extends UnresolvedSupervisionOptions,
|
|
585
|
+
ResolvedSupervisionTuning {
|
|
586
|
+
/**
|
|
587
|
+
* Names the supervised thing. It is what both log lines, both hook payloads and
|
|
588
|
+
* the `ProjectionStalled` failure report, because an operator's first question
|
|
589
|
+
* about a stalled projection is which one.
|
|
590
|
+
*/
|
|
591
|
+
readonly key: CheckpointKey
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* The externally observed signal, re-read after EVERY failure — which is what
|
|
595
|
+
* makes the give-up decision about the world rather than about this process's
|
|
596
|
+
* own attempt count.
|
|
597
|
+
*
|
|
598
|
+
* A VALUE, not a thunk and not a service: nothing is read until the first
|
|
599
|
+
* failure, so building a supervision touches the view store not at all. That is
|
|
600
|
+
* what keeps the read side's construction-time defects honest — a runner the
|
|
601
|
+
* wiring gate rejected has read no checkpoint and opened no subscription, which
|
|
602
|
+
* every construction case in this package asserts.
|
|
603
|
+
*/
|
|
604
|
+
readonly progress: Effect.Effect<Position, ProjectionStoreError>
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Supervise `run`: retry it while `options.progress` moves, give up with
|
|
609
|
+
* `ProjectionStalled` when it does not.
|
|
610
|
+
*
|
|
611
|
+
* Generic in the supervised effect's `A`, `E` and `R`. The supervisor reads the
|
|
612
|
+
* failure only through `String` and hands it to `onRestart`/`onStalled` as
|
|
613
|
+
* `unknown`, so it has no reason to constrain `E`; its own error channel is exactly
|
|
614
|
+
* `ProjectionStalled`, because the breaker collapses everything else into another
|
|
615
|
+
* restart; and `R` passes through untouched, nothing here adding a requirement,
|
|
616
|
+
* which is what keeps either entry point's requirement list the pipeline's own.
|
|
617
|
+
*
|
|
618
|
+
* ONE CALL rather than a curried supervisor later applied to an attempt. The rank-2
|
|
619
|
+
* intermediate bought exactly one thing: the tuning could be validated before the
|
|
620
|
+
* attempt existed, which fixed the ORDER a mis-tuned supervisor was reported in
|
|
621
|
+
* relative to the runner's other construction-time checks. That argument is now
|
|
622
|
+
* gone outright rather than merely outweighed — the tuning is judged by
|
|
623
|
+
* `projectionWiringFault` before a supervisor, an attempt or a fibre exists, in an
|
|
624
|
+
* order that module declares — so what a curried form would leave behind is a type
|
|
625
|
+
* whose only application sat a line from its construction.
|
|
626
|
+
*
|
|
627
|
+
* It validates NOTHING, which is deliberate rather than an omission. Every
|
|
628
|
+
* construction-time rejection on the read side has ONE home — the wiring gate, whose
|
|
629
|
+
* own single caller is the shared `prepareProjection` phase both entry points run
|
|
630
|
+
* before anything is built — and this module is
|
|
631
|
+
* package-internal with exactly one caller, so a check here would be a SECOND home
|
|
632
|
+
* for one rule: two sentences for one mistake, free to drift, each reachable only by
|
|
633
|
+
* whichever of the two modules its author happened to open. What arrives here has
|
|
634
|
+
* therefore already been resolved and judged, which is why the three restart
|
|
635
|
+
* `Duration`s are REQUIRED on `SuperviseOnProgressOptions` and why nothing below
|
|
636
|
+
* reaches for a default. The mistake still lands as a DEFECT at construction,
|
|
637
|
+
* exactly as `assertServableQuery`'s does; the gate is merely where.
|
|
638
|
+
*
|
|
639
|
+
* Nothing is taken for RENDERING the supervised effect's `E` on the two log lines:
|
|
640
|
+
* the `reason` annotation is `String(fault)`, so the fault labels itself. That is a
|
|
641
|
+
* real reduction rather than a floor, because `Error.prototype.toString` is
|
|
642
|
+
* `name: message` and `Data.TaggedError` sets `name` to the tag — so a fault that
|
|
643
|
+
* fills `message` renders as `'SomeTag: why'`, and one that does not renders as
|
|
644
|
+
* `'SomeTag'`. A caller wanting a better label sets `message` on its own error,
|
|
645
|
+
* where the knowledge belongs and where `Cause.pretty` and every other reader
|
|
646
|
+
* benefit from it too, rather than handing a table to a supervisor that would be
|
|
647
|
+
* the only thing to use it.
|
|
648
|
+
*
|
|
649
|
+
* The returned effect is re-runnable: the two supervision `Ref`s are created INSIDE
|
|
650
|
+
* it rather than here, so every run starts a fresh supervision session instead of
|
|
651
|
+
* inheriting the breaker state a previous run left behind.
|
|
652
|
+
*/
|
|
653
|
+
export const superviseOnProgress = <A, E, R>(
|
|
654
|
+
run: Effect.Effect<A, E, R>,
|
|
655
|
+
options: SuperviseOnProgressOptions,
|
|
656
|
+
): Effect.Effect<A, ProjectionStalled, R> => {
|
|
657
|
+
// The three restart durations are TAKEN, not defaulted, while the COUNT keeps its
|
|
658
|
+
// default: `SuperviseOnProgressOptions` above argues that asymmetry in full.
|
|
659
|
+
const { key, progress, restartMinDelay, restartMaxDelay, restartResetAfter } =
|
|
660
|
+
options
|
|
661
|
+
const maxNoProgressRestarts =
|
|
662
|
+
options.maxNoProgressRestarts ?? DEFAULT_MAX_NO_PROGRESS_RESTARTS
|
|
663
|
+
const onRestart = options.onRestart ?? (() => Effect.void)
|
|
664
|
+
const onStalled = options.onStalled ?? (() => Effect.void)
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* The restart pacing: a capped exponential that NEVER TERMINATES on its own.
|
|
668
|
+
*
|
|
669
|
+
* `Schedule.union` continues while EITHER arm continues and uses the SHORTER
|
|
670
|
+
* of the two delays, so unioning an unbounded exponential with a fixed
|
|
671
|
+
* `spaced(restartMaxDelay)` yields exponential growth up to the cap and then
|
|
672
|
+
* a flat cap — with no `recurs` arm, because the give-up decision belongs to
|
|
673
|
+
* the circuit-breaker (which knows whether anything is progressing) and not
|
|
674
|
+
* to a schedule (which only knows how many times it has fired).
|
|
675
|
+
*
|
|
676
|
+
* `jittered` because restarts are correlated: a database blip restarts every
|
|
677
|
+
* projection in the deployment at the same instant, and an unjittered backoff
|
|
678
|
+
* would march them all back in lockstep.
|
|
679
|
+
*
|
|
680
|
+
* `resetAfter` keys on ELAPSED retrying time rather than on the length of the
|
|
681
|
+
* last run (`Schedule.elapsed`, verified against the installed source), which
|
|
682
|
+
* buys two things at once: a run that stays up for `restartResetAfter` starts
|
|
683
|
+
* its next backoff from `restartMinDelay` instead of inheriting a ceiling from
|
|
684
|
+
* a fault it recovered from hours ago, and a sustained outage sawtooths from
|
|
685
|
+
* `restartMinDelay` up to the cap and back rather than pinning at the cap for
|
|
686
|
+
* ever. The sawtooth is also what makes the no-progress budget computable —
|
|
687
|
+
* the arithmetic is on `DEFAULT_MAX_NO_PROGRESS_RESTARTS`, and a test pins it.
|
|
688
|
+
*
|
|
689
|
+
* Effect v3 caveat: this is the THREE-type-parameter `Schedule<Out, In, R>`
|
|
690
|
+
* that `effect@3` exports. The currently-published `Schedule` docs show a
|
|
691
|
+
* newer four-type-parameter shape — do not copy them; this is written against
|
|
692
|
+
* the installed type definitions.
|
|
693
|
+
*/
|
|
694
|
+
const restartSchedule = Schedule.union(
|
|
695
|
+
Schedule.exponential(restartMinDelay, 2),
|
|
696
|
+
Schedule.spaced(restartMaxDelay),
|
|
697
|
+
).pipe(Schedule.jittered, Schedule.resetAfter(restartResetAfter))
|
|
698
|
+
|
|
699
|
+
return Effect.gen(function* () {
|
|
700
|
+
// Supervision state, owned OUTSIDE the retried effect so it survives a
|
|
701
|
+
// restart — which is the whole point: the breaker's question is about the
|
|
702
|
+
// sequence of restarts, not about any one of them.
|
|
703
|
+
const noProgressRestarts = yield* Ref.make(0)
|
|
704
|
+
// The signal observed at the previous restart. Seeded with `ORIGIN` rather
|
|
705
|
+
// than with a read of `progress` (which is not read until the first
|
|
706
|
+
// failure): a read model resuming from a non-zero durable checkpoint
|
|
707
|
+
// therefore scores its FIRST failure as progress and gets one extra restart
|
|
708
|
+
// before the counter engages. That errs towards retrying, which is the safe
|
|
709
|
+
// direction for a breaker.
|
|
710
|
+
const lastRestartPosition = yield* Ref.make(ORIGIN)
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* The circuit-breaker, evaluated on every failure and BEFORE the backoff
|
|
714
|
+
* sleep: does the progress signal show that anything is moving?
|
|
715
|
+
*
|
|
716
|
+
* Keying on the signal rather than on a failure count or on the error's tag
|
|
717
|
+
* is what makes competing runners behave — the module doc sets out that
|
|
718
|
+
* story in full. Conversely a poison event or a broken view store leaves the
|
|
719
|
+
* position pinned however many times the run restarts, so the breaker trips
|
|
720
|
+
* on exactly the cases retrying cannot fix.
|
|
721
|
+
*/
|
|
722
|
+
const shouldRestart = (reason: E): Effect.Effect<boolean> =>
|
|
723
|
+
Effect.gen(function* () {
|
|
724
|
+
const previous = yield* Ref.get(lastRestartPosition)
|
|
725
|
+
|
|
726
|
+
// A view store too broken to answer `readCheckpoint` cannot report
|
|
727
|
+
// progress either, so a failed re-read is treated as "unchanged" and
|
|
728
|
+
// counts towards the breaker. That is the conservative reading: the
|
|
729
|
+
// alternative (retrying for ever because we cannot tell) is exactly the
|
|
730
|
+
// silent stall the breaker exists to end.
|
|
731
|
+
//
|
|
732
|
+
// `catchAll`, not `orElseSucceed`: the latter falls back on any cause
|
|
733
|
+
// carrying no defect, which includes INTERRUPTION, so a scope closing
|
|
734
|
+
// mid-re-read would be absorbed here — the daemon would go on to count a
|
|
735
|
+
// restart, log it and call `onRestart` while it was being torn down, and
|
|
736
|
+
// a teardown arriving on the last of the budget would report a
|
|
737
|
+
// `ProjectionStalled` that never happened — and now PAGE somebody about
|
|
738
|
+
// it, since `onStalled` fires on that branch. It is the same trap
|
|
739
|
+
// `catchAllDefect` avoids at the runner's stream boundary. `catchAll`
|
|
740
|
+
// touches only the typed `ProjectionStoreError`.
|
|
741
|
+
const stored = yield* Effect.catchAll(progress, () =>
|
|
742
|
+
Effect.succeed(previous),
|
|
743
|
+
)
|
|
744
|
+
const progressed = stored !== previous
|
|
745
|
+
|
|
746
|
+
const restarts = progressed
|
|
747
|
+
? 0
|
|
748
|
+
: (yield* Ref.get(noProgressRestarts)) + 1
|
|
749
|
+
yield* Ref.set(noProgressRestarts, restarts)
|
|
750
|
+
yield* Ref.set(lastRestartPosition, stored)
|
|
751
|
+
|
|
752
|
+
// ## The log line is the LOSSY channel; the hooks are the faithful one
|
|
753
|
+
//
|
|
754
|
+
// Both supervision outcomes now reach a programmatic consumer carrying
|
|
755
|
+
// the fault ITSELF — `onRestart` at the foot of this function,
|
|
756
|
+
// `onStalled` just below — so this block is no longer the only record of
|
|
757
|
+
// what happened. It is the convenience channel: flat, greppable, and
|
|
758
|
+
// reduced to JSON-safe scalars on purpose. Anything that needs a fault's
|
|
759
|
+
// FIELDS takes a hook; anything that needs a line in a log takes this.
|
|
760
|
+
//
|
|
761
|
+
// ## Why every value is JSON-SAFE, MEASURED rather than assumed
|
|
762
|
+
//
|
|
763
|
+
// The bound is serialisability, not stringiness: `noProgressRestarts`
|
|
764
|
+
// below goes out as a NUMBER, which no serialiser objects to and which a
|
|
765
|
+
// log aggregator can filter, threshold and graph on — properties a
|
|
766
|
+
// `String` around it would throw away in exchange for nothing. What has
|
|
767
|
+
// to be reduced is the value class a serialiser genuinely chokes on.
|
|
768
|
+
//
|
|
769
|
+
// A `bigint` is not JSON-serialisable and these faults carry them
|
|
770
|
+
// (`CheckpointSuperseded.expected` is a `Position`). What each logger
|
|
771
|
+
// `effect@3.22` ships actually does with one was established by running
|
|
772
|
+
// it, not read off the documentation:
|
|
773
|
+
//
|
|
774
|
+
// - `Logger.json` special-cases a TOP-LEVEL `bigint` annotation value
|
|
775
|
+
// (`internal/logger.ts`'s `structuredMessage`), but a `bigint` NESTED
|
|
776
|
+
// inside an annotation's object survives `Inspectable.toJSON` into the
|
|
777
|
+
// final `Inspectable.stringifyCircular`, which throws `TypeError: Do
|
|
778
|
+
// not know how to serialize a BigInt`. Nothing catches it, so the throw
|
|
779
|
+
// takes the WHOLE line — every other annotation with it — and lands as
|
|
780
|
+
// a defect inside the supervisor, on the one path whose entire job is
|
|
781
|
+
// to make failures visible.
|
|
782
|
+
// - The DEFAULT logger (`stringLogger`) does not throw: it renders each
|
|
783
|
+
// annotation through `Inspectable.toStringUnknown`, whose `try/catch`
|
|
784
|
+
// falls back to `String(value)`. The same loss, quieter — the offending
|
|
785
|
+
// annotation degrades to a bare tag, or to `[object Object]`.
|
|
786
|
+
// - `Logger.pretty` hands the value to `console.log` and prints `42n`.
|
|
787
|
+
//
|
|
788
|
+
// So the reduction happens HERE, before the logger sees anything.
|
|
789
|
+
// `position` is `String`ed rather than handed over as a `Position`
|
|
790
|
+
// despite all three shipped loggers coping with a bare `bigint`: they
|
|
791
|
+
// render it identically to `String`, so there is nothing to win, and a
|
|
792
|
+
// third-party logger that JSON-encodes its annotation map naively is a
|
|
793
|
+
// real thing.
|
|
794
|
+
//
|
|
795
|
+
// `reason` is `String`ed too, and that is the WHOLE reduction — no table
|
|
796
|
+
// of a caller's fault union, because the faults can label themselves.
|
|
797
|
+
// `Error.prototype.toString` is `name: message` and `Data.TaggedError`
|
|
798
|
+
// sets `name` to the tag, so a fault that fills `message` arrives as
|
|
799
|
+
// `'ProjectionStoreError: connection reset'` and one that does not arrives
|
|
800
|
+
// as its bare tag. This module is generic in `E`, so that is also all it
|
|
801
|
+
// could honestly do: the choice of what to say is the fault's, made once
|
|
802
|
+
// on the class, and `Cause.pretty` and `String(fault)` everywhere else
|
|
803
|
+
// read the same field.
|
|
804
|
+
const annotations = {
|
|
805
|
+
projection: key.projection,
|
|
806
|
+
partition: key.partition,
|
|
807
|
+
position: String(stored),
|
|
808
|
+
noProgressRestarts: restarts,
|
|
809
|
+
reason: String(reason),
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
if (restarts >= maxNoProgressRestarts) {
|
|
813
|
+
// Logged HERE, not by the caller, because a stalled projection must
|
|
814
|
+
// never be silent: `runProjection` returns an infallible effect, so
|
|
815
|
+
// unless somebody awaits the fibre the `ProjectionStalled` failure has
|
|
816
|
+
// no other route to an operator.
|
|
817
|
+
//
|
|
818
|
+
// `Effect.annotateLogs`, not a second argument to `logError`. Effect's
|
|
819
|
+
// log constructors are VARIADIC IN THE MESSAGE (`logError: (...message:
|
|
820
|
+
// ReadonlyArray<any>) => Effect<void>`), so an object passed second is
|
|
821
|
+
// a second message and not an annotation at all — it used to arrive as
|
|
822
|
+
// a JSON blob wedged inside the line rather than as the structured
|
|
823
|
+
// fields the reduction above was built for. Annotating puts each field
|
|
824
|
+
// where a logger can render it in its own idiom: `reason=… position=…`
|
|
825
|
+
// under the default logger, discrete keys under `annotations` in
|
|
826
|
+
// `Logger.json`. That is the logger owning the presentation, which is
|
|
827
|
+
// its job; all this module owes it is values it can serialise.
|
|
828
|
+
yield* Effect.annotateLogs(
|
|
829
|
+
Effect.logError(
|
|
830
|
+
'kairos-es projection stalled: giving up after consecutive restarts with no checkpoint progress',
|
|
831
|
+
),
|
|
832
|
+
annotations,
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
// ...and then the SEAM, because a log line is not an alert. The
|
|
836
|
+
// give-up is the outcome nothing recovers from, so it gets the hook
|
|
837
|
+
// the restart already had — an application can fail a health check,
|
|
838
|
+
// exit non-zero or page from here. `onStalled`'s doc carries the
|
|
839
|
+
// asymmetry argument in full.
|
|
840
|
+
//
|
|
841
|
+
// AFTER the log, deliberately: the log is the guaranteed route out and
|
|
842
|
+
// a defect from an application's hook must not be able to silence it.
|
|
843
|
+
// The payload is the `ProjectionStalled` this give-up is about to
|
|
844
|
+
// produce, field for field and value for value — `stored` is what was
|
|
845
|
+
// just written to `lastRestartPosition`, `restarts` what was just
|
|
846
|
+
// written to `noProgressRestarts`, and `reason` is the failure the
|
|
847
|
+
// `catchAll` below will carry — so a hook and a caller awaiting the
|
|
848
|
+
// daemon never see two versions of one stall. The RAW fault rather
|
|
849
|
+
// than the log label, for the same reason the annotations take the
|
|
850
|
+
// label: a callback can read a `bigint`, a serialiser cannot.
|
|
851
|
+
yield* onStalled({
|
|
852
|
+
key,
|
|
853
|
+
noProgressRestarts: restarts,
|
|
854
|
+
position: stored,
|
|
855
|
+
reason,
|
|
856
|
+
})
|
|
857
|
+
return false
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
yield* Effect.annotateLogs(
|
|
861
|
+
Effect.logWarning(
|
|
862
|
+
'kairos-es projection restarting from its stored checkpoint',
|
|
863
|
+
),
|
|
864
|
+
annotations,
|
|
865
|
+
)
|
|
866
|
+
yield* onRestart({ key, reason, noProgressRestarts: restarts })
|
|
867
|
+
return true
|
|
868
|
+
})
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* `Effect.retry`'s `while` IS the breaker, which is why the retry can exit
|
|
872
|
+
* in exactly one way: the schedule never terminates, so the only path out
|
|
873
|
+
* with a failure is `shouldRestart` returning `false`. The residual error is
|
|
874
|
+
* therefore always the give-up case, and mapping it to `ProjectionStalled`
|
|
875
|
+
* is total rather than a fallback for an unreachable branch.
|
|
876
|
+
*
|
|
877
|
+
* A run that *succeeds* ends the daemon quietly. For a projection that is
|
|
878
|
+
* the honest translation of "there is nothing left to read", which the
|
|
879
|
+
* shipped stores' live tails never say.
|
|
880
|
+
*/
|
|
881
|
+
return yield* Effect.retry(run, {
|
|
882
|
+
schedule: restartSchedule,
|
|
883
|
+
while: shouldRestart,
|
|
884
|
+
}).pipe(
|
|
885
|
+
Effect.catchAll((reason) =>
|
|
886
|
+
Effect.gen(function* () {
|
|
887
|
+
return yield* new ProjectionStalled({
|
|
888
|
+
key,
|
|
889
|
+
noProgressRestarts: yield* Ref.get(noProgressRestarts),
|
|
890
|
+
position: yield* Ref.get(lastRestartPosition),
|
|
891
|
+
reason,
|
|
892
|
+
})
|
|
893
|
+
}),
|
|
894
|
+
),
|
|
895
|
+
)
|
|
896
|
+
})
|
|
897
|
+
}
|