@runuai/host 0.9.150 → 0.9.151
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/db/migrations/0027_cloud_credential_foundation.sql +51 -0
- package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
- package/db/migrations/meta/_journal.json +14 -0
- package/db/schema.ts +188 -0
- package/lib/agent-cli.ts +11 -13
- package/lib/agent.ts +177 -7
- package/lib/agents/durable-proc.ts +56 -44
- package/lib/agents/registry.ts +19 -3
- package/lib/agents/session-state.ts +23 -10
- package/lib/agents/transport.ts +101 -69
- package/lib/apple-one-off.ts +277 -0
- package/lib/apple-runtime-recycle.ts +4 -3
- package/lib/attachments.ts +19 -17
- package/lib/capture-flow.ts +319 -0
- package/lib/cloud-credential-cleanup-adapter.ts +123 -0
- package/lib/cloud-credential-host.ts +512 -0
- package/lib/cloud-credential-leases.ts +2544 -0
- package/lib/cloud-credential-runtime.ts +1035 -0
- package/lib/codex-auth.ts +21 -1
- package/lib/container-runtime.ts +76 -0
- package/lib/credential-cleanup-inventory.ts +405 -0
- package/lib/credential-consumers/engine-anchor.ts +1876 -0
- package/lib/credential-consumers/engines.ts +773 -0
- package/lib/credential-consumers/github.ts +594 -0
- package/lib/credential-consumers/mcp.ts +384 -0
- package/lib/credential-consumers/task-files.ts +127 -0
- package/lib/credential-store-pickup.ts +15 -2
- package/lib/credential-store.ts +361 -33
- package/lib/credentials-activation-authority.ts +546 -0
- package/lib/credentials-purge.ts +1006 -0
- package/lib/db.ts +11 -0
- package/lib/docker-exec.ts +3 -1
- package/lib/engine-accounts.ts +42 -32
- package/lib/engine-connect.ts +47 -2
- package/lib/engine-credential-env.ts +50 -0
- package/lib/engine-login.ts +83 -1
- package/lib/env-file.ts +62 -8
- package/lib/env.ts +5 -0
- package/lib/fs-aliases.ts +49 -0
- package/lib/git-clone-credentials.ts +334 -0
- package/lib/git-diff.ts +219 -177
- package/lib/git-identity.ts +59 -14
- package/lib/github-origin.ts +28 -0
- package/lib/github-tokens.ts +56 -21
- package/lib/host-env.ts +17 -4
- package/lib/launch-mode.ts +25 -0
- package/lib/legacy-runtime-retirement.ts +388 -0
- package/lib/machine-exec.ts +14 -3
- package/lib/machine-provider-aws.ts +50 -8
- package/lib/machine-provider-local.ts +4 -0
- package/lib/machine-provider.ts +4 -0
- package/lib/managed-operation-drain.ts +8 -0
- package/lib/managed-runtime.ts +371 -7
- package/lib/mcp-capture.ts +860 -0
- package/lib/mcp-connections.ts +8 -1
- package/lib/mcp-gateway.ts +168 -10
- package/lib/obs.ts +2 -6
- package/lib/orchestrator.ts +1830 -224
- package/lib/person-capture.ts +327 -0
- package/lib/project-env-legacy-tags.ts +130 -0
- package/lib/project-env-task-replica.ts +1752 -0
- package/lib/runner-boot.ts +10 -0
- package/lib/runtime-activation-tail.ts +50 -3
- package/lib/runtime-archive.ts +68 -1
- package/lib/runtime-reconnect.ts +86 -0
- package/lib/runtime-state.ts +429 -13
- package/lib/safe-http.ts +364 -0
- package/lib/secrets-proxy/task-trust.ts +34 -3
- package/lib/sidecar-resume.ts +18 -5
- package/lib/skills.ts +47 -8
- package/lib/standard-image.ts +157 -103
- package/lib/stopped-task-status-queue.ts +68 -10
- package/lib/task-credential-cleanup.ts +525 -0
- package/lib/task-diff-sandbox.ts +464 -0
- package/lib/task-diff.ts +66 -163
- package/lib/task-env-source.ts +68 -14
- package/lib/task-environment/anchor.ts +31 -0
- package/lib/task-environment/apple-container.ts +69 -28
- package/lib/task-environment/docker.ts +101 -20
- package/lib/task-environment/machine-environment.ts +61 -0
- package/lib/task-environment/machine-task-up.ts +459 -96
- package/lib/task-environment/machine.ts +101 -11
- package/lib/task-environment/self.ts +108 -50
- package/lib/task-environment/types.ts +89 -5
- package/lib/task-environment/workspace-files.ts +623 -25
- package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
- package/lib/task-gateway-recovery.ts +4 -1
- package/lib/task-gateway-state.ts +15 -0
- package/lib/task-machine-swap-safety.ts +729 -0
- package/lib/token-broker-adopt.ts +344 -0
- package/lib/token-broker-lambda.ts +56 -2
- package/lib/token-broker-local.ts +28 -4
- package/lib/token-broker-revision.ts +310 -0
- package/lib/token-broker-ssm.ts +368 -28
- package/lib/token-lanes/openai-oauth.ts +7 -3
- package/lib/token-lanes/xai-oauth.ts +7 -3
- package/lib/transcript.ts +9 -5
- package/package.json +6 -2
- package/scripts/agent/task-up.sh +242 -100
- package/src/cloud-credentials.ts +2399 -0
- package/src/event-outbox.ts +8 -6
- package/src/index.ts +1386 -875
- package/src/main.ts +1061 -139
- package/src/outbound-frame.ts +31 -0
- package/src/project-env-policy.ts +125 -10
- package/src/protocol.ts +699 -23
- package/src/ui/server.ts +48 -10
|
@@ -0,0 +1,1752 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-154 task-keyed project-environment replica.
|
|
3
|
+
*
|
|
4
|
+
* Unlike the legacy sidecar replica, this authority is deliberately
|
|
5
|
+
* in-memory. Each task owns its reader, staging view, live values, delivery
|
|
6
|
+
* cursor and gate. Nothing in this module imports the host database or writes
|
|
7
|
+
* a secret to disk.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
MAX_PROJECT_ENV_ACK_PAGE_BYTES,
|
|
12
|
+
isHostOpId,
|
|
13
|
+
isProjectEnvPolicyEpochs,
|
|
14
|
+
isProjectEnvReplicaV1ApplyFrame,
|
|
15
|
+
isProjectEnvReplicaV1BeginFrame,
|
|
16
|
+
isProjectEnvReplicaV1CommitFrame,
|
|
17
|
+
isProjectEnvReplicaV1PageFrame,
|
|
18
|
+
sameProjectEnvHead,
|
|
19
|
+
type ProjectEnvDeliveryCursor,
|
|
20
|
+
type ProjectEnvReplicaBindingState,
|
|
21
|
+
type ProjectEnvReplicaMaterialization,
|
|
22
|
+
type ProjectEnvReplicaResult,
|
|
23
|
+
type ProjectEnvReplicaV1AckFrame,
|
|
24
|
+
type ProjectEnvReplicaV1ApplyFrame,
|
|
25
|
+
type ProjectEnvReplicaV1BeginFrame,
|
|
26
|
+
type ProjectEnvReplicaV1CommitFrame,
|
|
27
|
+
type ProjectEnvReplicaV1PageFrame,
|
|
28
|
+
} from "../src/protocol";
|
|
29
|
+
import { PROJECT_ENV_POLICY_EPOCHS } from "../src/project-env-policy";
|
|
30
|
+
import { RUNNER_BOOT_ID } from "./runner-boot";
|
|
31
|
+
|
|
32
|
+
export type ProjectEnvTaskReplicaFrame =
|
|
33
|
+
| ProjectEnvReplicaV1BeginFrame
|
|
34
|
+
| ProjectEnvReplicaV1PageFrame
|
|
35
|
+
| ProjectEnvReplicaV1ApplyFrame
|
|
36
|
+
| ProjectEnvReplicaV1CommitFrame;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The lease registry supplies one of these per task grant. Implementations
|
|
40
|
+
* must read through that task's project session and recheck their own lease
|
|
41
|
+
* fence after I/O. This replica independently rechecks its fence too.
|
|
42
|
+
*/
|
|
43
|
+
export interface ProjectEnvTaskValueReader {
|
|
44
|
+
/** Exact raw v2 parameter envelope, or null when the named value is absent. */
|
|
45
|
+
readStaged(
|
|
46
|
+
projectId: string,
|
|
47
|
+
key: string,
|
|
48
|
+
stagingId: string,
|
|
49
|
+
): Promise<string | null>;
|
|
50
|
+
/** Exact raw v1 envelope and observed SSM version, or null when absent. */
|
|
51
|
+
readLegacy(
|
|
52
|
+
projectId: string,
|
|
53
|
+
key: string,
|
|
54
|
+
version: number,
|
|
55
|
+
): Promise<{ value: string; version: number } | null>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ProjectEnvTaskAssignment {
|
|
59
|
+
taskId: string;
|
|
60
|
+
runEpoch: number;
|
|
61
|
+
orgId: string;
|
|
62
|
+
reader: ProjectEnvTaskValueReader;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ProjectEnvTaskReplicaStatus {
|
|
66
|
+
taskId: string;
|
|
67
|
+
runEpoch: number;
|
|
68
|
+
runnerBootId: string;
|
|
69
|
+
gateOpen: boolean;
|
|
70
|
+
/** This run stays fail-closed until a new run or runner process starts. */
|
|
71
|
+
restartRequired: boolean;
|
|
72
|
+
streamId: string | null;
|
|
73
|
+
attemptId: string | null;
|
|
74
|
+
appliedThrough: ProjectEnvDeliveryCursor | null;
|
|
75
|
+
projectIds: string[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface ProjectEnvTaskReplicaSnapshotEntry {
|
|
79
|
+
state: ProjectEnvReplicaBindingState;
|
|
80
|
+
materialization: ProjectEnvReplicaMaterialization;
|
|
81
|
+
value: string | null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface ProjectEnvTaskReplicaSnapshotProject {
|
|
85
|
+
projectId: string;
|
|
86
|
+
recordVersion: number;
|
|
87
|
+
entries: ProjectEnvTaskReplicaSnapshotEntry[];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface ProjectEnvTaskReplicaSnapshot {
|
|
91
|
+
taskId: string;
|
|
92
|
+
runEpoch: number;
|
|
93
|
+
runnerBootId: string;
|
|
94
|
+
appliedThrough: ProjectEnvDeliveryCursor;
|
|
95
|
+
projects: ProjectEnvTaskReplicaSnapshotProject[];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface ProjectEnvTaskReplicaManagerOptions {
|
|
99
|
+
runnerBootId?: string;
|
|
100
|
+
policyEpochs?: readonly number[];
|
|
101
|
+
/** Optional lower limits for tests or unusually constrained runners. */
|
|
102
|
+
replayLedgerLimits?: {
|
|
103
|
+
maxTaskEntries?: number;
|
|
104
|
+
maxTaskBytes?: number;
|
|
105
|
+
maxTotalEntries?: number;
|
|
106
|
+
maxTotalBytes?: number;
|
|
107
|
+
};
|
|
108
|
+
onChange?: (taskId: string) => void;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface ReplicaEntry {
|
|
112
|
+
state: ProjectEnvReplicaBindingState;
|
|
113
|
+
materialization: ProjectEnvReplicaMaterialization;
|
|
114
|
+
value: string | null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface ReplicaProject {
|
|
118
|
+
recordVersion: number;
|
|
119
|
+
records: Map<string, ReplicaEntry>;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
interface PageReceipt {
|
|
123
|
+
fingerprint: string;
|
|
124
|
+
after: string | null;
|
|
125
|
+
recordVersion: number;
|
|
126
|
+
results: ProjectEnvReplicaResult[];
|
|
127
|
+
complete: boolean;
|
|
128
|
+
appliedThrough: ProjectEnvDeliveryCursor;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
interface StagedProject extends ReplicaProject {
|
|
132
|
+
started: boolean;
|
|
133
|
+
complete: boolean;
|
|
134
|
+
lastKey: string | null;
|
|
135
|
+
pages: Map<string, PageReceipt>;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
interface SnapshotAttempt {
|
|
139
|
+
attemptId: string;
|
|
140
|
+
beginFingerprint: string;
|
|
141
|
+
projectIds: string[];
|
|
142
|
+
projects: Map<string, StagedProject>;
|
|
143
|
+
/** The grant reader it began under (its `readerGeneration`): every value
|
|
144
|
+
* it holds was read through it, or it is refused. */
|
|
145
|
+
readerGeneration: number;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
type ApplyReceipt =
|
|
149
|
+
| {
|
|
150
|
+
fingerprint: string;
|
|
151
|
+
ok: true;
|
|
152
|
+
attemptId: string | null;
|
|
153
|
+
projectId: string;
|
|
154
|
+
recordVersion: number;
|
|
155
|
+
result: ProjectEnvReplicaResult;
|
|
156
|
+
appliedThrough: ProjectEnvDeliveryCursor;
|
|
157
|
+
}
|
|
158
|
+
| {
|
|
159
|
+
fingerprint: string;
|
|
160
|
+
ok: false;
|
|
161
|
+
attemptId: string | null;
|
|
162
|
+
code: string;
|
|
163
|
+
appliedThrough: ProjectEnvDeliveryCursor;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
interface CompletedAttempt {
|
|
167
|
+
fingerprint: string;
|
|
168
|
+
highWater: ProjectEnvDeliveryCursor;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
interface ReadyWaiter {
|
|
172
|
+
resolve: () => void;
|
|
173
|
+
reject: (error: Error) => void;
|
|
174
|
+
timer: NodeJS.Timeout;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
interface TaskReplica {
|
|
178
|
+
taskId: string;
|
|
179
|
+
runEpoch: number;
|
|
180
|
+
orgId: string;
|
|
181
|
+
reader: ProjectEnvTaskValueReader;
|
|
182
|
+
/** Advanced each time a grant's reader replaces the reader: a number, so
|
|
183
|
+
* no earlier grant's reader (and its sessions) stays reachable here. */
|
|
184
|
+
readerGeneration: number;
|
|
185
|
+
/** The reader generation whose attempt produced the committed snapshot. */
|
|
186
|
+
snapshotReaderGeneration: number | null;
|
|
187
|
+
/** Advanced by synchronous lifecycle changes, fencing reads in flight. */
|
|
188
|
+
generation: number;
|
|
189
|
+
gateOpen: boolean;
|
|
190
|
+
streamId: string | null;
|
|
191
|
+
appliedSequence: number;
|
|
192
|
+
attempt: SnapshotAttempt | null;
|
|
193
|
+
projects: Map<string, ReplicaProject>;
|
|
194
|
+
retiredStreams: Set<string>;
|
|
195
|
+
retiredAttempts: Set<string>;
|
|
196
|
+
applyReceipts: Map<number, ApplyReceipt>;
|
|
197
|
+
applyReceiptBytes: Map<number, number>;
|
|
198
|
+
completedAttempts: Map<string, CompletedAttempt>;
|
|
199
|
+
replayLedgerEntries: number;
|
|
200
|
+
replayLedgerBytes: number;
|
|
201
|
+
restartRequired: boolean;
|
|
202
|
+
/** Materialized page/apply entries not yet installed into a task view. */
|
|
203
|
+
inFlightEntries: Set<ReplicaEntry>;
|
|
204
|
+
waiters: Set<ReadyWaiter>;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
type FramePhase = ProjectEnvReplicaV1AckFrame["phase"];
|
|
208
|
+
|
|
209
|
+
const NULL_PAGE_CURSOR = "\0";
|
|
210
|
+
/**
|
|
211
|
+
* Replay receipts cannot be aged out while an assignment keeps serving. The
|
|
212
|
+
* wire contract has opaque stream/attempt ids and no acknowledgement watermark
|
|
213
|
+
* from the cloud, so an arbitrarily late retry can still be legitimate. These
|
|
214
|
+
* limits therefore fail the replica closed instead of evicting evidence. They
|
|
215
|
+
* count only the four cumulative replay ledgers below; live values and the one
|
|
216
|
+
* active snapshot are replica state, not replay metadata, and follow their own
|
|
217
|
+
* task/attempt lifetime.
|
|
218
|
+
*/
|
|
219
|
+
/** Maximum replay-evidence records retained by one live task assignment. */
|
|
220
|
+
export const PROJECT_ENV_REPLAY_LEDGER_MAX_TASK_ENTRIES = 16_384;
|
|
221
|
+
/** Maximum encoded replay-evidence bytes retained by one live assignment. */
|
|
222
|
+
export const PROJECT_ENV_REPLAY_LEDGER_MAX_TASK_BYTES = 8 * 1_024 * 1_024;
|
|
223
|
+
/** Aggregate replay-evidence record bound for this runner process. */
|
|
224
|
+
export const PROJECT_ENV_REPLAY_LEDGER_MAX_TOTAL_ENTRIES = 65_536;
|
|
225
|
+
/** Aggregate encoded replay-evidence byte bound for this runner process. */
|
|
226
|
+
export const PROJECT_ENV_REPLAY_LEDGER_MAX_TOTAL_BYTES = 64 * 1_024 * 1_024;
|
|
227
|
+
const REPLAY_LEDGER_ENTRY_OVERHEAD_BYTES = 64;
|
|
228
|
+
const REPLAY_LEDGER_EXHAUSTED = "replica_restart_required";
|
|
229
|
+
/** A grant for the run was accepted and its reader is not active yet: retry
|
|
230
|
+
* the frame shortly. A snapshot read through the reader it replaces would
|
|
231
|
+
* answer for nothing. */
|
|
232
|
+
export const PROJECT_ENV_ACTIVATION_PENDING = "activation_pending";
|
|
233
|
+
/** The run's grant reader changed under this attempt, this read or the live
|
|
234
|
+
* snapshot this apply would change: begin a new attempt. */
|
|
235
|
+
export const PROJECT_ENV_READER_CHANGED = "reader_changed";
|
|
236
|
+
const CLOSED_READER: ProjectEnvTaskValueReader = {
|
|
237
|
+
readStaged: async () => {
|
|
238
|
+
throw new Error("task replica reader is closed");
|
|
239
|
+
},
|
|
240
|
+
readLegacy: async () => {
|
|
241
|
+
throw new Error("task replica reader is closed");
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
function canonicalJson(value: unknown): string {
|
|
246
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
247
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
248
|
+
const object = value as Record<string, unknown>;
|
|
249
|
+
return `{${Object.keys(object)
|
|
250
|
+
.sort()
|
|
251
|
+
.map((key) => `${JSON.stringify(key)}:${canonicalJson(object[key])}`)
|
|
252
|
+
.join(",")}}`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function clone<T>(value: T): T {
|
|
256
|
+
return structuredClone(value);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function sameStrings(left: readonly string[], right: readonly string[]): boolean {
|
|
260
|
+
return (
|
|
261
|
+
left.length === right.length &&
|
|
262
|
+
left.every((value, index) => value === right[index])
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function validRunEpoch(value: number): boolean {
|
|
267
|
+
return Number.isSafeInteger(value) && value >= 1;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function validCredentialId(value: string): boolean {
|
|
271
|
+
return (
|
|
272
|
+
value.length > 0 &&
|
|
273
|
+
value.length <= 128 &&
|
|
274
|
+
!/[\u0000-\u001f\u007f]/.test(value)
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function validReplayLedgerLimit(value: number): boolean {
|
|
279
|
+
return Number.isSafeInteger(value) && value >= 1;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function replayLedgerEntryBytes(value: unknown): number {
|
|
283
|
+
return (
|
|
284
|
+
REPLAY_LEDGER_ENTRY_OVERHEAD_BYTES +
|
|
285
|
+
Buffer.byteLength(canonicalJson(value), "utf8")
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function validAssignment(value: ProjectEnvTaskAssignment): boolean {
|
|
290
|
+
return (
|
|
291
|
+
isHostOpId(value.taskId) &&
|
|
292
|
+
validRunEpoch(value.runEpoch) &&
|
|
293
|
+
validCredentialId(value.orgId) &&
|
|
294
|
+
value.reader !== null &&
|
|
295
|
+
typeof value.reader === "object" &&
|
|
296
|
+
typeof value.reader.readStaged === "function" &&
|
|
297
|
+
typeof value.reader.readLegacy === "function"
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function exactObject(
|
|
302
|
+
value: unknown,
|
|
303
|
+
keys: readonly string[],
|
|
304
|
+
): value is Record<string, unknown> {
|
|
305
|
+
return (
|
|
306
|
+
value !== null &&
|
|
307
|
+
typeof value === "object" &&
|
|
308
|
+
!Array.isArray(value) &&
|
|
309
|
+
Object.keys(value).length === keys.length &&
|
|
310
|
+
keys.every((key) => Object.hasOwn(value, key))
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function desiredMaterialization(
|
|
315
|
+
state: ProjectEnvReplicaBindingState,
|
|
316
|
+
policyEpochs: readonly number[],
|
|
317
|
+
): ProjectEnvReplicaMaterialization {
|
|
318
|
+
if (state.head.tombstone) return { kind: "withheld", reason: "tombstone" };
|
|
319
|
+
if (state.policyEpoch === null || !policyEpochs.includes(state.policyEpoch)) {
|
|
320
|
+
return { kind: "withheld", reason: "policy_epoch_unknown" };
|
|
321
|
+
}
|
|
322
|
+
if (state.binding?.kind === "omit") {
|
|
323
|
+
return { kind: "withheld", reason: "binding_omits" };
|
|
324
|
+
}
|
|
325
|
+
if (state.binding?.kind === "withhold") {
|
|
326
|
+
return { kind: "withheld", reason: state.binding.reason };
|
|
327
|
+
}
|
|
328
|
+
return { kind: "unavailable", reason: "materialization_pending" };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function resultOf(entry: ReplicaEntry): ProjectEnvReplicaResult {
|
|
332
|
+
return {
|
|
333
|
+
state: clone(entry.state),
|
|
334
|
+
materialization: clone(entry.materialization),
|
|
335
|
+
} as ProjectEnvReplicaResult;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function scrubEntry(entry: ReplicaEntry): void {
|
|
339
|
+
entry.value = null;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function scrubProjects(projects: Map<string, ReplicaProject>): void {
|
|
343
|
+
for (const project of projects.values()) {
|
|
344
|
+
for (const entry of project.records.values()) scrubEntry(entry);
|
|
345
|
+
project.records.clear();
|
|
346
|
+
}
|
|
347
|
+
projects.clear();
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function scrubAttempt(attempt: SnapshotAttempt | null): void {
|
|
351
|
+
if (attempt === null) return;
|
|
352
|
+
scrubProjects(attempt.projects);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function stateOrder(
|
|
356
|
+
incoming: ProjectEnvReplicaBindingState,
|
|
357
|
+
current: ProjectEnvReplicaBindingState,
|
|
358
|
+
): "same" | "newer" | "older" | "conflict" {
|
|
359
|
+
const revisionDelta = incoming.head.revision - current.head.revision;
|
|
360
|
+
const generationDelta = incoming.head.generation - current.head.generation;
|
|
361
|
+
if (revisionDelta < 0 && generationDelta <= 0) return "older";
|
|
362
|
+
if (generationDelta < 0 && revisionDelta <= 0) return "older";
|
|
363
|
+
if (revisionDelta < 0 || generationDelta < 0) return "conflict";
|
|
364
|
+
if (revisionDelta === 0 && generationDelta === 0) {
|
|
365
|
+
if (!sameProjectEnvHead(incoming.head, current.head)) return "conflict";
|
|
366
|
+
if (
|
|
367
|
+
canonicalJson(incoming.binding) !== canonicalJson(current.binding) ||
|
|
368
|
+
incoming.policyEpoch !== current.policyEpoch
|
|
369
|
+
) {
|
|
370
|
+
return "conflict";
|
|
371
|
+
}
|
|
372
|
+
if (incoming.listingGeneration < current.listingGeneration) return "older";
|
|
373
|
+
if (incoming.listingGeneration === current.listingGeneration) {
|
|
374
|
+
return canonicalJson(incoming) === canonicalJson(current)
|
|
375
|
+
? "same"
|
|
376
|
+
: "conflict";
|
|
377
|
+
}
|
|
378
|
+
return "newer";
|
|
379
|
+
}
|
|
380
|
+
if (incoming.listingGeneration < current.listingGeneration) return "conflict";
|
|
381
|
+
if (
|
|
382
|
+
revisionDelta === 0 &&
|
|
383
|
+
(incoming.head.tombstone !== current.head.tombstone ||
|
|
384
|
+
canonicalJson(incoming.binding) !== canonicalJson(current.binding) ||
|
|
385
|
+
incoming.policyEpoch !== current.policyEpoch)
|
|
386
|
+
) {
|
|
387
|
+
return "conflict";
|
|
388
|
+
}
|
|
389
|
+
if (
|
|
390
|
+
generationDelta === 0 &&
|
|
391
|
+
(incoming.head.tombstone !== current.head.tombstone ||
|
|
392
|
+
canonicalJson(incoming.head.valueRef) !==
|
|
393
|
+
canonicalJson(current.head.valueRef))
|
|
394
|
+
) {
|
|
395
|
+
return "conflict";
|
|
396
|
+
}
|
|
397
|
+
return "newer";
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function isReachableRecordAdvance(
|
|
401
|
+
incoming: ProjectEnvReplicaBindingState,
|
|
402
|
+
current: ProjectEnvReplicaBindingState | null,
|
|
403
|
+
): boolean {
|
|
404
|
+
if (current === null) {
|
|
405
|
+
return (
|
|
406
|
+
(incoming.head.revision === 1 &&
|
|
407
|
+
incoming.head.generation === 1 &&
|
|
408
|
+
!incoming.head.tombstone) ||
|
|
409
|
+
(incoming.head.revision === 1 &&
|
|
410
|
+
incoming.head.generation === 0 &&
|
|
411
|
+
incoming.head.tombstone)
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
const revision = incoming.head.revision - current.head.revision;
|
|
415
|
+
const generation = incoming.head.generation - current.head.generation;
|
|
416
|
+
if (incoming.listingGeneration < current.listingGeneration) return false;
|
|
417
|
+
if (revision === 0 && generation === 0) {
|
|
418
|
+
return (
|
|
419
|
+
sameProjectEnvHead(incoming.head, current.head) &&
|
|
420
|
+
canonicalJson(incoming.binding) === canonicalJson(current.binding) &&
|
|
421
|
+
incoming.policyEpoch === current.policyEpoch &&
|
|
422
|
+
incoming.listingGeneration > current.listingGeneration
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
if (revision === 0 && generation === 1) {
|
|
426
|
+
return (
|
|
427
|
+
!current.head.tombstone &&
|
|
428
|
+
!incoming.head.tombstone &&
|
|
429
|
+
canonicalJson(incoming.binding) === canonicalJson(current.binding) &&
|
|
430
|
+
incoming.policyEpoch === current.policyEpoch
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
if (revision === 1 && generation === 0) {
|
|
434
|
+
return (
|
|
435
|
+
!current.head.tombstone &&
|
|
436
|
+
!incoming.head.tombstone &&
|
|
437
|
+
canonicalJson(incoming.head.valueRef) ===
|
|
438
|
+
canonicalJson(current.head.valueRef)
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
if (revision === 1 && generation === 1) {
|
|
442
|
+
if (current.head.generation === 0) {
|
|
443
|
+
return current.head.tombstone && !incoming.head.tombstone;
|
|
444
|
+
}
|
|
445
|
+
return (
|
|
446
|
+
current.head.tombstone !== incoming.head.tombstone &&
|
|
447
|
+
canonicalJson(incoming.binding) === canonicalJson(current.binding) &&
|
|
448
|
+
incoming.policyEpoch === current.policyEpoch
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
return false;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function parseStagedEnvelope(
|
|
455
|
+
raw: string,
|
|
456
|
+
orgId: string,
|
|
457
|
+
projectId: string,
|
|
458
|
+
key: string,
|
|
459
|
+
stagingId: string,
|
|
460
|
+
): string | null {
|
|
461
|
+
const parsed: unknown = JSON.parse(raw);
|
|
462
|
+
if (
|
|
463
|
+
!exactObject(parsed, [
|
|
464
|
+
"v",
|
|
465
|
+
"kind",
|
|
466
|
+
"orgId",
|
|
467
|
+
"projectId",
|
|
468
|
+
"key",
|
|
469
|
+
"stagingId",
|
|
470
|
+
"value",
|
|
471
|
+
]) ||
|
|
472
|
+
parsed.v !== 2 ||
|
|
473
|
+
parsed.kind !== "project-env" ||
|
|
474
|
+
parsed.orgId !== orgId ||
|
|
475
|
+
parsed.projectId !== projectId ||
|
|
476
|
+
parsed.key !== key ||
|
|
477
|
+
parsed.stagingId !== stagingId ||
|
|
478
|
+
typeof parsed.value !== "string"
|
|
479
|
+
) {
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
return parsed.value;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function parseLegacyEnvelope(
|
|
486
|
+
raw: string,
|
|
487
|
+
orgId: string,
|
|
488
|
+
projectId: string,
|
|
489
|
+
key: string,
|
|
490
|
+
): string | null {
|
|
491
|
+
const parsed: unknown = JSON.parse(raw);
|
|
492
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
const object = parsed as Record<string, unknown>;
|
|
496
|
+
const keys = Object.keys(object);
|
|
497
|
+
const required = ["v", "kind", "projectId", "key", "value", "updatedAt"];
|
|
498
|
+
if (
|
|
499
|
+
required.some((field) => !Object.hasOwn(object, field)) ||
|
|
500
|
+
keys.some((field) => !required.includes(field) && field !== "orgId") ||
|
|
501
|
+
object.v !== 1 ||
|
|
502
|
+
object.kind !== "project-env" ||
|
|
503
|
+
object.projectId !== projectId ||
|
|
504
|
+
object.key !== key ||
|
|
505
|
+
typeof object.value !== "string" ||
|
|
506
|
+
typeof object.updatedAt !== "number" ||
|
|
507
|
+
!Number.isFinite(object.updatedAt) ||
|
|
508
|
+
!(object.orgId === undefined || object.orgId === orgId)
|
|
509
|
+
) {
|
|
510
|
+
return null;
|
|
511
|
+
}
|
|
512
|
+
return object.value;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function framePhase(frame: ProjectEnvTaskReplicaFrame): FramePhase {
|
|
516
|
+
switch (frame.kind) {
|
|
517
|
+
case "env.replica.v1.begin":
|
|
518
|
+
return "begun";
|
|
519
|
+
case "env.replica.v1.page":
|
|
520
|
+
return "page";
|
|
521
|
+
case "env.replica.v1.apply":
|
|
522
|
+
return "applied";
|
|
523
|
+
case "env.replica.v1.commit":
|
|
524
|
+
return "complete";
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* One process-local registry. Constructing a replacement with a new boot id
|
|
530
|
+
* starts empty; a WebSocket reconnect keeps using the same instance.
|
|
531
|
+
*/
|
|
532
|
+
export class ProjectEnvTaskReplicaManager {
|
|
533
|
+
readonly runnerBootId: string;
|
|
534
|
+
readonly policyEpochs: readonly number[];
|
|
535
|
+
|
|
536
|
+
readonly #tasks = new Map<string, TaskReplica>();
|
|
537
|
+
/** Non-secret same-boot floor: cleanup cannot make an old grant current. */
|
|
538
|
+
readonly #runEpochFloors = new Map<string, number>();
|
|
539
|
+
/** A ledger-exhausted run cannot be recreated under the same boot fence. */
|
|
540
|
+
readonly #restartRequiredRuns = new Map<string, number>();
|
|
541
|
+
readonly #lanes = new Map<string, Promise<void>>();
|
|
542
|
+
/** Grants accepted and being installed whose reader is not active yet, by
|
|
543
|
+
* task (each grant's token, and its run): the run's frames are refused,
|
|
544
|
+
* retryably, while any is pending. */
|
|
545
|
+
readonly #pendingActivations = new Map<string, Map<number, number>>();
|
|
546
|
+
#activationSequence = 0;
|
|
547
|
+
readonly #onChange: (taskId: string) => void;
|
|
548
|
+
readonly #replayLedgerMaxTaskEntries: number;
|
|
549
|
+
readonly #replayLedgerMaxTaskBytes: number;
|
|
550
|
+
readonly #replayLedgerMaxTotalEntries: number;
|
|
551
|
+
readonly #replayLedgerMaxTotalBytes: number;
|
|
552
|
+
#replayLedgerEntries = 0;
|
|
553
|
+
#replayLedgerBytes = 0;
|
|
554
|
+
|
|
555
|
+
constructor(options: ProjectEnvTaskReplicaManagerOptions = {}) {
|
|
556
|
+
const runnerBootId = options.runnerBootId ?? RUNNER_BOOT_ID;
|
|
557
|
+
const policyEpochs = [...(options.policyEpochs ?? PROJECT_ENV_POLICY_EPOCHS)];
|
|
558
|
+
const replayLedgerMaxTaskEntries =
|
|
559
|
+
options.replayLedgerLimits?.maxTaskEntries ??
|
|
560
|
+
PROJECT_ENV_REPLAY_LEDGER_MAX_TASK_ENTRIES;
|
|
561
|
+
const replayLedgerMaxTaskBytes =
|
|
562
|
+
options.replayLedgerLimits?.maxTaskBytes ??
|
|
563
|
+
PROJECT_ENV_REPLAY_LEDGER_MAX_TASK_BYTES;
|
|
564
|
+
const replayLedgerMaxTotalEntries =
|
|
565
|
+
options.replayLedgerLimits?.maxTotalEntries ??
|
|
566
|
+
PROJECT_ENV_REPLAY_LEDGER_MAX_TOTAL_ENTRIES;
|
|
567
|
+
const replayLedgerMaxTotalBytes =
|
|
568
|
+
options.replayLedgerLimits?.maxTotalBytes ??
|
|
569
|
+
PROJECT_ENV_REPLAY_LEDGER_MAX_TOTAL_BYTES;
|
|
570
|
+
if (
|
|
571
|
+
!isHostOpId(runnerBootId) ||
|
|
572
|
+
!isProjectEnvPolicyEpochs(policyEpochs) ||
|
|
573
|
+
!validReplayLedgerLimit(replayLedgerMaxTaskEntries) ||
|
|
574
|
+
!validReplayLedgerLimit(replayLedgerMaxTaskBytes) ||
|
|
575
|
+
!validReplayLedgerLimit(replayLedgerMaxTotalEntries) ||
|
|
576
|
+
!validReplayLedgerLimit(replayLedgerMaxTotalBytes) ||
|
|
577
|
+
replayLedgerMaxTaskEntries > replayLedgerMaxTotalEntries ||
|
|
578
|
+
replayLedgerMaxTaskBytes > replayLedgerMaxTotalBytes
|
|
579
|
+
) {
|
|
580
|
+
throw new Error("invalid task replica manager identity");
|
|
581
|
+
}
|
|
582
|
+
this.runnerBootId = runnerBootId;
|
|
583
|
+
this.policyEpochs = policyEpochs;
|
|
584
|
+
this.#replayLedgerMaxTaskEntries = replayLedgerMaxTaskEntries;
|
|
585
|
+
this.#replayLedgerMaxTaskBytes = replayLedgerMaxTaskBytes;
|
|
586
|
+
this.#replayLedgerMaxTotalEntries = replayLedgerMaxTotalEntries;
|
|
587
|
+
this.#replayLedgerMaxTotalBytes = replayLedgerMaxTotalBytes;
|
|
588
|
+
this.#onChange = options.onChange ?? (() => {});
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Install or refresh the lease-owned reader for an assignment. Repeating
|
|
593
|
+
* the same assignment preserves its replica across a reconnect/renewal but
|
|
594
|
+
* fences any read still using the previous reader. A new run drops every
|
|
595
|
+
* plaintext reference before creating the closed replacement.
|
|
596
|
+
*/
|
|
597
|
+
activateAssignment(
|
|
598
|
+
assignment: ProjectEnvTaskAssignment,
|
|
599
|
+
): "created" | "retained" | "replaced" {
|
|
600
|
+
if (!validAssignment(assignment)) throw new Error("invalid task assignment");
|
|
601
|
+
const floor = this.#runEpochFloors.get(assignment.taskId) ?? 0;
|
|
602
|
+
if (assignment.runEpoch < floor) {
|
|
603
|
+
throw new Error("task assignment run epoch is stale");
|
|
604
|
+
}
|
|
605
|
+
const restartRequiredRun = this.#restartRequiredRuns.get(assignment.taskId);
|
|
606
|
+
if (restartRequiredRun === assignment.runEpoch) {
|
|
607
|
+
throw new Error("task replica requires a runner restart");
|
|
608
|
+
}
|
|
609
|
+
if (
|
|
610
|
+
restartRequiredRun !== undefined &&
|
|
611
|
+
assignment.runEpoch > restartRequiredRun
|
|
612
|
+
) {
|
|
613
|
+
this.#restartRequiredRuns.delete(assignment.taskId);
|
|
614
|
+
}
|
|
615
|
+
const current = this.#tasks.get(assignment.taskId);
|
|
616
|
+
if (current?.runEpoch === assignment.runEpoch) {
|
|
617
|
+
if (current.orgId !== assignment.orgId) {
|
|
618
|
+
throw new Error("task assignment organization cannot change");
|
|
619
|
+
}
|
|
620
|
+
if (current.reader !== assignment.reader) {
|
|
621
|
+
current.reader = assignment.reader;
|
|
622
|
+
current.readerGeneration += 1;
|
|
623
|
+
current.generation += 1;
|
|
624
|
+
}
|
|
625
|
+
this.#activated(assignment);
|
|
626
|
+
return "retained";
|
|
627
|
+
}
|
|
628
|
+
if (current !== undefined) this.#destroyTask(current, "assignment replaced");
|
|
629
|
+
const task: TaskReplica = {
|
|
630
|
+
taskId: assignment.taskId,
|
|
631
|
+
runEpoch: assignment.runEpoch,
|
|
632
|
+
orgId: assignment.orgId,
|
|
633
|
+
reader: assignment.reader,
|
|
634
|
+
readerGeneration: 1,
|
|
635
|
+
snapshotReaderGeneration: null,
|
|
636
|
+
generation: 1,
|
|
637
|
+
gateOpen: false,
|
|
638
|
+
streamId: null,
|
|
639
|
+
appliedSequence: 0,
|
|
640
|
+
attempt: null,
|
|
641
|
+
projects: new Map(),
|
|
642
|
+
retiredStreams: new Set(),
|
|
643
|
+
retiredAttempts: new Set(),
|
|
644
|
+
applyReceipts: new Map(),
|
|
645
|
+
applyReceiptBytes: new Map(),
|
|
646
|
+
completedAttempts: new Map(),
|
|
647
|
+
replayLedgerEntries: 0,
|
|
648
|
+
replayLedgerBytes: 0,
|
|
649
|
+
restartRequired: false,
|
|
650
|
+
inFlightEntries: new Set(),
|
|
651
|
+
waiters: new Set(),
|
|
652
|
+
};
|
|
653
|
+
this.#runEpochFloors.set(task.taskId, task.runEpoch);
|
|
654
|
+
this.#tasks.set(task.taskId, task);
|
|
655
|
+
this.#activated(assignment);
|
|
656
|
+
this.#changed(task.taskId);
|
|
657
|
+
return current === undefined ? "created" : "replaced";
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** The run's grant reader is now active, and only now: its frames are
|
|
661
|
+
* taken from here. An activation that throws leaves them refused. */
|
|
662
|
+
#activated(assignment: ProjectEnvTaskAssignment): void {
|
|
663
|
+
const pending = this.#pendingActivations.get(assignment.taskId);
|
|
664
|
+
if (!pending) return;
|
|
665
|
+
for (const [sequence, runEpoch] of pending) {
|
|
666
|
+
if (runEpoch === assignment.runEpoch) pending.delete(sequence);
|
|
667
|
+
}
|
|
668
|
+
if (pending.size === 0) this.#pendingActivations.delete(assignment.taskId);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* A grant for the run was accepted and is being installed: until its
|
|
673
|
+
* reader activates (`activateAssignment` succeeding), the run's frames are
|
|
674
|
+
* refused with `activation_pending`, for the cloud to retry, so that no
|
|
675
|
+
* snapshot is read through the reader the grant replaces. Returns what
|
|
676
|
+
* `settleActivation` takes, once the grant's handling is over.
|
|
677
|
+
*/
|
|
678
|
+
expectActivation(taskId: string, runEpoch: number): number {
|
|
679
|
+
const sequence = ++this.#activationSequence;
|
|
680
|
+
const pending = this.#pendingActivations.get(taskId) ?? new Map<number, number>();
|
|
681
|
+
pending.set(sequence, runEpoch);
|
|
682
|
+
this.#pendingActivations.set(taskId, pending);
|
|
683
|
+
return sequence;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** That grant's handling is over, its reader active or not: its token
|
|
687
|
+
* goes, and no other grant's. */
|
|
688
|
+
settleActivation(taskId: string, sequence: number): void {
|
|
689
|
+
const pending = this.#pendingActivations.get(taskId);
|
|
690
|
+
if (!pending) return;
|
|
691
|
+
pending.delete(sequence);
|
|
692
|
+
if (pending.size === 0) this.#pendingActivations.delete(taskId);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** The run's grant reader generation now: what an install waits for a
|
|
696
|
+
* snapshot of, and what serving is bound to. */
|
|
697
|
+
readerGeneration(taskId: string, runEpoch: number): number | null {
|
|
698
|
+
const task = this.#tasks.get(taskId);
|
|
699
|
+
return task !== undefined && task.runEpoch === runEpoch ? task.readerGeneration : null;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** A stale Stop/deny for an older run may not erase the current run. */
|
|
703
|
+
dropAssignment(taskId: string, runEpoch: number): boolean {
|
|
704
|
+
const task = this.#tasks.get(taskId);
|
|
705
|
+
if (task === undefined || task.runEpoch !== runEpoch) return false;
|
|
706
|
+
this.#tasks.delete(taskId);
|
|
707
|
+
this.#destroyTask(task, "task credentials were dropped");
|
|
708
|
+
this.#changed(taskId);
|
|
709
|
+
return true;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/** Process shutdown defense in depth; a new process starts with a new id. */
|
|
713
|
+
dropAll(): void {
|
|
714
|
+
const tasks = [...this.#tasks.values()];
|
|
715
|
+
this.#tasks.clear();
|
|
716
|
+
this.#pendingActivations.clear();
|
|
717
|
+
for (const task of tasks) {
|
|
718
|
+
this.#destroyTask(task, "runner process ended");
|
|
719
|
+
this.#changed(task.taskId);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Serialized per task so page/apply order is deterministic even when a
|
|
725
|
+
* transport dispatcher calls this concurrently. Lifecycle drops remain
|
|
726
|
+
* synchronous and therefore can fence a read while it awaits AWS.
|
|
727
|
+
*/
|
|
728
|
+
async handleFrame(value: unknown): Promise<ProjectEnvReplicaV1AckFrame> {
|
|
729
|
+
const frame = this.#parseFrame(value);
|
|
730
|
+
const previous = this.#lanes.get(frame.taskId) ?? Promise.resolve();
|
|
731
|
+
const operation = previous.catch(() => {}).then(() => this.#dispatch(frame));
|
|
732
|
+
const tail = operation.then(
|
|
733
|
+
() => {},
|
|
734
|
+
() => {},
|
|
735
|
+
);
|
|
736
|
+
this.#lanes.set(frame.taskId, tail);
|
|
737
|
+
void tail.finally(() => {
|
|
738
|
+
if (this.#lanes.get(frame.taskId) === tail) this.#lanes.delete(frame.taskId);
|
|
739
|
+
});
|
|
740
|
+
return operation;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
readStatus(taskId: string): ProjectEnvTaskReplicaStatus | null {
|
|
744
|
+
const task = this.#tasks.get(taskId);
|
|
745
|
+
if (task === undefined) return null;
|
|
746
|
+
return {
|
|
747
|
+
taskId,
|
|
748
|
+
runEpoch: task.runEpoch,
|
|
749
|
+
runnerBootId: this.runnerBootId,
|
|
750
|
+
gateOpen: task.gateOpen,
|
|
751
|
+
restartRequired: task.restartRequired,
|
|
752
|
+
streamId: task.streamId,
|
|
753
|
+
attemptId: task.attempt?.attemptId ?? null,
|
|
754
|
+
appliedThrough:
|
|
755
|
+
task.streamId === null
|
|
756
|
+
? null
|
|
757
|
+
: { streamId: task.streamId, sequence: task.appliedSequence },
|
|
758
|
+
projectIds: [...task.projects.keys()].sort(),
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
isReady(taskId: string, runEpoch: number): boolean {
|
|
763
|
+
const task = this.#tasks.get(taskId);
|
|
764
|
+
return Boolean(
|
|
765
|
+
task &&
|
|
766
|
+
task.runEpoch === runEpoch &&
|
|
767
|
+
task.gateOpen &&
|
|
768
|
+
task.attempt === null &&
|
|
769
|
+
task.streamId !== null,
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
awaitReady(taskId: string, runEpoch: number, timeoutMs: number): Promise<void> {
|
|
774
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) {
|
|
775
|
+
return Promise.reject(new Error("invalid task replica readiness timeout"));
|
|
776
|
+
}
|
|
777
|
+
const task = this.#tasks.get(taskId);
|
|
778
|
+
if (task === undefined || task.runEpoch !== runEpoch) {
|
|
779
|
+
return Promise.reject(new Error("task replica assignment is unavailable"));
|
|
780
|
+
}
|
|
781
|
+
if (task.restartRequired) {
|
|
782
|
+
return Promise.reject(new Error("task replica requires a runner restart"));
|
|
783
|
+
}
|
|
784
|
+
if (this.isReady(taskId, runEpoch)) return Promise.resolve();
|
|
785
|
+
return new Promise<void>((resolve, reject) => {
|
|
786
|
+
const waiter: ReadyWaiter = {
|
|
787
|
+
resolve: () => {
|
|
788
|
+
clearTimeout(waiter.timer);
|
|
789
|
+
task.waiters.delete(waiter);
|
|
790
|
+
resolve();
|
|
791
|
+
},
|
|
792
|
+
reject: (error) => {
|
|
793
|
+
clearTimeout(waiter.timer);
|
|
794
|
+
task.waiters.delete(waiter);
|
|
795
|
+
reject(error);
|
|
796
|
+
},
|
|
797
|
+
timer: setTimeout(() => {
|
|
798
|
+
waiter.reject(
|
|
799
|
+
new Error("task environment replica did not become ready in time"),
|
|
800
|
+
);
|
|
801
|
+
}, timeoutMs),
|
|
802
|
+
};
|
|
803
|
+
waiter.timer.unref?.();
|
|
804
|
+
task.waiters.add(waiter);
|
|
805
|
+
if (this.isReady(taskId, runEpoch)) waiter.resolve();
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* Fail closed unless the caller names the current assignment and exactly
|
|
811
|
+
* the committed project set. The caller's order is retained for env merge
|
|
812
|
+
* precedence, while set comparison remains canonical.
|
|
813
|
+
*/
|
|
814
|
+
readView(
|
|
815
|
+
taskId: string,
|
|
816
|
+
runEpoch: number,
|
|
817
|
+
projectIds: readonly string[],
|
|
818
|
+
): ProjectEnvTaskReplicaSnapshot | null {
|
|
819
|
+
const task = this.#tasks.get(taskId);
|
|
820
|
+
if (
|
|
821
|
+
task === undefined ||
|
|
822
|
+
task.runEpoch !== runEpoch ||
|
|
823
|
+
!task.gateOpen ||
|
|
824
|
+
task.attempt !== null ||
|
|
825
|
+
task.streamId === null
|
|
826
|
+
) {
|
|
827
|
+
return null;
|
|
828
|
+
}
|
|
829
|
+
const requested = [...projectIds];
|
|
830
|
+
const canonical = [...new Set(requested)].sort();
|
|
831
|
+
const actual = [...task.projects.keys()].sort();
|
|
832
|
+
if (canonical.length !== requested.length || !sameStrings(canonical, actual)) {
|
|
833
|
+
return null;
|
|
834
|
+
}
|
|
835
|
+
return {
|
|
836
|
+
taskId,
|
|
837
|
+
runEpoch,
|
|
838
|
+
runnerBootId: this.runnerBootId,
|
|
839
|
+
appliedThrough: {
|
|
840
|
+
streamId: task.streamId,
|
|
841
|
+
sequence: task.appliedSequence,
|
|
842
|
+
},
|
|
843
|
+
projects: requested.map((projectId) => {
|
|
844
|
+
const project = task.projects.get(projectId)!;
|
|
845
|
+
return {
|
|
846
|
+
projectId,
|
|
847
|
+
recordVersion: project.recordVersion,
|
|
848
|
+
entries: [...project.records.values()]
|
|
849
|
+
.sort((left, right) => left.state.key.localeCompare(right.state.key))
|
|
850
|
+
.map((entry) => ({
|
|
851
|
+
state: clone(entry.state),
|
|
852
|
+
materialization: clone(entry.materialization),
|
|
853
|
+
value: entry.value,
|
|
854
|
+
})),
|
|
855
|
+
};
|
|
856
|
+
}),
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* `readView`, for a snapshot every value of which was read through the
|
|
862
|
+
* reader of `readerGeneration` (one grant's): a snapshot another grant's
|
|
863
|
+
* reader produced never answers for this grant, nor is served for it.
|
|
864
|
+
*/
|
|
865
|
+
readViewAt(
|
|
866
|
+
taskId: string,
|
|
867
|
+
runEpoch: number,
|
|
868
|
+
projectIds: readonly string[],
|
|
869
|
+
readerGeneration: number,
|
|
870
|
+
): ProjectEnvTaskReplicaSnapshot | null {
|
|
871
|
+
const task = this.#tasks.get(taskId);
|
|
872
|
+
if (task === undefined || task.snapshotReaderGeneration !== readerGeneration) return null;
|
|
873
|
+
return this.readView(taskId, runEpoch, projectIds);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
#parseFrame(value: unknown): ProjectEnvTaskReplicaFrame {
|
|
877
|
+
if (isProjectEnvReplicaV1BeginFrame(value)) return clone(value);
|
|
878
|
+
if (isProjectEnvReplicaV1PageFrame(value)) return clone(value);
|
|
879
|
+
if (isProjectEnvReplicaV1ApplyFrame(value)) return clone(value);
|
|
880
|
+
if (isProjectEnvReplicaV1CommitFrame(value)) return clone(value);
|
|
881
|
+
throw new Error("invalid task project environment replica frame");
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
async #dispatch(
|
|
885
|
+
frame: ProjectEnvTaskReplicaFrame,
|
|
886
|
+
): Promise<ProjectEnvReplicaV1AckFrame> {
|
|
887
|
+
const task = this.#tasks.get(frame.taskId);
|
|
888
|
+
const identityError = this.#identityError(task, frame);
|
|
889
|
+
if (identityError !== null) return this.#errorAck(frame, task, identityError);
|
|
890
|
+
if (task!.restartRequired) {
|
|
891
|
+
return this.#errorAck(frame, task, REPLAY_LEDGER_EXHAUSTED);
|
|
892
|
+
}
|
|
893
|
+
switch (frame.kind) {
|
|
894
|
+
case "env.replica.v1.begin":
|
|
895
|
+
return this.#begin(task!, frame);
|
|
896
|
+
case "env.replica.v1.page":
|
|
897
|
+
return this.#page(task!, frame);
|
|
898
|
+
case "env.replica.v1.apply":
|
|
899
|
+
return this.#apply(task!, frame);
|
|
900
|
+
case "env.replica.v1.commit":
|
|
901
|
+
return this.#commit(task!, frame);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
#identityError(
|
|
906
|
+
task: TaskReplica | undefined,
|
|
907
|
+
frame: ProjectEnvTaskReplicaFrame,
|
|
908
|
+
): string | null {
|
|
909
|
+
if (frame.runnerBootId !== this.runnerBootId) return "runner_boot_mismatch";
|
|
910
|
+
for (const runEpoch of this.#pendingActivations.get(frame.taskId)?.values() ?? []) {
|
|
911
|
+
if (runEpoch === frame.runEpoch) return PROJECT_ENV_ACTIVATION_PENDING;
|
|
912
|
+
}
|
|
913
|
+
if (task === undefined || task.runEpoch !== frame.runEpoch) {
|
|
914
|
+
return "task_assignment_mismatch";
|
|
915
|
+
}
|
|
916
|
+
return null;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
#begin(
|
|
920
|
+
task: TaskReplica,
|
|
921
|
+
frame: ProjectEnvReplicaV1BeginFrame,
|
|
922
|
+
): ProjectEnvReplicaV1AckFrame {
|
|
923
|
+
const fingerprint = canonicalJson({
|
|
924
|
+
streamId: frame.streamId,
|
|
925
|
+
attemptId: frame.attemptId,
|
|
926
|
+
projectIds: frame.projectIds,
|
|
927
|
+
});
|
|
928
|
+
if (task.retiredStreams.has(frame.streamId)) {
|
|
929
|
+
return this.#errorAck(frame, task, "stream_retired");
|
|
930
|
+
}
|
|
931
|
+
if (task.retiredAttempts.has(frame.attemptId)) {
|
|
932
|
+
return this.#errorAck(frame, task, "reconciliation_attempt_retired");
|
|
933
|
+
}
|
|
934
|
+
if (task.streamId === frame.streamId && task.attempt !== null) {
|
|
935
|
+
if (
|
|
936
|
+
task.attempt.attemptId === frame.attemptId &&
|
|
937
|
+
task.attempt.beginFingerprint === fingerprint
|
|
938
|
+
) {
|
|
939
|
+
return this.#successBegin(frame, task);
|
|
940
|
+
}
|
|
941
|
+
if (task.attempt.attemptId === frame.attemptId) {
|
|
942
|
+
return this.#errorAck(frame, task, "reconciliation_attempt_conflict");
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
if (task.streamId !== null && task.streamId !== frame.streamId) {
|
|
946
|
+
// Receipts belong to the old ordered stream. Once a different stream's
|
|
947
|
+
// begin arrives, the retired stream id is the evidence that prevents a
|
|
948
|
+
// delayed old begin from becoming current again.
|
|
949
|
+
this.#clearApplyReceipts(task);
|
|
950
|
+
if (!this.#rememberRetiredStream(task, task.streamId)) {
|
|
951
|
+
return this.#exhaustReplayLedger(task, frame);
|
|
952
|
+
}
|
|
953
|
+
task.appliedSequence = 0;
|
|
954
|
+
}
|
|
955
|
+
if (task.attempt !== null) {
|
|
956
|
+
if (!this.#rememberRetiredAttempt(task, task.attempt.attemptId)) {
|
|
957
|
+
return this.#exhaustReplayLedger(task, frame);
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
scrubAttempt(task.attempt);
|
|
961
|
+
task.streamId = frame.streamId;
|
|
962
|
+
task.gateOpen = false;
|
|
963
|
+
task.generation += 1;
|
|
964
|
+
task.attempt = {
|
|
965
|
+
attemptId: frame.attemptId,
|
|
966
|
+
beginFingerprint: fingerprint,
|
|
967
|
+
readerGeneration: task.readerGeneration,
|
|
968
|
+
projectIds: [...frame.projectIds],
|
|
969
|
+
projects: new Map(
|
|
970
|
+
frame.projectIds.map((projectId) => [
|
|
971
|
+
projectId,
|
|
972
|
+
{
|
|
973
|
+
recordVersion: 0,
|
|
974
|
+
records: new Map(),
|
|
975
|
+
started: false,
|
|
976
|
+
complete: false,
|
|
977
|
+
lastKey: null,
|
|
978
|
+
pages: new Map(),
|
|
979
|
+
},
|
|
980
|
+
]),
|
|
981
|
+
),
|
|
982
|
+
};
|
|
983
|
+
this.#changed(task.taskId);
|
|
984
|
+
return this.#successBegin(frame, task);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
async #page(
|
|
988
|
+
task: TaskReplica,
|
|
989
|
+
frame: ProjectEnvReplicaV1PageFrame,
|
|
990
|
+
): Promise<ProjectEnvReplicaV1AckFrame> {
|
|
991
|
+
const attempt = task.attempt;
|
|
992
|
+
if (
|
|
993
|
+
task.streamId !== frame.streamId ||
|
|
994
|
+
attempt === null ||
|
|
995
|
+
attempt.attemptId !== frame.attemptId ||
|
|
996
|
+
task.gateOpen
|
|
997
|
+
) {
|
|
998
|
+
return this.#errorAck(frame, task, "reconciliation_attempt_mismatch");
|
|
999
|
+
}
|
|
1000
|
+
if (attempt.readerGeneration !== task.readerGeneration) {
|
|
1001
|
+
return this.#errorAck(frame, task, PROJECT_ENV_READER_CHANGED);
|
|
1002
|
+
}
|
|
1003
|
+
const project = attempt.projects.get(frame.projectId);
|
|
1004
|
+
if (project === undefined) {
|
|
1005
|
+
return this.#errorAck(frame, task, "project_not_in_attempt");
|
|
1006
|
+
}
|
|
1007
|
+
const pageKey = frame.after ?? NULL_PAGE_CURSOR;
|
|
1008
|
+
const fingerprint = canonicalJson({
|
|
1009
|
+
projectId: frame.projectId,
|
|
1010
|
+
after: frame.after,
|
|
1011
|
+
recordVersion: frame.recordVersion,
|
|
1012
|
+
records: frame.records,
|
|
1013
|
+
complete: frame.complete,
|
|
1014
|
+
});
|
|
1015
|
+
const receipt = project.pages.get(pageKey);
|
|
1016
|
+
if (receipt !== undefined) {
|
|
1017
|
+
if (receipt.fingerprint !== fingerprint) {
|
|
1018
|
+
return this.#errorAck(frame, task, "snapshot_page_conflict");
|
|
1019
|
+
}
|
|
1020
|
+
return this.#successPage(frame, receipt, task);
|
|
1021
|
+
}
|
|
1022
|
+
if (
|
|
1023
|
+
(frame.after === null && project.started) ||
|
|
1024
|
+
(frame.after !== null &&
|
|
1025
|
+
(!project.started || project.complete || project.lastKey !== frame.after))
|
|
1026
|
+
) {
|
|
1027
|
+
return this.#errorAck(frame, task, "snapshot_page_mismatch");
|
|
1028
|
+
}
|
|
1029
|
+
if (project.started && project.recordVersion !== frame.recordVersion) {
|
|
1030
|
+
return this.#errorAck(frame, task, "snapshot_version_conflict");
|
|
1031
|
+
}
|
|
1032
|
+
const liveProject = task.projects.get(frame.projectId);
|
|
1033
|
+
if (
|
|
1034
|
+
!project.started &&
|
|
1035
|
+
liveProject !== undefined &&
|
|
1036
|
+
frame.recordVersion < liveProject.recordVersion
|
|
1037
|
+
) {
|
|
1038
|
+
return this.#errorAck(frame, task, "record_version_stale");
|
|
1039
|
+
}
|
|
1040
|
+
if (
|
|
1041
|
+
liveProject !== undefined &&
|
|
1042
|
+
frame.recordVersion === liveProject.recordVersion
|
|
1043
|
+
) {
|
|
1044
|
+
const conflicts = frame.records.some((state) => {
|
|
1045
|
+
const live = liveProject.records.get(state.key);
|
|
1046
|
+
return live === undefined || canonicalJson(live.state) !== canonicalJson(state);
|
|
1047
|
+
});
|
|
1048
|
+
const prospectiveKeys = new Set([
|
|
1049
|
+
...project.records.keys(),
|
|
1050
|
+
...frame.records.map((state) => state.key),
|
|
1051
|
+
]);
|
|
1052
|
+
if (
|
|
1053
|
+
conflicts ||
|
|
1054
|
+
(frame.complete &&
|
|
1055
|
+
(prospectiveKeys.size !== liveProject.records.size ||
|
|
1056
|
+
[...prospectiveKeys].some((key) => !liveProject.records.has(key))))
|
|
1057
|
+
) {
|
|
1058
|
+
return this.#errorAck(frame, task, "snapshot_version_conflict");
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
const generation = task.generation;
|
|
1063
|
+
const reader = task.reader;
|
|
1064
|
+
const entries: ReplicaEntry[] = [];
|
|
1065
|
+
for (const state of frame.records) {
|
|
1066
|
+
const live = liveProject?.records.get(state.key);
|
|
1067
|
+
// Plaintext the live snapshot holds is kept only when the same reader
|
|
1068
|
+
// read it: a newer grant reads every value again, through itself.
|
|
1069
|
+
const mayKeepInstalled =
|
|
1070
|
+
live !== undefined &&
|
|
1071
|
+
task.snapshotReaderGeneration === attempt.readerGeneration &&
|
|
1072
|
+
live.materialization.kind === "installed" &&
|
|
1073
|
+
canonicalJson(live.state) === canonicalJson(state) &&
|
|
1074
|
+
desiredMaterialization(state, this.policyEpochs).kind !== "withheld";
|
|
1075
|
+
const entry = mayKeepInstalled
|
|
1076
|
+
? {
|
|
1077
|
+
state: clone(live.state),
|
|
1078
|
+
materialization: clone(live.materialization),
|
|
1079
|
+
value: live.value,
|
|
1080
|
+
}
|
|
1081
|
+
: await this.#materialize(task, frame.projectId, state, reader);
|
|
1082
|
+
task.inFlightEntries.add(entry);
|
|
1083
|
+
if (!this.#readFenceCurrent(task, frame, generation, reader, attempt)) {
|
|
1084
|
+
for (const candidate of entries) {
|
|
1085
|
+
scrubEntry(candidate);
|
|
1086
|
+
task.inFlightEntries.delete(candidate);
|
|
1087
|
+
}
|
|
1088
|
+
scrubEntry(entry);
|
|
1089
|
+
task.inFlightEntries.delete(entry);
|
|
1090
|
+
return this.#errorAck(frame, this.#tasks.get(frame.taskId), this.#fenceCode(task, attempt.readerGeneration));
|
|
1091
|
+
}
|
|
1092
|
+
entries.push(entry);
|
|
1093
|
+
}
|
|
1094
|
+
const results = entries.map(resultOf);
|
|
1095
|
+
const appliedThrough = this.#cursor(task, frame.streamId);
|
|
1096
|
+
const prospective = this.#successPage(
|
|
1097
|
+
frame,
|
|
1098
|
+
{
|
|
1099
|
+
fingerprint,
|
|
1100
|
+
after: frame.after,
|
|
1101
|
+
recordVersion: frame.recordVersion,
|
|
1102
|
+
results,
|
|
1103
|
+
complete: frame.complete,
|
|
1104
|
+
appliedThrough,
|
|
1105
|
+
},
|
|
1106
|
+
task,
|
|
1107
|
+
);
|
|
1108
|
+
if (
|
|
1109
|
+
Buffer.byteLength(JSON.stringify(prospective), "utf8") >
|
|
1110
|
+
MAX_PROJECT_ENV_ACK_PAGE_BYTES
|
|
1111
|
+
) {
|
|
1112
|
+
for (const entry of entries) {
|
|
1113
|
+
scrubEntry(entry);
|
|
1114
|
+
task.inFlightEntries.delete(entry);
|
|
1115
|
+
}
|
|
1116
|
+
return this.#errorAck(frame, task, "ack_page_too_large");
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
for (const entry of entries) {
|
|
1120
|
+
task.inFlightEntries.delete(entry);
|
|
1121
|
+
project.records.set(entry.state.key, entry);
|
|
1122
|
+
}
|
|
1123
|
+
project.started = true;
|
|
1124
|
+
project.recordVersion = frame.recordVersion;
|
|
1125
|
+
project.lastKey = frame.records.at(-1)?.key ?? null;
|
|
1126
|
+
project.complete = frame.complete;
|
|
1127
|
+
project.pages.set(pageKey, {
|
|
1128
|
+
fingerprint,
|
|
1129
|
+
after: frame.after,
|
|
1130
|
+
recordVersion: frame.recordVersion,
|
|
1131
|
+
results: clone(results),
|
|
1132
|
+
complete: frame.complete,
|
|
1133
|
+
appliedThrough,
|
|
1134
|
+
});
|
|
1135
|
+
return prospective;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
async #apply(
|
|
1139
|
+
task: TaskReplica,
|
|
1140
|
+
frame: ProjectEnvReplicaV1ApplyFrame,
|
|
1141
|
+
): Promise<ProjectEnvReplicaV1AckFrame> {
|
|
1142
|
+
if (task.streamId !== frame.streamId) {
|
|
1143
|
+
return this.#errorAck(frame, task, "stream_mismatch");
|
|
1144
|
+
}
|
|
1145
|
+
const fingerprint = canonicalJson({
|
|
1146
|
+
attemptId: frame.attemptId,
|
|
1147
|
+
projectId: frame.projectId,
|
|
1148
|
+
recordVersion: frame.recordVersion,
|
|
1149
|
+
state: frame.state,
|
|
1150
|
+
delivery: frame.delivery,
|
|
1151
|
+
});
|
|
1152
|
+
if (frame.delivery.sequence <= task.appliedSequence) {
|
|
1153
|
+
const receipt = task.applyReceipts.get(frame.delivery.sequence);
|
|
1154
|
+
if (receipt === undefined) {
|
|
1155
|
+
return this.#errorAck(frame, task, "receipt_missing");
|
|
1156
|
+
}
|
|
1157
|
+
if (receipt.fingerprint !== fingerprint) {
|
|
1158
|
+
return this.#errorAck(frame, task, "delivery_conflict");
|
|
1159
|
+
}
|
|
1160
|
+
return this.#ackFromApplyReceipt(frame, receipt, task);
|
|
1161
|
+
}
|
|
1162
|
+
if (
|
|
1163
|
+
task.gateOpen
|
|
1164
|
+
? frame.attemptId !== null || task.attempt !== null
|
|
1165
|
+
: task.attempt === null || frame.attemptId !== task.attempt.attemptId
|
|
1166
|
+
) {
|
|
1167
|
+
return this.#errorAck(frame, task, "reconciliation_attempt_mismatch");
|
|
1168
|
+
}
|
|
1169
|
+
if (frame.delivery.sequence !== task.appliedSequence + 1) {
|
|
1170
|
+
return this.#errorAck(frame, task, "delivery_gap");
|
|
1171
|
+
}
|
|
1172
|
+
// Read through the run's current reader, into what that same reader
|
|
1173
|
+
// read: an attempt begun under it, or a live snapshot it produced.
|
|
1174
|
+
if (
|
|
1175
|
+
task.attempt === null
|
|
1176
|
+
? task.snapshotReaderGeneration !== task.readerGeneration
|
|
1177
|
+
: task.attempt.readerGeneration !== task.readerGeneration
|
|
1178
|
+
) {
|
|
1179
|
+
return this.#errorAck(frame, task, PROJECT_ENV_READER_CHANGED);
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
const project = task.attempt === null
|
|
1183
|
+
? task.projects.get(frame.projectId)
|
|
1184
|
+
: task.attempt.projects.get(frame.projectId);
|
|
1185
|
+
if (project === undefined) {
|
|
1186
|
+
return this.#consumeApplyError(
|
|
1187
|
+
task,
|
|
1188
|
+
frame,
|
|
1189
|
+
fingerprint,
|
|
1190
|
+
"project_not_in_replica",
|
|
1191
|
+
);
|
|
1192
|
+
}
|
|
1193
|
+
if ("complete" in project && !project.complete) {
|
|
1194
|
+
return this.#errorAck(frame, task, "snapshot_incomplete");
|
|
1195
|
+
}
|
|
1196
|
+
if (frame.recordVersion > project.recordVersion + 1) {
|
|
1197
|
+
return this.#errorAck(frame, task, "record_version_gap");
|
|
1198
|
+
}
|
|
1199
|
+
const current = project.records.get(frame.state.key) ?? null;
|
|
1200
|
+
const advances =
|
|
1201
|
+
frame.recordVersion === project.recordVersion + 1 &&
|
|
1202
|
+
(current === null || stateOrder(frame.state, current.state) === "newer") &&
|
|
1203
|
+
isReachableRecordAdvance(frame.state, current?.state ?? null);
|
|
1204
|
+
if (!advances && current === null) {
|
|
1205
|
+
return this.#consumeApplyError(
|
|
1206
|
+
task,
|
|
1207
|
+
frame,
|
|
1208
|
+
fingerprint,
|
|
1209
|
+
"record_version_conflict",
|
|
1210
|
+
);
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
const desiredState = advances ? frame.state : current!.state;
|
|
1214
|
+
const generation = task.generation;
|
|
1215
|
+
const reader = task.reader;
|
|
1216
|
+
const readerGeneration = task.readerGeneration;
|
|
1217
|
+
const attempt = task.attempt;
|
|
1218
|
+
let entry: ReplicaEntry;
|
|
1219
|
+
if (
|
|
1220
|
+
!advances &&
|
|
1221
|
+
current!.materialization.kind === "installed" &&
|
|
1222
|
+
desiredMaterialization(current!.state, this.policyEpochs).kind !== "withheld"
|
|
1223
|
+
) {
|
|
1224
|
+
entry = {
|
|
1225
|
+
state: clone(current!.state),
|
|
1226
|
+
materialization: clone(current!.materialization),
|
|
1227
|
+
value: current!.value,
|
|
1228
|
+
};
|
|
1229
|
+
} else {
|
|
1230
|
+
entry = await this.#materialize(task, frame.projectId, desiredState, reader);
|
|
1231
|
+
task.inFlightEntries.add(entry);
|
|
1232
|
+
if (!this.#readFenceCurrent(task, frame, generation, reader, attempt)) {
|
|
1233
|
+
scrubEntry(entry);
|
|
1234
|
+
task.inFlightEntries.delete(entry);
|
|
1235
|
+
return this.#errorAck(frame, this.#tasks.get(frame.taskId), this.#fenceCode(task, readerGeneration));
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
const installedRecordVersion = advances
|
|
1240
|
+
? frame.recordVersion
|
|
1241
|
+
: project.recordVersion;
|
|
1242
|
+
const appliedThrough = clone(frame.delivery);
|
|
1243
|
+
const receipt: ApplyReceipt = {
|
|
1244
|
+
fingerprint,
|
|
1245
|
+
ok: true,
|
|
1246
|
+
attemptId: frame.attemptId,
|
|
1247
|
+
projectId: frame.projectId,
|
|
1248
|
+
recordVersion: installedRecordVersion,
|
|
1249
|
+
result: resultOf(entry),
|
|
1250
|
+
appliedThrough,
|
|
1251
|
+
};
|
|
1252
|
+
if (!this.#rememberApplyReceipt(task, frame.delivery.sequence, receipt)) {
|
|
1253
|
+
scrubEntry(entry);
|
|
1254
|
+
task.inFlightEntries.delete(entry);
|
|
1255
|
+
return this.#exhaustReplayLedger(task, frame);
|
|
1256
|
+
}
|
|
1257
|
+
task.inFlightEntries.delete(entry);
|
|
1258
|
+
if (current !== null && current !== entry) scrubEntry(current);
|
|
1259
|
+
project.records.set(entry.state.key, entry);
|
|
1260
|
+
project.recordVersion = installedRecordVersion;
|
|
1261
|
+
task.appliedSequence = frame.delivery.sequence;
|
|
1262
|
+
if (task.gateOpen) this.#changed(task.taskId);
|
|
1263
|
+
return this.#ackFromApplyReceipt(frame, receipt, task);
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
#commit(
|
|
1267
|
+
task: TaskReplica,
|
|
1268
|
+
frame: ProjectEnvReplicaV1CommitFrame,
|
|
1269
|
+
): ProjectEnvReplicaV1AckFrame {
|
|
1270
|
+
const fingerprint = canonicalJson({
|
|
1271
|
+
streamId: frame.streamId,
|
|
1272
|
+
attemptId: frame.attemptId,
|
|
1273
|
+
highWater: frame.highWater,
|
|
1274
|
+
versions: frame.versions,
|
|
1275
|
+
});
|
|
1276
|
+
const completed = task.completedAttempts.get(frame.attemptId);
|
|
1277
|
+
if (completed !== undefined) {
|
|
1278
|
+
if (completed.fingerprint !== fingerprint) {
|
|
1279
|
+
return this.#errorAck(frame, task, "reconciliation_attempt_conflict");
|
|
1280
|
+
}
|
|
1281
|
+
return this.#successCommit(frame, completed.highWater, task);
|
|
1282
|
+
}
|
|
1283
|
+
const attempt = task.attempt;
|
|
1284
|
+
if (
|
|
1285
|
+
task.streamId !== frame.streamId ||
|
|
1286
|
+
attempt === null ||
|
|
1287
|
+
attempt.attemptId !== frame.attemptId ||
|
|
1288
|
+
task.gateOpen
|
|
1289
|
+
) {
|
|
1290
|
+
return this.#errorAck(frame, task, "reconciliation_attempt_mismatch");
|
|
1291
|
+
}
|
|
1292
|
+
if (attempt.readerGeneration !== task.readerGeneration) {
|
|
1293
|
+
return this.#errorAck(frame, task, PROJECT_ENV_READER_CHANGED);
|
|
1294
|
+
}
|
|
1295
|
+
if (frame.highWater.sequence !== task.appliedSequence) {
|
|
1296
|
+
return this.#errorAck(frame, task, "delivery_incomplete");
|
|
1297
|
+
}
|
|
1298
|
+
if (
|
|
1299
|
+
frame.versions.length !== attempt.projectIds.length ||
|
|
1300
|
+
frame.versions.some((version, index) => {
|
|
1301
|
+
const projectId = attempt.projectIds[index];
|
|
1302
|
+
const project = projectId === undefined
|
|
1303
|
+
? undefined
|
|
1304
|
+
: attempt.projects.get(projectId);
|
|
1305
|
+
return (
|
|
1306
|
+
project === undefined ||
|
|
1307
|
+
version.projectId !== projectId ||
|
|
1308
|
+
!project.complete ||
|
|
1309
|
+
project.recordVersion !== version.recordVersion
|
|
1310
|
+
);
|
|
1311
|
+
})
|
|
1312
|
+
) {
|
|
1313
|
+
return this.#errorAck(frame, task, "record_version_changed");
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
const completedAttempt: CompletedAttempt = {
|
|
1317
|
+
fingerprint,
|
|
1318
|
+
highWater: clone(frame.highWater),
|
|
1319
|
+
};
|
|
1320
|
+
if (
|
|
1321
|
+
!this.#rememberCompletedAttempt(
|
|
1322
|
+
task,
|
|
1323
|
+
frame.attemptId,
|
|
1324
|
+
completedAttempt,
|
|
1325
|
+
) ||
|
|
1326
|
+
!this.#rememberRetiredAttempt(task, frame.attemptId)
|
|
1327
|
+
) {
|
|
1328
|
+
return this.#exhaustReplayLedger(task, frame);
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
const oldProjects = task.projects;
|
|
1332
|
+
task.projects = new Map(
|
|
1333
|
+
attempt.projectIds.map((projectId) => {
|
|
1334
|
+
const staged = attempt.projects.get(projectId)!;
|
|
1335
|
+
return [
|
|
1336
|
+
projectId,
|
|
1337
|
+
{
|
|
1338
|
+
recordVersion: staged.recordVersion,
|
|
1339
|
+
records: staged.records,
|
|
1340
|
+
},
|
|
1341
|
+
];
|
|
1342
|
+
}),
|
|
1343
|
+
);
|
|
1344
|
+
// Transfer ownership before clearing the attempt; then scrub the old live
|
|
1345
|
+
// map so removed projects and superseded values lose their last reference.
|
|
1346
|
+
for (const project of attempt.projects.values()) project.records = new Map();
|
|
1347
|
+
task.attempt = null;
|
|
1348
|
+
task.snapshotReaderGeneration = attempt.readerGeneration;
|
|
1349
|
+
task.gateOpen = true;
|
|
1350
|
+
task.generation += 1;
|
|
1351
|
+
scrubProjects(oldProjects);
|
|
1352
|
+
this.#resolveReady(task);
|
|
1353
|
+
this.#changed(task.taskId);
|
|
1354
|
+
return this.#successCommit(frame, frame.highWater, task);
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
async #materialize(
|
|
1358
|
+
task: TaskReplica,
|
|
1359
|
+
projectId: string,
|
|
1360
|
+
state: ProjectEnvReplicaBindingState,
|
|
1361
|
+
reader: ProjectEnvTaskValueReader,
|
|
1362
|
+
): Promise<ReplicaEntry> {
|
|
1363
|
+
const materialization = desiredMaterialization(state, this.policyEpochs);
|
|
1364
|
+
if (materialization.kind === "withheld" || state.head.tombstone) {
|
|
1365
|
+
return { state: clone(state), materialization, value: null };
|
|
1366
|
+
}
|
|
1367
|
+
const ref = state.head.valueRef;
|
|
1368
|
+
if (ref === null) {
|
|
1369
|
+
return {
|
|
1370
|
+
state: clone(state),
|
|
1371
|
+
materialization: { kind: "unavailable", reason: "value_reference_invalid" },
|
|
1372
|
+
value: null,
|
|
1373
|
+
};
|
|
1374
|
+
}
|
|
1375
|
+
try {
|
|
1376
|
+
if (ref.kind === "staged") {
|
|
1377
|
+
const raw = await reader.readStaged(
|
|
1378
|
+
projectId,
|
|
1379
|
+
state.key,
|
|
1380
|
+
ref.stagingId,
|
|
1381
|
+
);
|
|
1382
|
+
if (raw === null) return this.#unavailable(state, "value_missing");
|
|
1383
|
+
const value = parseStagedEnvelope(
|
|
1384
|
+
raw,
|
|
1385
|
+
task.orgId,
|
|
1386
|
+
projectId,
|
|
1387
|
+
state.key,
|
|
1388
|
+
ref.stagingId,
|
|
1389
|
+
);
|
|
1390
|
+
return value === null
|
|
1391
|
+
? this.#unavailable(state, "value_envelope_mismatch")
|
|
1392
|
+
: {
|
|
1393
|
+
state: clone(state),
|
|
1394
|
+
materialization: { kind: "installed" },
|
|
1395
|
+
value,
|
|
1396
|
+
};
|
|
1397
|
+
}
|
|
1398
|
+
const found = await reader.readLegacy(
|
|
1399
|
+
projectId,
|
|
1400
|
+
state.key,
|
|
1401
|
+
ref.ssmVersion,
|
|
1402
|
+
);
|
|
1403
|
+
if (found === null) return this.#unavailable(state, "value_missing");
|
|
1404
|
+
if (found.version !== ref.ssmVersion) {
|
|
1405
|
+
return this.#unavailable(state, "value_version_mismatch");
|
|
1406
|
+
}
|
|
1407
|
+
const value = parseLegacyEnvelope(
|
|
1408
|
+
found.value,
|
|
1409
|
+
task.orgId,
|
|
1410
|
+
projectId,
|
|
1411
|
+
state.key,
|
|
1412
|
+
);
|
|
1413
|
+
return value === null
|
|
1414
|
+
? this.#unavailable(state, "value_envelope_mismatch")
|
|
1415
|
+
: {
|
|
1416
|
+
state: clone(state),
|
|
1417
|
+
materialization: { kind: "installed" },
|
|
1418
|
+
value,
|
|
1419
|
+
};
|
|
1420
|
+
} catch {
|
|
1421
|
+
return this.#unavailable(state, "value_unavailable");
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
#unavailable(
|
|
1426
|
+
state: ProjectEnvReplicaBindingState,
|
|
1427
|
+
reason: string,
|
|
1428
|
+
): ReplicaEntry {
|
|
1429
|
+
return {
|
|
1430
|
+
state: clone(state),
|
|
1431
|
+
materialization: { kind: "unavailable", reason },
|
|
1432
|
+
value: null,
|
|
1433
|
+
};
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
/** Why a read crossed its fence: the run's reader changed (a newer
|
|
1437
|
+
* grant's), or anything else. */
|
|
1438
|
+
#fenceCode(task: TaskReplica, readerGeneration: number): string {
|
|
1439
|
+
return this.#tasks.get(task.taskId) === task && task.readerGeneration !== readerGeneration
|
|
1440
|
+
? PROJECT_ENV_READER_CHANGED
|
|
1441
|
+
: "replica_state_changed";
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
#readFenceCurrent(
|
|
1445
|
+
task: TaskReplica,
|
|
1446
|
+
frame: ProjectEnvReplicaV1PageFrame | ProjectEnvReplicaV1ApplyFrame,
|
|
1447
|
+
generation: number,
|
|
1448
|
+
reader: ProjectEnvTaskValueReader,
|
|
1449
|
+
attempt: SnapshotAttempt | null,
|
|
1450
|
+
): boolean {
|
|
1451
|
+
return (
|
|
1452
|
+
this.#tasks.get(task.taskId) === task &&
|
|
1453
|
+
task.runEpoch === frame.runEpoch &&
|
|
1454
|
+
frame.runnerBootId === this.runnerBootId &&
|
|
1455
|
+
task.generation === generation &&
|
|
1456
|
+
task.reader === reader &&
|
|
1457
|
+
task.streamId === frame.streamId &&
|
|
1458
|
+
task.attempt === attempt &&
|
|
1459
|
+
(attempt === null
|
|
1460
|
+
? frame.kind === "env.replica.v1.apply" && frame.attemptId === null
|
|
1461
|
+
: frame.attemptId === attempt.attemptId)
|
|
1462
|
+
);
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
#consumeApplyError(
|
|
1466
|
+
task: TaskReplica,
|
|
1467
|
+
frame: ProjectEnvReplicaV1ApplyFrame,
|
|
1468
|
+
fingerprint: string,
|
|
1469
|
+
code: string,
|
|
1470
|
+
): ProjectEnvReplicaV1AckFrame {
|
|
1471
|
+
const receipt: ApplyReceipt = {
|
|
1472
|
+
fingerprint,
|
|
1473
|
+
ok: false,
|
|
1474
|
+
attemptId: frame.attemptId,
|
|
1475
|
+
code,
|
|
1476
|
+
appliedThrough: clone(frame.delivery),
|
|
1477
|
+
};
|
|
1478
|
+
if (!this.#rememberApplyReceipt(task, frame.delivery.sequence, receipt)) {
|
|
1479
|
+
return this.#exhaustReplayLedger(task, frame);
|
|
1480
|
+
}
|
|
1481
|
+
task.appliedSequence = frame.delivery.sequence;
|
|
1482
|
+
return this.#ackFromApplyReceipt(frame, receipt, task);
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
#ackFromApplyReceipt(
|
|
1486
|
+
frame: ProjectEnvReplicaV1ApplyFrame,
|
|
1487
|
+
receipt: ApplyReceipt,
|
|
1488
|
+
task: TaskReplica,
|
|
1489
|
+
): ProjectEnvReplicaV1AckFrame {
|
|
1490
|
+
const common = {
|
|
1491
|
+
kind: "env.replica.v1.ack" as const,
|
|
1492
|
+
opId: frame.opId,
|
|
1493
|
+
taskId: frame.taskId,
|
|
1494
|
+
runEpoch: frame.runEpoch,
|
|
1495
|
+
runnerBootId: frame.runnerBootId,
|
|
1496
|
+
streamId: frame.streamId,
|
|
1497
|
+
attemptId: frame.attemptId,
|
|
1498
|
+
phase: "applied" as const,
|
|
1499
|
+
policyEpochs: [...this.policyEpochs],
|
|
1500
|
+
appliedThrough: this.#cursor(task, frame.streamId),
|
|
1501
|
+
};
|
|
1502
|
+
if (!receipt.ok) {
|
|
1503
|
+
return { ...common, ok: false, code: receipt.code, error: receipt.code };
|
|
1504
|
+
}
|
|
1505
|
+
return {
|
|
1506
|
+
...common,
|
|
1507
|
+
ok: true,
|
|
1508
|
+
projectId: receipt.projectId,
|
|
1509
|
+
recordVersion: receipt.recordVersion,
|
|
1510
|
+
result: clone(receipt.result),
|
|
1511
|
+
};
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
#successBegin(
|
|
1515
|
+
frame: ProjectEnvReplicaV1BeginFrame,
|
|
1516
|
+
task: TaskReplica,
|
|
1517
|
+
): ProjectEnvReplicaV1AckFrame {
|
|
1518
|
+
return {
|
|
1519
|
+
...this.#ackCommon(frame, task),
|
|
1520
|
+
attemptId: frame.attemptId,
|
|
1521
|
+
ok: true,
|
|
1522
|
+
phase: "begun",
|
|
1523
|
+
};
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
#successPage(
|
|
1527
|
+
frame: ProjectEnvReplicaV1PageFrame,
|
|
1528
|
+
receipt: PageReceipt,
|
|
1529
|
+
task: TaskReplica,
|
|
1530
|
+
): ProjectEnvReplicaV1AckFrame {
|
|
1531
|
+
return {
|
|
1532
|
+
...this.#ackCommon(frame, task, receipt.appliedThrough),
|
|
1533
|
+
attemptId: frame.attemptId,
|
|
1534
|
+
ok: true,
|
|
1535
|
+
phase: "page",
|
|
1536
|
+
projectId: frame.projectId,
|
|
1537
|
+
after: receipt.after,
|
|
1538
|
+
recordVersion: receipt.recordVersion,
|
|
1539
|
+
results: clone(receipt.results),
|
|
1540
|
+
complete: receipt.complete,
|
|
1541
|
+
};
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
#successCommit(
|
|
1545
|
+
frame: ProjectEnvReplicaV1CommitFrame,
|
|
1546
|
+
highWater: ProjectEnvDeliveryCursor,
|
|
1547
|
+
task: TaskReplica,
|
|
1548
|
+
): ProjectEnvReplicaV1AckFrame {
|
|
1549
|
+
return {
|
|
1550
|
+
...this.#ackCommon(frame, task, highWater),
|
|
1551
|
+
attemptId: frame.attemptId,
|
|
1552
|
+
ok: true,
|
|
1553
|
+
phase: "complete",
|
|
1554
|
+
highWater: clone(highWater),
|
|
1555
|
+
};
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
#errorAck(
|
|
1559
|
+
frame: ProjectEnvTaskReplicaFrame,
|
|
1560
|
+
task: TaskReplica | undefined,
|
|
1561
|
+
code: string,
|
|
1562
|
+
): ProjectEnvReplicaV1AckFrame {
|
|
1563
|
+
return {
|
|
1564
|
+
...this.#ackCommon(frame, task),
|
|
1565
|
+
ok: false,
|
|
1566
|
+
phase: framePhase(frame),
|
|
1567
|
+
code,
|
|
1568
|
+
error: code,
|
|
1569
|
+
} as ProjectEnvReplicaV1AckFrame;
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
#ackCommon(
|
|
1573
|
+
frame: ProjectEnvTaskReplicaFrame,
|
|
1574
|
+
task: TaskReplica | undefined,
|
|
1575
|
+
cursor?: ProjectEnvDeliveryCursor,
|
|
1576
|
+
) {
|
|
1577
|
+
const matchedTask =
|
|
1578
|
+
frame.runnerBootId === this.runnerBootId &&
|
|
1579
|
+
task?.runEpoch === frame.runEpoch
|
|
1580
|
+
? task
|
|
1581
|
+
: undefined;
|
|
1582
|
+
return {
|
|
1583
|
+
kind: "env.replica.v1.ack" as const,
|
|
1584
|
+
opId: frame.opId,
|
|
1585
|
+
taskId: frame.taskId,
|
|
1586
|
+
runEpoch: frame.runEpoch,
|
|
1587
|
+
runnerBootId: frame.runnerBootId,
|
|
1588
|
+
streamId: frame.streamId,
|
|
1589
|
+
attemptId: frame.attemptId,
|
|
1590
|
+
policyEpochs: [...this.policyEpochs],
|
|
1591
|
+
appliedThrough:
|
|
1592
|
+
cursor ?? this.#cursor(matchedTask, frame.streamId),
|
|
1593
|
+
};
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
#cursor(
|
|
1597
|
+
task: TaskReplica | undefined,
|
|
1598
|
+
streamId: string,
|
|
1599
|
+
): ProjectEnvDeliveryCursor {
|
|
1600
|
+
return {
|
|
1601
|
+
streamId,
|
|
1602
|
+
sequence:
|
|
1603
|
+
task?.streamId === streamId ? task.appliedSequence : 0,
|
|
1604
|
+
};
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
#reserveReplayEvidence(task: TaskReplica, evidence: unknown): number | null {
|
|
1608
|
+
const bytes = replayLedgerEntryBytes(evidence);
|
|
1609
|
+
if (
|
|
1610
|
+
task.replayLedgerEntries + 1 > this.#replayLedgerMaxTaskEntries ||
|
|
1611
|
+
task.replayLedgerBytes + bytes > this.#replayLedgerMaxTaskBytes ||
|
|
1612
|
+
this.#replayLedgerEntries + 1 > this.#replayLedgerMaxTotalEntries ||
|
|
1613
|
+
this.#replayLedgerBytes + bytes > this.#replayLedgerMaxTotalBytes
|
|
1614
|
+
) {
|
|
1615
|
+
return null;
|
|
1616
|
+
}
|
|
1617
|
+
task.replayLedgerEntries += 1;
|
|
1618
|
+
task.replayLedgerBytes += bytes;
|
|
1619
|
+
this.#replayLedgerEntries += 1;
|
|
1620
|
+
this.#replayLedgerBytes += bytes;
|
|
1621
|
+
return bytes;
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
#releaseReplayEvidence(task: TaskReplica, bytes: number): void {
|
|
1625
|
+
task.replayLedgerEntries -= 1;
|
|
1626
|
+
task.replayLedgerBytes -= bytes;
|
|
1627
|
+
this.#replayLedgerEntries -= 1;
|
|
1628
|
+
this.#replayLedgerBytes -= bytes;
|
|
1629
|
+
if (
|
|
1630
|
+
task.replayLedgerEntries < 0 ||
|
|
1631
|
+
task.replayLedgerBytes < 0 ||
|
|
1632
|
+
this.#replayLedgerEntries < 0 ||
|
|
1633
|
+
this.#replayLedgerBytes < 0
|
|
1634
|
+
) {
|
|
1635
|
+
throw new Error("task replica replay ledger accounting underflow");
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
#rememberRetiredStream(task: TaskReplica, streamId: string): boolean {
|
|
1640
|
+
if (task.retiredStreams.has(streamId)) return true;
|
|
1641
|
+
const evidence = ["retired_stream", streamId];
|
|
1642
|
+
if (this.#reserveReplayEvidence(task, evidence) === null) return false;
|
|
1643
|
+
task.retiredStreams.add(streamId);
|
|
1644
|
+
return true;
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
#rememberRetiredAttempt(task: TaskReplica, attemptId: string): boolean {
|
|
1648
|
+
if (task.retiredAttempts.has(attemptId)) return true;
|
|
1649
|
+
const evidence = ["retired_attempt", attemptId];
|
|
1650
|
+
if (this.#reserveReplayEvidence(task, evidence) === null) return false;
|
|
1651
|
+
task.retiredAttempts.add(attemptId);
|
|
1652
|
+
return true;
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
#rememberApplyReceipt(
|
|
1656
|
+
task: TaskReplica,
|
|
1657
|
+
sequence: number,
|
|
1658
|
+
receipt: ApplyReceipt,
|
|
1659
|
+
): boolean {
|
|
1660
|
+
if (task.applyReceipts.has(sequence)) return true;
|
|
1661
|
+
const evidence = ["apply", sequence, receipt];
|
|
1662
|
+
const bytes = this.#reserveReplayEvidence(task, evidence);
|
|
1663
|
+
if (bytes === null) return false;
|
|
1664
|
+
task.applyReceipts.set(sequence, receipt);
|
|
1665
|
+
task.applyReceiptBytes.set(sequence, bytes);
|
|
1666
|
+
return true;
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
#rememberCompletedAttempt(
|
|
1670
|
+
task: TaskReplica,
|
|
1671
|
+
attemptId: string,
|
|
1672
|
+
completed: CompletedAttempt,
|
|
1673
|
+
): boolean {
|
|
1674
|
+
if (task.completedAttempts.has(attemptId)) return true;
|
|
1675
|
+
const evidence = ["completed_attempt", attemptId, completed];
|
|
1676
|
+
if (this.#reserveReplayEvidence(task, evidence) === null) return false;
|
|
1677
|
+
task.completedAttempts.set(attemptId, completed);
|
|
1678
|
+
return true;
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
#clearApplyReceipts(task: TaskReplica): void {
|
|
1682
|
+
for (const bytes of task.applyReceiptBytes.values()) {
|
|
1683
|
+
this.#releaseReplayEvidence(task, bytes);
|
|
1684
|
+
}
|
|
1685
|
+
task.applyReceipts.clear();
|
|
1686
|
+
task.applyReceiptBytes.clear();
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
#clearReplayLedger(task: TaskReplica): void {
|
|
1690
|
+
this.#replayLedgerEntries -= task.replayLedgerEntries;
|
|
1691
|
+
this.#replayLedgerBytes -= task.replayLedgerBytes;
|
|
1692
|
+
if (this.#replayLedgerEntries < 0 || this.#replayLedgerBytes < 0) {
|
|
1693
|
+
throw new Error("task replica replay ledger accounting underflow");
|
|
1694
|
+
}
|
|
1695
|
+
task.retiredStreams.clear();
|
|
1696
|
+
task.retiredAttempts.clear();
|
|
1697
|
+
task.applyReceipts.clear();
|
|
1698
|
+
task.applyReceiptBytes.clear();
|
|
1699
|
+
task.completedAttempts.clear();
|
|
1700
|
+
task.replayLedgerEntries = 0;
|
|
1701
|
+
task.replayLedgerBytes = 0;
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
#exhaustReplayLedger(
|
|
1705
|
+
task: TaskReplica,
|
|
1706
|
+
frame: ProjectEnvTaskReplicaFrame,
|
|
1707
|
+
): ProjectEnvReplicaV1AckFrame {
|
|
1708
|
+
if (!task.restartRequired) {
|
|
1709
|
+
task.restartRequired = true;
|
|
1710
|
+
this.#restartRequiredRuns.set(task.taskId, task.runEpoch);
|
|
1711
|
+
task.generation += 1;
|
|
1712
|
+
task.gateOpen = false;
|
|
1713
|
+
task.reader = CLOSED_READER;
|
|
1714
|
+
scrubAttempt(task.attempt);
|
|
1715
|
+
task.attempt = null;
|
|
1716
|
+
scrubProjects(task.projects);
|
|
1717
|
+
for (const entry of task.inFlightEntries) scrubEntry(entry);
|
|
1718
|
+
task.inFlightEntries.clear();
|
|
1719
|
+
this.#clearReplayLedger(task);
|
|
1720
|
+
for (const waiter of [...task.waiters]) {
|
|
1721
|
+
waiter.reject(new Error("task replica requires a runner restart"));
|
|
1722
|
+
}
|
|
1723
|
+
this.#changed(task.taskId);
|
|
1724
|
+
}
|
|
1725
|
+
return this.#errorAck(frame, task, REPLAY_LEDGER_EXHAUSTED);
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
#resolveReady(task: TaskReplica): void {
|
|
1729
|
+
for (const waiter of [...task.waiters]) waiter.resolve();
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
#destroyTask(task: TaskReplica, reason: string): void {
|
|
1733
|
+
task.generation += 1;
|
|
1734
|
+
task.gateOpen = false;
|
|
1735
|
+
task.reader = CLOSED_READER;
|
|
1736
|
+
scrubAttempt(task.attempt);
|
|
1737
|
+
task.attempt = null;
|
|
1738
|
+
scrubProjects(task.projects);
|
|
1739
|
+
this.#clearReplayLedger(task);
|
|
1740
|
+
for (const entry of task.inFlightEntries) scrubEntry(entry);
|
|
1741
|
+
task.inFlightEntries.clear();
|
|
1742
|
+
for (const waiter of [...task.waiters]) waiter.reject(new Error(reason));
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
#changed(taskId: string): void {
|
|
1746
|
+
try {
|
|
1747
|
+
this.#onChange(taskId);
|
|
1748
|
+
} catch {
|
|
1749
|
+
// Replica authority cannot be rolled back by a cache notification.
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
}
|