@cotal-ai/core 0.22.0 → 0.24.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/dist/checkpoint-answer.d.ts +46 -0
- package/dist/checkpoint-answer.d.ts.map +1 -0
- package/dist/checkpoint-answer.js +112 -0
- package/dist/checkpoint-answer.js.map +1 -0
- package/dist/endpoint-binding.d.ts +61 -0
- package/dist/endpoint-binding.d.ts.map +1 -1
- package/dist/endpoint-binding.js +109 -0
- package/dist/endpoint-binding.js.map +1 -1
- package/dist/endpoint-checkpoint.d.ts +21 -0
- package/dist/endpoint-checkpoint.d.ts.map +1 -1
- package/dist/endpoint-checkpoint.js +41 -3
- package/dist/endpoint-checkpoint.js.map +1 -1
- package/dist/endpoint-records.d.ts.map +1 -1
- package/dist/endpoint-records.js +86 -0
- package/dist/endpoint-records.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/provision.d.ts.map +1 -1
- package/dist/provision.js +7 -2
- package/dist/provision.js.map +1 -1
- package/dist/run-journal.d.ts +337 -0
- package/dist/run-journal.d.ts.map +1 -0
- package/dist/run-journal.js +703 -0
- package/dist/run-journal.js.map +1 -0
- package/dist/run-migration.d.ts +85 -0
- package/dist/run-migration.d.ts.map +1 -0
- package/dist/run-migration.js +201 -0
- package/dist/run-migration.js.map +1 -0
- package/dist/run-notice.d.ts +84 -0
- package/dist/run-notice.d.ts.map +1 -0
- package/dist/run-notice.js +212 -0
- package/dist/run-notice.js.map +1 -0
- package/dist/run-record.d.ts +104 -0
- package/dist/run-record.d.ts.map +1 -0
- package/dist/run-record.js +58 -0
- package/dist/run-record.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,703 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The workflow run journal's APPEND path — the activation barrier.
|
|
3
|
+
*
|
|
4
|
+
* A run has exactly one authoritative appender at a time, and the hard part is not choosing it but
|
|
5
|
+
* stopping the previous one. A driver that reads a lease token and then publishes performs two
|
|
6
|
+
* operations: it can read a token that is still valid, be preempted, lose the lease, and publish
|
|
7
|
+
* anyway. Every client-side variant of that check has the window BY CONSTRUCTION — narrowing the
|
|
8
|
+
* gap does not close it.
|
|
9
|
+
*
|
|
10
|
+
* So the STREAM is the acceptor, not the client. Every append carries
|
|
11
|
+
* `Nats-Expected-Last-Subject-Sequence` for the run's own subject, which JetStream evaluates
|
|
12
|
+
* server-side and atomically: the publish lands only if the run's subject is exactly where the
|
|
13
|
+
* publisher believed it was. There is no read-then-publish window because there is no read.
|
|
14
|
+
*
|
|
15
|
+
* Takeover is replay-then-activate, and the two are one mechanism rather than two adjacent steps:
|
|
16
|
+
*
|
|
17
|
+
* 1. The successor replays the run subject from the beginning. It must do this anyway — resume is
|
|
18
|
+
* re-running the program with journalled effects returning recorded results — and the LAST
|
|
19
|
+
* REPLAYED MESSAGE'S STREAM SEQUENCE is exactly the subject's last sequence. This is the only
|
|
20
|
+
* authoritative head there is: `STREAM.INFO`'s `last_seq` is stream-WIDE and its
|
|
21
|
+
* `subjects_filter` returns per-subject message COUNTS, not sequences, so an INFO-derived
|
|
22
|
+
* expectation is wrong the moment any other run appends (measured: a subject whose head was 2
|
|
23
|
+
* reported `last_seq` 3, and publishing at 3 was refused 10071).
|
|
24
|
+
* 2. Its first act is an ACTIVATION record appended at that expected sequence.
|
|
25
|
+
* 3. Once the activation lands it has advanced the subject, so any append still in flight from the
|
|
26
|
+
* superseded driver carries an expectation from before it and the SERVER rejects it.
|
|
27
|
+
*
|
|
28
|
+
* **TWO CAS FAILURES THAT ARE NOT THE SAME STATE**, and conflating them was this barrier's first
|
|
29
|
+
* defect. Nothing orders a successor's activation ahead of a predecessor's last packet: the
|
|
30
|
+
* predecessor's delayed append can land FIRST and take the sequence the successor was activating
|
|
31
|
+
* at. So:
|
|
32
|
+
*
|
|
33
|
+
* - A refused ACTIVATION means "my replay is stale", not "I am superseded". The successor has
|
|
34
|
+
* driven nothing yet — that is the rule, not an accident of timing — and the entries that beat
|
|
35
|
+
* it are simply more prefix. It re-replays and activates again while it still holds the lease,
|
|
36
|
+
* or it releases the lease. Retrying here is correct.
|
|
37
|
+
* - A refused APPEND, after an activation that won, means "someone else activated". That driver
|
|
38
|
+
* IS superseded: it stops, and it never refreshes the sequence and tries again, because a retry
|
|
39
|
+
* at the new head is exactly the defect the barrier exists to prevent wearing a different hat —
|
|
40
|
+
* a driver that has already lost the run re-establishing itself by asking again.
|
|
41
|
+
*
|
|
42
|
+
* **ONE SERIAL PUMP PER RUN.** Concurrency scopes append from several branches at once, and two
|
|
43
|
+
* publishes carrying the same expectation cannot both land: the server accepts one and refuses the
|
|
44
|
+
* other, which under the rule above would make a driver declare ITSELF superseded while it is the
|
|
45
|
+
* only writer. So the appender owns one queue and one head, publishes one entry at a time, and
|
|
46
|
+
* advances the head only from each PubAck. Any outcome without a PubAck — a refusal, a timeout, a
|
|
47
|
+
* dropped connection — poisons it: everything queued behind fails with the same terminal error and
|
|
48
|
+
* never reaches the wire.
|
|
49
|
+
*
|
|
50
|
+
* **ONE SUBJECT PER RUN**, `<spacePrefix>.wfj.<runId>`, where a per-entry subject would write
|
|
51
|
+
* `…wfj.<runId>.<entryId>`. This is a deviation, and it is NOT because a per-entry subject cannot
|
|
52
|
+
* be fenced — it can: `Nats-Expected-Last-Subject-Sequence-Subject` lets the expectation be
|
|
53
|
+
* evaluated against a wildcard comparator, and it works on the repo's broker floor (measured on
|
|
54
|
+
* 2.12.1: per-entry subjects under a `wfj.<runId>.*` comparator accepted at the run's head, refused
|
|
55
|
+
* 10071 on a stale one, and were unaffected by another run's appends). The reasons are the ones the
|
|
56
|
+
* range exists for: per-run ordering, per-run replay by consumer filter, and cheap retirement by
|
|
57
|
+
* subject purge, and all three are properties of the RUN token — the entry token buys per-entry
|
|
58
|
+
* point reads, which an append-only journal replayed in full never issues. Against that it costs
|
|
59
|
+
* one stream subject per journal entry forever, in a stream that deliberately has no age eviction,
|
|
60
|
+
* and a second header whose absence or mismatch degrades silently to a per-publish-subject
|
|
61
|
+
* comparison — which on a fresh entry subject is `0`, i.e. always a create, i.e. no fence at all. A
|
|
62
|
+
* fence that fails open when misconfigured is worse than one coordinate less addressable.
|
|
63
|
+
*/
|
|
64
|
+
import { randomBytes } from "node:crypto";
|
|
65
|
+
import { jetstream, jetstreamManager } from "@nats-io/jetstream";
|
|
66
|
+
import { headers as natsHeaders } from "@nats-io/transport-node";
|
|
67
|
+
import { wfjStreamName, wfjSubject, runJournalConsumerConfig } from "./endpoint-binding.js";
|
|
68
|
+
import { isCasLoss } from "./endpoint-records.js";
|
|
69
|
+
/**
|
|
70
|
+
* The server refused an append from a driver that had already activated: the run's subject is no
|
|
71
|
+
* longer where this driver left it.
|
|
72
|
+
*
|
|
73
|
+
* Usually that means someone else activated, and that is what the name says. It is NOT the only
|
|
74
|
+
* cause: purging a run's subject — the retirement mechanism the subject range exists for — also
|
|
75
|
+
* moves the head out from under a live appender, and measured, it produced exactly this refusal
|
|
76
|
+
* with no successor in existence. So `isSuperseded` is "the stream refused my expectation", and a
|
|
77
|
+
* driver that needs to know WHICH replays the run: a journal that reads back empty was retired, one
|
|
78
|
+
* that carries a later activation was taken over.
|
|
79
|
+
*
|
|
80
|
+
* Not retryable either way, and not an effect failure. It says nothing about the effect the entry
|
|
81
|
+
* described — whatever it did, it did — only that this process may no longer speak for the run.
|
|
82
|
+
*/
|
|
83
|
+
export class RunSuperseded extends Error {
|
|
84
|
+
run;
|
|
85
|
+
subject;
|
|
86
|
+
expectedSeq;
|
|
87
|
+
cause;
|
|
88
|
+
constructor(run, subject, expectedSeq, cause) {
|
|
89
|
+
super(`run ${run}: an append to ${subject} expecting last sequence ${expectedSeq} was refused by the stream — someone else activated, or the run was retired. This driver is finished either way: stop, and do not retry with a refreshed sequence. Replay the run to learn which it was.`);
|
|
90
|
+
this.run = run;
|
|
91
|
+
this.subject = subject;
|
|
92
|
+
this.expectedSeq = expectedSeq;
|
|
93
|
+
this.cause = cause;
|
|
94
|
+
this.name = "RunSuperseded";
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* An append ended without a PubAck for a reason that is not a refusal.
|
|
99
|
+
*
|
|
100
|
+
* A timeout, a dropped connection, a serialization failure: the entry may be on disk, may not be,
|
|
101
|
+
* and the appender cannot tell. Either way its head is no longer known to match the subject's, so
|
|
102
|
+
* every later append would carry an expectation it invented. That is the read-then-publish window
|
|
103
|
+
* the barrier exists to close, so this is terminal in exactly the way {@link RunSuperseded} is —
|
|
104
|
+
* distinct only because nobody else has necessarily taken the run, and the same driver may recover
|
|
105
|
+
* it by replaying and activating again, which is what re-reads the true head.
|
|
106
|
+
*/
|
|
107
|
+
export class RunJournalStalled extends Error {
|
|
108
|
+
run;
|
|
109
|
+
subject;
|
|
110
|
+
expectedSeq;
|
|
111
|
+
cause;
|
|
112
|
+
constructor(run, subject, expectedSeq, cause) {
|
|
113
|
+
super(`run ${run} lost the head of ${subject}: an append expecting last sequence ${expectedSeq} ended without a PubAck (${messageOfCause(cause)}). Whether it landed is unknown — this appender is finished. Replay and activate again to learn the real head.`);
|
|
114
|
+
this.run = run;
|
|
115
|
+
this.subject = subject;
|
|
116
|
+
this.expectedSeq = expectedSeq;
|
|
117
|
+
this.cause = cause;
|
|
118
|
+
this.name = "RunJournalStalled";
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function messageOfCause(cause) {
|
|
122
|
+
return cause instanceof Error ? `${cause.name}: ${cause.message}` : String(cause);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* An ACTIVATION lost its CAS: the prefix this driver replayed is already stale.
|
|
126
|
+
*
|
|
127
|
+
* Deliberately not {@link RunSuperseded}. Nothing orders a takeover ahead of the outgoing driver's
|
|
128
|
+
* last packet, so losing here is the ordinary case of "it appended while I was reading", and the
|
|
129
|
+
* successor has performed no work to abandon. It re-replays and activates again while it still holds
|
|
130
|
+
* the lease. Only an append AFTER a won activation means superseded.
|
|
131
|
+
*/
|
|
132
|
+
export class ActivationRaced extends Error {
|
|
133
|
+
run;
|
|
134
|
+
subject;
|
|
135
|
+
expectedSeq;
|
|
136
|
+
attempts;
|
|
137
|
+
cause;
|
|
138
|
+
constructor(run, subject, expectedSeq, attempts, cause) {
|
|
139
|
+
super(`run ${run}: activation on ${subject} at expected sequence ${expectedSeq} lost its CAS after ${attempts} attempt(s). The journal moved while it was being replayed, so this driver's prefix is stale. Re-replay and activate again while the lease is still held, or release it — this is NOT a supersession.`);
|
|
140
|
+
this.run = run;
|
|
141
|
+
this.subject = subject;
|
|
142
|
+
this.expectedSeq = expectedSeq;
|
|
143
|
+
this.attempts = attempts;
|
|
144
|
+
this.cause = cause;
|
|
145
|
+
this.name = "ActivationRaced";
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* The replay did not deliver the whole prefix.
|
|
150
|
+
*
|
|
151
|
+
* A short replay is the same failure as an evicted journal prefix: the run re-performs effects it
|
|
152
|
+
* has already performed, under a driver that believes it is resuming correctly. A pull iterator
|
|
153
|
+
* ends cleanly on a dropped connection, so "it stopped early" and "there was no more" look alike on
|
|
154
|
+
* the wire and have to be distinguished by counting.
|
|
155
|
+
*/
|
|
156
|
+
export class RunJournalReplayIncomplete extends Error {
|
|
157
|
+
run;
|
|
158
|
+
expected;
|
|
159
|
+
delivered;
|
|
160
|
+
constructor(run, expected, delivered) {
|
|
161
|
+
super(`run ${run}: the journal replay delivered ${delivered} of ${expected} records; the prefix is incomplete and must not be resumed from`);
|
|
162
|
+
this.run = run;
|
|
163
|
+
this.expected = expected;
|
|
164
|
+
this.delivered = delivered;
|
|
165
|
+
this.name = "RunJournalReplayIncomplete";
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Two drivers replayed the same run at once, and this one inherited the other's consumer.
|
|
170
|
+
*
|
|
171
|
+
* Retryable at the takeover level — the loser waits and replays again — and deliberately not an
|
|
172
|
+
* incomplete-replay error, because nothing is missing from the JOURNAL: the run is simply being
|
|
173
|
+
* contended, which is what a takeover is.
|
|
174
|
+
*/
|
|
175
|
+
export class RunJournalReplayRaced extends Error {
|
|
176
|
+
run;
|
|
177
|
+
durable;
|
|
178
|
+
alreadyDelivered;
|
|
179
|
+
constructor(run, durable, alreadyDelivered) {
|
|
180
|
+
super(`run ${run}: the replay consumer ${durable} had already delivered ${alreadyDelivered} records to someone else. Another driver is replaying this run; retry the takeover rather than resuming from a partial prefix.`);
|
|
181
|
+
this.run = run;
|
|
182
|
+
this.durable = durable;
|
|
183
|
+
this.alreadyDelivered = alreadyDelivered;
|
|
184
|
+
this.name = "RunJournalReplayRaced";
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* The replayed prefix does not start at the run's beginning.
|
|
189
|
+
*
|
|
190
|
+
* Its counts were consistent and its last sequence may well be the subject's true head — that is
|
|
191
|
+
* exactly why this check exists separately from {@link RunJournalReplayIncomplete}.
|
|
192
|
+
*/
|
|
193
|
+
/**
|
|
194
|
+
* A takeover was attempted under a lease token older than the one the run is already held under.
|
|
195
|
+
*
|
|
196
|
+
* Terminal for this driver: it does not hold the lease, and the fix is not to try again but to stop
|
|
197
|
+
* driving. The activation barrier cannot catch this on its own — a stale holder that replays learns
|
|
198
|
+
* the head like anyone else — so the ordering is enforced against the journal's own record of who
|
|
199
|
+
* activated, which the replay has just read.
|
|
200
|
+
*/
|
|
201
|
+
export class StaleLeaseToken extends Error {
|
|
202
|
+
run;
|
|
203
|
+
offered;
|
|
204
|
+
held;
|
|
205
|
+
holder;
|
|
206
|
+
constructor(run, offered, held, holder) {
|
|
207
|
+
super(`run ${run} is held under fencing token ${held} by ${holder}; a takeover offering ${offered} is stale. This driver's lease is gone — stop driving.`);
|
|
208
|
+
this.run = run;
|
|
209
|
+
this.offered = offered;
|
|
210
|
+
this.held = held;
|
|
211
|
+
this.holder = holder;
|
|
212
|
+
this.name = "StaleLeaseToken";
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* A takeover said the run exists and its journal is empty.
|
|
217
|
+
*
|
|
218
|
+
* Terminal. A run whose journal has been purged is RETIRED, not new, and the difference is
|
|
219
|
+
* unrecoverable from the stream alone — which is why the caller states which it expected.
|
|
220
|
+
*/
|
|
221
|
+
export class RunNotResumable extends Error {
|
|
222
|
+
run;
|
|
223
|
+
subject;
|
|
224
|
+
constructor(run, subject) {
|
|
225
|
+
super(`run ${run} was to be resumed, but ${subject} holds no records. Its journal has been retired or lost; there is nothing to resume, and starting it again here would re-perform whatever it already did.`);
|
|
226
|
+
this.run = run;
|
|
227
|
+
this.subject = subject;
|
|
228
|
+
this.name = "RunNotResumable";
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/** A takeover said the run is new and its journal already has records. Terminal: a run id is used
|
|
232
|
+
* once, and re-running one under its own id is a fork, which takes a new id. */
|
|
233
|
+
export class RunAlreadyStarted extends Error {
|
|
234
|
+
run;
|
|
235
|
+
records;
|
|
236
|
+
constructor(run, records) {
|
|
237
|
+
super(`run ${run} was to be started, but its journal already holds ${records} records. A run is never started twice under one id — a re-run is a fork, and a fork takes a new id.`);
|
|
238
|
+
this.run = run;
|
|
239
|
+
this.records = records;
|
|
240
|
+
this.name = "RunAlreadyStarted";
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
/** A takeover carried a current fencing token but not the identity that holds it. Terminal. */
|
|
244
|
+
export class ActivationNotAuthorized extends Error {
|
|
245
|
+
run;
|
|
246
|
+
why;
|
|
247
|
+
constructor(run, why) {
|
|
248
|
+
super(`run ${run}: ${why}. This driver may not activate.`);
|
|
249
|
+
this.run = run;
|
|
250
|
+
this.why = why;
|
|
251
|
+
this.name = "ActivationNotAuthorized";
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
export class RunJournalPrefixTruncated extends Error {
|
|
255
|
+
run;
|
|
256
|
+
seq;
|
|
257
|
+
expectedN;
|
|
258
|
+
foundN;
|
|
259
|
+
constructor(run, seq, expectedN, foundN) {
|
|
260
|
+
super(`run ${run}: the record at stream sequence ${seq} is journal entry ${foundN} where entry ${expectedN} was expected. Records are missing from this run's journal — it cannot be resumed, and re-running it from what is left would repeat effects it has already performed.`);
|
|
261
|
+
this.run = run;
|
|
262
|
+
this.seq = seq;
|
|
263
|
+
this.expectedN = expectedN;
|
|
264
|
+
this.foundN = foundN;
|
|
265
|
+
this.name = "RunJournalPrefixTruncated";
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* The freshness test itself, separate so it can be put to a consumer that really was inherited.
|
|
270
|
+
*
|
|
271
|
+
* A consumer that has delivered anything, or is holding an ack, has fed another driver part of this
|
|
272
|
+
* run; what is left on it is a tail, however consistent its counts look.
|
|
273
|
+
*/
|
|
274
|
+
export function assertReplayConsumerFresh(run, durable, info) {
|
|
275
|
+
if (info.delivered.consumer_seq !== 0 || info.num_ack_pending !== 0) {
|
|
276
|
+
throw new RunJournalReplayRaced(run, durable, info.delivered.consumer_seq);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Measured on the repo's broker floor: `name: "ConsumerNotFoundError"`, `code: 10014`.
|
|
281
|
+
*
|
|
282
|
+
* Exported so the one error the replay is allowed to swallow can be checked against a real one.
|
|
283
|
+
*/
|
|
284
|
+
export function isConsumerNotFound(e) {
|
|
285
|
+
const err = e;
|
|
286
|
+
return Number(err?.code) === 10014 || err?.name === "ConsumerNotFoundError";
|
|
287
|
+
}
|
|
288
|
+
function isObj(v) {
|
|
289
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
290
|
+
}
|
|
291
|
+
function assertKeys(o, allowed, what) {
|
|
292
|
+
for (const k of Object.keys(o))
|
|
293
|
+
if (!allowed.includes(k))
|
|
294
|
+
throw new Error(`${what} carries unknown key ${JSON.stringify(k)}`);
|
|
295
|
+
}
|
|
296
|
+
/** Parse one stored record. The envelope is validated; the step's `entry` is passed through. */
|
|
297
|
+
export function parseRunJournalRecord(raw, subject) {
|
|
298
|
+
if (!isObj(raw))
|
|
299
|
+
throw new Error(`run-journal record on ${subject} is not an object`);
|
|
300
|
+
if (raw.v !== 1)
|
|
301
|
+
throw new Error(`run-journal record on ${subject} has version ${JSON.stringify(raw.v)}, expected 1`);
|
|
302
|
+
if (typeof raw.run !== "string" || raw.run.length === 0)
|
|
303
|
+
throw new Error(`run-journal record on ${subject} has no run id`);
|
|
304
|
+
if (typeof raw.at !== "number")
|
|
305
|
+
throw new Error(`run-journal record on ${subject} has no timestamp`);
|
|
306
|
+
if (raw.kind === "step") {
|
|
307
|
+
assertKeys(raw, ["v", "kind", "run", "n", "at", "entry"], `run-journal step on ${subject}`);
|
|
308
|
+
if (!("entry" in raw))
|
|
309
|
+
throw new Error(`run-journal step on ${subject} carries no entry`);
|
|
310
|
+
if (typeof raw.n !== "number" || !Number.isInteger(raw.n) || raw.n < 0)
|
|
311
|
+
throw new Error(`run-journal step on ${subject} has no n`);
|
|
312
|
+
return raw;
|
|
313
|
+
}
|
|
314
|
+
if (raw.kind === "activation") {
|
|
315
|
+
assertKeys(raw, ["v", "kind", "run", "n", "holder", "fencingToken", "epoch", "replayedTo", "at"], `run-journal activation on ${subject}`);
|
|
316
|
+
if (typeof raw.holder !== "string" || raw.holder.length === 0)
|
|
317
|
+
throw new Error(`run-journal activation on ${subject} has no holder`);
|
|
318
|
+
for (const k of ["epoch", "replayedTo", "n"])
|
|
319
|
+
if (typeof raw[k] !== "number")
|
|
320
|
+
throw new Error(`run-journal activation on ${subject} has no ${k}`);
|
|
321
|
+
if (typeof raw.fencingToken !== "number" || !Number.isInteger(raw.fencingToken) || raw.fencingToken < 1)
|
|
322
|
+
throw new Error(`run-journal activation on ${subject} has no fencingToken`);
|
|
323
|
+
return raw;
|
|
324
|
+
}
|
|
325
|
+
throw new Error(`run-journal record on ${subject} has unknown kind ${JSON.stringify(raw.kind)}`);
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Read the run's whole journal, in append order, from the beginning.
|
|
329
|
+
*
|
|
330
|
+
* The replay durable is DELETED and recreated rather than reused. A durable remembers how far it
|
|
331
|
+
* delivered, and resume is not a cursor: every takeover needs the prefix FROM THE TOP, so a durable
|
|
332
|
+
* that survived the previous driver would hand its successor the empty tail and let it resume a run
|
|
333
|
+
* as if nothing had happened. Deleting is also why a rival's interference is bounded — it can only
|
|
334
|
+
* disturb a pre-activation replay, and a disturbed replay either fails its count check or loses its
|
|
335
|
+
* activation CAS, both of which end in "replay again".
|
|
336
|
+
*/
|
|
337
|
+
export async function replayRunJournal(js, jsm, space, runId, takeoverId) {
|
|
338
|
+
const stream = wfjStreamName(space);
|
|
339
|
+
const subject = wfjSubject(space, runId);
|
|
340
|
+
// A consumer nobody else names, created, read and removed by a single replay. One durable per RUN
|
|
341
|
+
// would make every takeover a race with every other: `add` returns an existing durable rather than
|
|
342
|
+
// refusing, so a contender inherits a half-read consumer, and each contender's delete tears down
|
|
343
|
+
// the other's live fetch.
|
|
344
|
+
const cfg = runJournalConsumerConfig(space, runId, takeoverId);
|
|
345
|
+
const durable = cfg.durable_name;
|
|
346
|
+
const created = await jsm.consumers.add(stream, cfg);
|
|
347
|
+
try {
|
|
348
|
+
return await readReplay(js, stream, durable, created, subject, runId);
|
|
349
|
+
}
|
|
350
|
+
finally {
|
|
351
|
+
// ONLY "already gone" is swallowed. A catch-all here would rest on the stream's limits reaping
|
|
352
|
+
// the leftovers, and they do not: WFJ sets neither `max_consumers` nor `consumer_limits`, which
|
|
353
|
+
// the server normalizes to unlimited with an inactive threshold of zero, and a durable at zero
|
|
354
|
+
// gets no delete timer at all, so a failed delete leaves a consumer behind for good, one per
|
|
355
|
+
// takeover. The consumer carries its own threshold (see `runJournalConsumerConfig`) so the
|
|
356
|
+
// server reaps it regardless, and a delete that fails for a reason we did not name is raised.
|
|
357
|
+
try {
|
|
358
|
+
await jsm.consumers.delete(stream, durable);
|
|
359
|
+
}
|
|
360
|
+
catch (e) {
|
|
361
|
+
if (!isConsumerNotFound(e))
|
|
362
|
+
throw e;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
/** A takeover id: what a driver asks its credential to be minted for. Callers that mint their own
|
|
367
|
+
* rows use this; one that is handed a credential uses the id that credential names. */
|
|
368
|
+
export function newTakeoverId() {
|
|
369
|
+
return randomBytes(8).toString("hex");
|
|
370
|
+
}
|
|
371
|
+
async function readReplay(js, stream, durable, created, subject, runId) {
|
|
372
|
+
assertReplayConsumerFresh(runId, durable, created);
|
|
373
|
+
// Belt and braces now that the name is unique: a consumer that has delivered anything is not the
|
|
374
|
+
// one this replay just created, and reading a tail as if it were a run is the failure this exists
|
|
375
|
+
// to refuse. It costs nothing and it is the check the whole prefix rests on.
|
|
376
|
+
const consumer = await js.consumers.get(stream, durable);
|
|
377
|
+
// The CACHED info from the create, so the count is the one this consumer was born with rather
|
|
378
|
+
// than a second round trip that a concurrent append could have moved under us.
|
|
379
|
+
const pending = (await consumer.info(true)).num_pending;
|
|
380
|
+
const records = [];
|
|
381
|
+
if (pending === 0)
|
|
382
|
+
return { records, lastSeq: 0 };
|
|
383
|
+
const iter = await consumer.fetch({ max_messages: pending, expires: 15_000 });
|
|
384
|
+
for await (const m of iter) {
|
|
385
|
+
records.push({ seq: m.seq, record: parseRunJournalRecord(m.json(), subject) });
|
|
386
|
+
m.ack();
|
|
387
|
+
if (records.length >= pending)
|
|
388
|
+
break;
|
|
389
|
+
}
|
|
390
|
+
if (records.length !== pending)
|
|
391
|
+
throw new RunJournalReplayIncomplete(runId, pending, records.length);
|
|
392
|
+
// A count is only self-consistency: it says the consumer delivered what it promised, not that what
|
|
393
|
+
// it promised is the whole run. The CHAIN is the integrity check — every record carries its
|
|
394
|
+
// ordinal, so `records[i].n === i` fails on a short front (a purge that retired the run, a
|
|
395
|
+
// consumer that ate the head) AND on a record removed from the middle, which nothing else here can
|
|
396
|
+
// see: a replay of [1,2,4] passes the count and the front anchor both, and the run re-performs
|
|
397
|
+
// the effect it lost.
|
|
398
|
+
for (let i = 0; i < records.length; i += 1) {
|
|
399
|
+
if (records[i].record.n !== i) {
|
|
400
|
+
throw new RunJournalPrefixTruncated(runId, records[i].seq, i, records[i].record.n);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
// The chain proves the records are CONSECUTIVE; it does not prove they start where a run starts.
|
|
404
|
+
// A step numbered 0, or an activation that replayed something, is a journal beginning in the
|
|
405
|
+
// middle of a story with its page numbers rewritten — so the genesis anchor stays alongside it.
|
|
406
|
+
const first = records[0].record;
|
|
407
|
+
if (first.kind !== "activation" || first.replayedTo !== 0) {
|
|
408
|
+
throw new RunJournalPrefixTruncated(runId, records[0].seq, 0, first.n);
|
|
409
|
+
}
|
|
410
|
+
return { records, lastSeq: records[records.length - 1].seq };
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* The bytes of a record, produced BEFORE any publish is attempted.
|
|
414
|
+
*
|
|
415
|
+
* Serialization is the caller's own failure and must not be inside the window where an outcome is
|
|
416
|
+
* classified. `isCasLoss` reads a `code` off whatever was thrown, so an entry whose `toJSON` threw
|
|
417
|
+
* an object carrying `code: 10071` would read as "the stream refused this append": a local bug
|
|
418
|
+
* reported as a supersession, which is the one answer a driver acts on by standing down for good.
|
|
419
|
+
* And an appender that treats "no PubAck" as terminal would retire a run over it, when in truth
|
|
420
|
+
* nothing was sent, nothing landed, and nothing moved.
|
|
421
|
+
*/
|
|
422
|
+
function encodeRecord(record) {
|
|
423
|
+
return new TextEncoder().encode(JSON.stringify(record));
|
|
424
|
+
}
|
|
425
|
+
async function publishFenced(js, subject, body, expected) {
|
|
426
|
+
const h = natsHeaders();
|
|
427
|
+
h.set("Nats-Expected-Last-Subject-Sequence", String(expected));
|
|
428
|
+
try {
|
|
429
|
+
const pa = await js.publish(subject, body, { headers: h });
|
|
430
|
+
return { ok: true, seq: pa.seq };
|
|
431
|
+
}
|
|
432
|
+
catch (e) {
|
|
433
|
+
if (isCasLoss(e))
|
|
434
|
+
return { ok: false, cause: e };
|
|
435
|
+
throw e;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* The one authorized appender for a run, for as long as nobody else takes it.
|
|
440
|
+
*
|
|
441
|
+
* Obtained only from {@link activateRun}: an appender that has not activated does not know the
|
|
442
|
+
* subject's sequence, and one that guessed would be the read-then-publish window again. The head is
|
|
443
|
+
* private and advances only from a PubAck — a caller that could pass an expectation in could pass a
|
|
444
|
+
* stale one, and every branch of a concurrency scope would be passing its own.
|
|
445
|
+
*/
|
|
446
|
+
export class RunJournalAppender {
|
|
447
|
+
js;
|
|
448
|
+
run;
|
|
449
|
+
subject;
|
|
450
|
+
replayed;
|
|
451
|
+
seq;
|
|
452
|
+
/** Set once the stream refuses an append. Terminal, and checked before the wire is touched. */
|
|
453
|
+
dead;
|
|
454
|
+
/** The serial pump. Every append waits for the one before it, so one expectation is in flight. */
|
|
455
|
+
chain = Promise.resolve();
|
|
456
|
+
/** The next journal ordinal. Allocated under the serial pump, so it cannot be raced, and only
|
|
457
|
+
* advanced by a PubAck — a stalled append leaves the ordinal where it was, and the appender is
|
|
458
|
+
* finished anyway. */
|
|
459
|
+
nextN;
|
|
460
|
+
constructor(js, run, subject,
|
|
461
|
+
/** The prefix this driver replayed, which is the journal the interpreter resumes from. */
|
|
462
|
+
replayed, seq) {
|
|
463
|
+
this.js = js;
|
|
464
|
+
this.run = run;
|
|
465
|
+
this.subject = subject;
|
|
466
|
+
this.replayed = replayed;
|
|
467
|
+
this.seq = seq;
|
|
468
|
+
// The prefix ends at ordinal `replayed.length - 1` and this driver's own activation took the
|
|
469
|
+
// next one, so its first step is two past the end of what it read.
|
|
470
|
+
this.nextN = replayed.length + 1;
|
|
471
|
+
}
|
|
472
|
+
/** @internal — {@link activateRun} is the only way in. */
|
|
473
|
+
static of(js, run, subject, replayed, seq) {
|
|
474
|
+
return new RunJournalAppender(js, run, subject, replayed, seq);
|
|
475
|
+
}
|
|
476
|
+
/** The last sequence this appender has seen acknowledged on the run's subject. */
|
|
477
|
+
get lastSeq() {
|
|
478
|
+
return this.seq;
|
|
479
|
+
}
|
|
480
|
+
/** True once the stream has REFUSED an append: someone else holds the run. */
|
|
481
|
+
get isSuperseded() {
|
|
482
|
+
return this.dead instanceof RunSuperseded;
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* True once this appender has stopped writing, for either reason. Drivers test this; only the
|
|
486
|
+
* recovery path cares which of the two it was.
|
|
487
|
+
*/
|
|
488
|
+
get isFinished() {
|
|
489
|
+
return this.dead !== undefined;
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* The highest journal ordinal this appender has written or replayed.
|
|
493
|
+
*
|
|
494
|
+
* The run record's tail anchor is written from this: it is the one number that says how far the
|
|
495
|
+
* journal got, and it lives here because `nextN` advances only on a PubAck — so it is a fact about
|
|
496
|
+
* what the broker accepted, never about what this process attempted.
|
|
497
|
+
*/
|
|
498
|
+
get journalHigh() {
|
|
499
|
+
return this.nextN - 1;
|
|
500
|
+
}
|
|
501
|
+
/** The step entries of the replayed prefix, in order, with the activations dropped. */
|
|
502
|
+
steps() {
|
|
503
|
+
return this.replayed.filter((r) => r.record.kind === "step").map((r) => r.record.entry);
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Append one step entry. Serialized against every other append on this run.
|
|
507
|
+
*
|
|
508
|
+
* Callers do not pass a sequence and could not usefully hold one: two concurrent branches of a
|
|
509
|
+
* `parallel` would both hold the same head, and the second publish would be refused by a server
|
|
510
|
+
* doing exactly what it was asked. Here they queue instead, and the head moves only when the
|
|
511
|
+
* broker says it did.
|
|
512
|
+
*/
|
|
513
|
+
append(entry, at) {
|
|
514
|
+
const result = this.chain.then(() => this.publishOne(entry, at));
|
|
515
|
+
// The chain must not reject, or the NEXT append inherits an unhandled rejection instead of
|
|
516
|
+
// running. It is a sequencer, not a result: the poison flag is what makes the failure stick.
|
|
517
|
+
this.chain = result.then(() => undefined, () => undefined);
|
|
518
|
+
return result;
|
|
519
|
+
}
|
|
520
|
+
async publishOne(entry, at) {
|
|
521
|
+
// Poisoned: everything queued behind a refusal fails with the same terminal error, and none of
|
|
522
|
+
// it reaches the wire. A queued entry retried at the new head is the superseded driver writing
|
|
523
|
+
// again, which is the one thing the barrier forbids.
|
|
524
|
+
if (this.dead !== undefined)
|
|
525
|
+
throw this.dead;
|
|
526
|
+
const record = { v: 1, kind: "step", run: this.run, n: this.nextN, at, entry };
|
|
527
|
+
// Bytes first, and deliberately outside the try below. Nothing has been sent, so a record that
|
|
528
|
+
// cannot be serialized leaves the head and the ordinal exactly where they were: the next append
|
|
529
|
+
// takes the same `n` at the same expectation, and this appender is still the run's.
|
|
530
|
+
const body = encodeRecord(record);
|
|
531
|
+
// Every outcome without a PubAck is terminal ONCE THE BYTES HAVE GONE OUT, not just a refusal:
|
|
532
|
+
// a publish that throws in flight leaves the head unknown, and the next queued append would
|
|
533
|
+
// reuse the stale expectation — measured, an entry that failed on the wire was followed by the
|
|
534
|
+
// next entry landing at the same head, so the journal read back a hole with a live appender.
|
|
535
|
+
// The bytes above are the boundary. A record that could not be serialized never reached the
|
|
536
|
+
// wire: the head is where it was, `n` is where it was, and this run is still ours. Poisoning
|
|
537
|
+
// there would end a run over a local bug — and, because `isCasLoss` reads a `code` off whatever
|
|
538
|
+
// was thrown, a thrown object carrying one would have retired it as a supersession.
|
|
539
|
+
let r;
|
|
540
|
+
try {
|
|
541
|
+
r = await publishFenced(this.js, this.subject, body, this.seq);
|
|
542
|
+
}
|
|
543
|
+
catch (e) {
|
|
544
|
+
this.dead = new RunJournalStalled(this.run, this.subject, this.seq, e);
|
|
545
|
+
throw this.dead;
|
|
546
|
+
}
|
|
547
|
+
if (!r.ok) {
|
|
548
|
+
this.dead = new RunSuperseded(this.run, this.subject, this.seq, r.cause);
|
|
549
|
+
throw this.dead;
|
|
550
|
+
}
|
|
551
|
+
this.seq = r.seq;
|
|
552
|
+
this.nextN += 1;
|
|
553
|
+
return r.seq;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Take a run over: replay its journal, then claim the subject with an activation record.
|
|
558
|
+
*
|
|
559
|
+
* Returns the appender that is now the run's single authorized writer, carrying the replayed prefix.
|
|
560
|
+
* Throws {@link ActivationRaced} when the journal kept moving — the caller has driven nothing and
|
|
561
|
+
* may try again while it holds the lease. It never throws {@link RunSuperseded}: not having won the
|
|
562
|
+
* subject yet is not the same as having lost it.
|
|
563
|
+
*/
|
|
564
|
+
/**
|
|
565
|
+
* May this takeover activate over the activation the journal already holds?
|
|
566
|
+
*
|
|
567
|
+
* The fencing token orders LEASES, and by itself that is not the whole authority: two takeovers can
|
|
568
|
+
* legitimately carry the SAME token — a renewed lease keeps its token, and a holder whose appender
|
|
569
|
+
* stalled recovers under the lease it still has — so "equal is allowed" was written to let that
|
|
570
|
+
* recovery through. Measured, it let much more through: holder A at epoch 1, then B at epoch 2, then
|
|
571
|
+
* A at epoch 1 again, all on token 7, with A appending after its return and B superseded. That is
|
|
572
|
+
* the retry-at-a-refreshed-head the barrier exists to forbid, performed through `activateRun`
|
|
573
|
+
* instead of through the appender.
|
|
574
|
+
*
|
|
575
|
+
* So an equal token stays bound to the identity that holds it: the SAME holder at the SAME epoch.
|
|
576
|
+
* Exact-tuple recovery — same token, same holder, same epoch — remains possible, because that is one
|
|
577
|
+
* process picking its own run back up, which is the case this rule was relaxed for.
|
|
578
|
+
*
|
|
579
|
+
* A newer epoch on an equal token is refused too, and that is not extra strictness but agreement
|
|
580
|
+
* with the authority that issues the token. A lease is bound to one worker at assignment: within an
|
|
581
|
+
* attempt the pool returns the SAME lease verbatim (`first-wins: the still-current attempt's lease,
|
|
582
|
+
* verbatim`), a redelivery advances the token, and the commit gate matches the bound epoch EXACTLY:
|
|
583
|
+
* `current !== bound.epoch` settles nothing. So (equal token, newer
|
|
584
|
+
* epoch) is a tuple no lease can produce, and a driver presenting it would be driving a run it can
|
|
585
|
+
* never settle. A restarted process re-leases and gets its attempt's lease back with the epoch it
|
|
586
|
+
* was bound at, which is the exact tuple above.
|
|
587
|
+
*/
|
|
588
|
+
function assertMayActivate(t, held) {
|
|
589
|
+
if (held === undefined)
|
|
590
|
+
return;
|
|
591
|
+
if (t.fencingToken < held.fencingToken) {
|
|
592
|
+
throw new StaleLeaseToken(t.runId, t.fencingToken, held.fencingToken, held.holder);
|
|
593
|
+
}
|
|
594
|
+
if (t.fencingToken > held.fencingToken)
|
|
595
|
+
return;
|
|
596
|
+
if (t.holder !== held.holder) {
|
|
597
|
+
throw new ActivationNotAuthorized(t.runId, `token ${t.fencingToken} is held by ${held.holder}; ${t.holder} cannot activate on the same token`);
|
|
598
|
+
}
|
|
599
|
+
if (t.epoch !== held.epoch) {
|
|
600
|
+
throw new ActivationNotAuthorized(t.runId, `token ${t.fencingToken} is bound to ${held.holder} at epoch ${held.epoch}; epoch ${t.epoch} is a different process of it, and a lease binds one worker per attempt`);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
/** The last activation in a replayed prefix, i.e. whoever currently holds the run. */
|
|
604
|
+
function lastActivation(records) {
|
|
605
|
+
for (let i = records.length - 1; i >= 0; i -= 1) {
|
|
606
|
+
const r = records[i].record;
|
|
607
|
+
if (r.kind === "activation")
|
|
608
|
+
return r;
|
|
609
|
+
}
|
|
610
|
+
return undefined;
|
|
611
|
+
}
|
|
612
|
+
export async function activateRun(js, jsm, takeover, opts = {}) {
|
|
613
|
+
// DETACHED before anything is validated. A takeover is an ordinary JS object owned by the caller,
|
|
614
|
+
// and this function awaits between authorizing it and publishing it — so without a snapshot, one
|
|
615
|
+
// tuple is checked and a different one lands: check holder A at token 7, publish holder B at
|
|
616
|
+
// token 7, or token 1. The runId case is the worst of them, because `subject` is fixed here: a
|
|
617
|
+
// mutated runId would label a FOREIGN run on this run's subject, and the barrier would then
|
|
618
|
+
// believe that record. The pool's seams detach caller input for the same reason (`snapshotRef`).
|
|
619
|
+
const t = {
|
|
620
|
+
space: takeover.space,
|
|
621
|
+
runId: takeover.runId,
|
|
622
|
+
holder: takeover.holder,
|
|
623
|
+
fencingToken: takeover.fencingToken,
|
|
624
|
+
epoch: takeover.epoch,
|
|
625
|
+
at: takeover.at,
|
|
626
|
+
expect: takeover.expect,
|
|
627
|
+
takeoverId: takeover.takeoverId,
|
|
628
|
+
};
|
|
629
|
+
const subject = wfjSubject(t.space, t.runId);
|
|
630
|
+
const attempts = Math.max(1, opts.attempts ?? 3);
|
|
631
|
+
let lastExpected = 0;
|
|
632
|
+
let cause;
|
|
633
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
634
|
+
if (attempt > 1 && opts.beforeRetry !== undefined)
|
|
635
|
+
await opts.beforeRetry(attempt);
|
|
636
|
+
let replay;
|
|
637
|
+
try {
|
|
638
|
+
replay = await replayRunJournal(js, jsm, t.space, t.runId, t.takeoverId);
|
|
639
|
+
}
|
|
640
|
+
catch (e) {
|
|
641
|
+
// Another driver is replaying the same run: that is a lost round, not a lost run.
|
|
642
|
+
if (!(e instanceof RunJournalReplayRaced))
|
|
643
|
+
throw e;
|
|
644
|
+
cause = e;
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
const { records, lastSeq } = replay;
|
|
648
|
+
lastExpected = lastSeq;
|
|
649
|
+
// The barrier by itself fences KNOWLEDGE OF THE HEAD, not authority: anyone who replays learns
|
|
650
|
+
// the head, so a driver whose lease expired long ago can activate over the current holder just
|
|
651
|
+
// by reading, and then append behind it. The replayed prefix is where the answer already is: it
|
|
652
|
+
// contains every activation and is current as of the CAS that follows, so a takeover under a
|
|
653
|
+
// token older than the last one recorded is refused here rather than after the damage.
|
|
654
|
+
// Typed is not checked: a value that is neither would fall through BOTH guards and reactivate a
|
|
655
|
+
// purged run as if it were new, which is the thing the field exists to make impossible.
|
|
656
|
+
if (t.expect !== "new" && t.expect !== "existing") {
|
|
657
|
+
throw new Error(`run ${t.runId}: a takeover must state expect "new" or "existing"; got ${JSON.stringify(t.expect)}`);
|
|
658
|
+
}
|
|
659
|
+
if (t.expect === "existing" && records.length === 0) {
|
|
660
|
+
throw new RunNotResumable(t.runId, subject);
|
|
661
|
+
}
|
|
662
|
+
if (t.expect === "new" && records.length > 0) {
|
|
663
|
+
throw new RunAlreadyStarted(t.runId, records.length);
|
|
664
|
+
}
|
|
665
|
+
assertMayActivate(t, lastActivation(records));
|
|
666
|
+
// Built HERE, from the prefix that was just validated, and not after the hook below: `records`
|
|
667
|
+
// is handed to the caller and an array can grow. It did — the hook pushed one record and the
|
|
668
|
+
// activation landed at ordinal 2 over a prefix of 1, which the chain check would then read as a
|
|
669
|
+
// hole for the rest of the run's life.
|
|
670
|
+
const prefix = records.slice();
|
|
671
|
+
const activation = {
|
|
672
|
+
v: 1,
|
|
673
|
+
kind: "activation",
|
|
674
|
+
run: t.runId,
|
|
675
|
+
n: prefix.length,
|
|
676
|
+
holder: t.holder,
|
|
677
|
+
fencingToken: t.fencingToken,
|
|
678
|
+
epoch: t.epoch,
|
|
679
|
+
replayedTo: lastSeq,
|
|
680
|
+
at: t.at,
|
|
681
|
+
};
|
|
682
|
+
// A COPY. `prefix` is the journal the interpreter will resume from, and slicing the array only
|
|
683
|
+
// detaches the array: the records inside it are shared, so a hook that reached into one would
|
|
684
|
+
// change what the run replays while WFJ still stores the original — a driver resuming from a
|
|
685
|
+
// history that is not the one on the wire. The hook exists to observe the moment the prefix is
|
|
686
|
+
// known, which needs no handle on it. Production passes no hook, so this clone costs nothing
|
|
687
|
+
// there; where it is passed, it is the caller's copy and mutating it changes nothing.
|
|
688
|
+
if (opts.onReplayed !== undefined)
|
|
689
|
+
await opts.onReplayed(structuredClone(replay));
|
|
690
|
+
const r = await publishFenced(js, subject, encodeRecord(activation), lastSeq);
|
|
691
|
+
// The activation's PubAck is the line: before it this driver has performed nothing and holds
|
|
692
|
+
// nothing, after it the run's subject is its own until someone else activates.
|
|
693
|
+
if (r.ok)
|
|
694
|
+
return RunJournalAppender.of(js, t.runId, subject, prefix, r.seq);
|
|
695
|
+
cause = r.cause;
|
|
696
|
+
}
|
|
697
|
+
throw new ActivationRaced(t.runId, subject, lastExpected, attempts, cause);
|
|
698
|
+
}
|
|
699
|
+
/** Convenience for a caller holding only a connection. */
|
|
700
|
+
export async function runJournalContext(nc) {
|
|
701
|
+
return { js: jetstream(nc), jsm: await jetstreamManager(nc) };
|
|
702
|
+
}
|
|
703
|
+
//# sourceMappingURL=run-journal.js.map
|