@yielded/agent-storage-memory 0.1.0-beta.167 → 0.1.0-beta.169

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.
@@ -19,9 +19,35 @@ import {
19
19
  validateMessageDelivery,
20
20
  } from "@yielded/agent/message-delivery";
21
21
  import { ScheduleInstant } from "@yielded/agent/schedule";
22
- import { Effect, Layer, Ref, Schema, Semaphore } from "effect";
22
+ import { validateCanonicalSettlement } from "@yielded/agent/settlement-publisher";
23
+ import {
24
+ Settlement,
25
+ settlementFailureFromRecord,
26
+ submissionSettlementRecordId,
27
+ submissionSettlementId,
28
+ } from "@yielded/agent/submission-ledger";
29
+ import { PreparedInput } from "@yielded/agent/subscription";
30
+ import {
31
+ ThreadDeliveryImportReader,
32
+ ThreadImportRejected,
33
+ invalidThreadArchive,
34
+ } from "@yielded/agent/thread-import";
35
+ import { ThreadStoreError, makeTransferFactCheck } from "@yielded/agent/thread-store";
36
+ import { workId, type ThreadWorkEntry } from "@yielded/agent/thread-work";
37
+ import { Effect, Layer, MutableRef, Ref, Schema } from "effect";
38
+
39
+ import { MemoryThreadStoreKernel } from "./internal/MemoryThreadStoreKernel.ts";
40
+ import { boundedWorkSelection } from "./internal/WorkSelection.ts";
23
41
 
24
42
  const codec = Schema.fromJsonString(MessageDeliveryRecord);
43
+ const deliveryFitsTransfer = makeTransferFactCheck(MessageDeliveryRecord);
44
+
45
+ const checkTransfer = (record: MessageDeliveryRecord) =>
46
+ deliveryFitsTransfer(record.key.ownerThreadId, { ...record, leaseUntilMillis: null })
47
+ ? Effect.void
48
+ : Effect.fail(
49
+ MessageDeliveryError.make({ reason: "capacity", operation: "transfer-page-bytes" }),
50
+ );
25
51
 
26
52
  const decode = (text: string) =>
27
53
  Schema.decodeEffect(codec)(text).pipe(
@@ -34,19 +60,80 @@ const Scan = Schema.Struct({
34
60
  ownerThreadId: Schema.optionalKey(ThreadId),
35
61
  });
36
62
 
63
+ const position = (keys: ReadonlyArray<string>, key: string) => {
64
+ let low = 0;
65
+ let high = keys.length;
66
+
67
+ while (low < high) {
68
+ const mid = Math.floor((low + high) / 2);
69
+
70
+ if (keys[mid] < key) low = mid + 1;
71
+ else high = mid;
72
+ }
73
+
74
+ return low;
75
+ };
76
+
77
+ const retainKey = (keys: Array<string>, key: string) => {
78
+ const at = position(keys, key);
79
+
80
+ if (keys[at] !== key) keys.splice(at, 0, key);
81
+ };
82
+
83
+ const removeKey = (keys: Array<string>, key: string) => {
84
+ const at = position(keys, key);
85
+
86
+ if (keys[at] === key) keys.splice(at, 1);
87
+ };
88
+
89
+ const backend = () => ({
90
+ records: new Map<string, string>(),
91
+ work: new Map<string, ThreadWorkEntry>(),
92
+ ordered: new Map<string, Array<string>>(),
93
+ pending: new Map<ThreadId, Set<MessageDeliveryKey["messageId"]>>(),
94
+ counts: new Map<ThreadId, { retained: number; ordinary: number; update: number; peer: number }>(),
95
+ metadata: new Map<
96
+ string,
97
+ {
98
+ update: boolean;
99
+ pending: boolean;
100
+ peer: boolean;
101
+ deadlineKey?: string;
102
+ importVisit?: "visiting" | "verified";
103
+ }
104
+ >(),
105
+ deadlines: new Map<string, { readonly key: MessageDeliveryKey; readonly deadline: number }>(),
106
+ deadlineKeys: new Array<string>(),
107
+ ownerDeadlineKeys: new Map<ThreadId, Array<string>>(),
108
+ });
109
+
110
+ const ownerKey = (id: ThreadId) => JSON.stringify([id]);
111
+
112
+ const keysFor = (current: ReturnType<typeof backend>, key: string) => {
113
+ let keys = current.ordered.get(key);
114
+
115
+ if (keys === undefined) current.ordered.set(key, (keys = []));
116
+
117
+ return keys;
118
+ };
119
+
37
120
  export interface MemoryMessageDeliveryStoreOptions {
38
121
  /** UTF-8 bound on the complete stored record, including a processed Settlement. */
39
122
  readonly maxStoredValueBytes?: number;
40
123
  }
41
124
 
42
- /** Reference adapter. All retained rows are bounded; completed deduplication evidence is never evicted. */
125
+ /**
126
+ * Pair with MemoryThreadStoreLive so export/import sees every retained delivery obligation.
127
+ * Each retained value and live partition is bounded; completed deduplication evidence is never evicted.
128
+ */
43
129
  export const memoryMessageDeliveryStoreLayer = (
44
130
  limits: MessageDeliveryStoreLimits = defaultMessageDeliveryStoreLimits,
45
131
  options: MemoryMessageDeliveryStoreOptions = {},
46
- ): Layer.Layer<MessageDeliveryStore, MessageDeliveryError> =>
132
+ ): Layer.Layer<MessageDeliveryStore, MessageDeliveryError, MemoryThreadStoreKernel> =>
47
133
  Layer.effect(
48
134
  MessageDeliveryStore,
49
135
  Effect.gen(function* () {
136
+ const journal = yield* MemoryThreadStoreKernel;
50
137
  const config = yield* validateMessageDelivery(MessageDeliveryStoreLimits, limits, "limits");
51
138
 
52
139
  const maxStoredValueBytes = yield* validateMessageDelivery(
@@ -55,62 +142,187 @@ export const memoryMessageDeliveryStoreLayer = (
55
142
  "stored-value-limit",
56
143
  );
57
144
 
58
- const state = yield* Ref.make({
59
- records: new Map<string, string>(),
60
- pending: new Map<ThreadId, ReadonlySet<MessageDeliveryKey["messageId"]>>(),
61
- workers: new Map<string, ReadonlySet<MessageDeliveryKey["messageId"]>>(),
62
- });
145
+ const state = yield* Ref.make(backend());
63
146
 
64
- const commit = (record: MessageDeliveryRecord, encoded: string) =>
65
- Ref.update(state, (current) => {
66
- const key = messageDeliveryKeyString(record.key);
67
- const keys = new Set(current.pending.get(record.key.ownerThreadId));
147
+ yield* journal.registerDeliveryLookup((threadId, messageId) =>
148
+ MutableRef.get(state.ref).records.has(
149
+ messageDeliveryKeyString({ ownerThreadId: threadId, messageId }),
150
+ ),
151
+ );
152
+ yield* journal.registerWorkOwner("deliveries", {
153
+ threads: (request) =>
154
+ Ref.get(state).pipe(
155
+ Effect.map((current) => {
156
+ const candidates = boundedWorkSelection<ThreadId>(
157
+ request.limit,
158
+ (id) => id,
159
+ request.afterThreadId,
160
+ );
68
161
 
69
- if (messageDeliveryUsesCapacity(record)) keys.add(record.key.messageId);
70
- else keys.delete(record.key.messageId);
71
- const pending = new Map(current.pending);
162
+ for (const threadId of current.pending.keys()) candidates.add(threadId);
163
+ const threadIds = candidates.values;
72
164
 
73
- if (keys.size === 0) pending.delete(record.key.ownerThreadId);
74
- else pending.set(record.key.ownerThreadId, keys);
165
+ const afterThreadId =
166
+ threadIds.length === request.limit ? threadIds.at(-1) : undefined;
75
167
 
76
- const workers = new Map(current.workers);
77
- const admission = record.envelope.workerAdmission;
168
+ return {
169
+ threadIds,
170
+ ...(afterThreadId === undefined ? {} : { afterThreadId }),
171
+ };
172
+ }),
173
+ ),
174
+ page: (threadId, after, limit) =>
175
+ Effect.gen(function* () {
176
+ const current = yield* Ref.get(state);
177
+
178
+ const candidates = boundedWorkSelection<MessageDeliveryKey["messageId"]>(
179
+ limit + 1,
180
+ (id) => id,
181
+ after,
182
+ );
78
183
 
79
- if (admission !== undefined) {
80
- const worker = admission.origin.worker;
184
+ for (const id of current.pending.get(threadId) ?? []) candidates.add(id);
185
+ const ids = candidates.values;
81
186
 
82
- const startKey = JSON.stringify([
83
- record.key.ownerThreadId,
84
- worker.delegationId,
85
- worker.targetAgentId,
86
- ]);
187
+ const entries: Array<ThreadWorkEntry> = [];
87
188
 
88
- if (record.key.messageId === admission.origin.firstMessageId)
89
- workers.set(
90
- startKey,
91
- new Set([...(workers.get(startKey) ?? []), record.key.messageId]),
92
- );
93
- const pendingKey = JSON.stringify([record.key.ownerThreadId, worker.threadId]);
94
- const inputs = new Set(workers.get(pendingKey));
95
-
96
- if (
97
- (record.status === "pending" || record.status === "parked") &&
98
- record.receipt === null
99
- )
100
- inputs.add(record.key.messageId);
101
- else inputs.delete(record.key.messageId);
102
- workers.set(pendingKey, inputs);
103
- }
189
+ for (const messageId of ids.slice(0, limit)) {
190
+ const key = messageDeliveryKeyString({ ownerThreadId: threadId, messageId });
191
+ const entry = current.work.get(key);
192
+
193
+ if (entry === undefined || !current.records.has(key))
194
+ return yield* ThreadStoreError.make({
195
+ operation: "delivery work",
196
+ message: "Pending delivery index is incomplete or corrupt",
197
+ });
198
+ entries.push(entry);
199
+ }
200
+ const next = ids.length > limit ? ids[limit - 1] : undefined;
201
+
202
+ return { entries, ...(next === undefined ? {} : { after: next }) };
203
+ }),
204
+ });
205
+
206
+ const commitInto = (
207
+ current: ReturnType<typeof backend>,
208
+ record: MessageDeliveryRecord,
209
+ encoded: string,
210
+ publish: boolean,
211
+ ) => {
212
+ const key = messageDeliveryKeyString(record.key);
213
+ const owner = record.key.ownerThreadId;
214
+ const old = current.metadata.get(key);
215
+ const update = isWorkerUpdateDelivery(record);
216
+ const pending = messageDeliveryUsesCapacity(record);
217
+
218
+ const peer =
219
+ record.envelope.messageAdmission !== undefined &&
220
+ "peerName" in record.envelope.messageAdmission;
221
+
222
+ let counts = current.counts.get(owner);
223
+
224
+ if (counts === undefined)
225
+ current.counts.set(owner, (counts = { retained: 0, ordinary: 0, update: 0, peer: 0 }));
226
+ if (old === undefined) counts.retained++;
227
+ if (old?.pending) {
228
+ if (old.update) counts.update--;
229
+ else counts.ordinary--;
230
+ if (old.peer) counts.peer--;
231
+ }
232
+ if (pending) {
233
+ if (update) counts.update++;
234
+ else counts.ordinary++;
235
+ if (peer) counts.peer++;
236
+ }
237
+ let owned = current.pending.get(owner);
238
+
239
+ if (owned === undefined) current.pending.set(owner, (owned = new Set()));
240
+ if (pending) owned.add(record.key.messageId);
241
+ else owned.delete(record.key.messageId);
242
+ if (owned.size === 0) current.pending.delete(owner);
243
+ retainKey(keysFor(current, ownerKey(owner)), record.key.messageId);
244
+ const admission = record.envelope.workerAdmission;
245
+
246
+ if (admission !== undefined) {
247
+ const worker = admission.origin.worker;
248
+
249
+ if (record.key.messageId === admission.origin.firstMessageId)
250
+ retainKey(
251
+ keysFor(current, JSON.stringify([owner, worker.delegationId, worker.targetAgentId])),
252
+ record.key.messageId,
253
+ );
254
+ const inputs = keysFor(current, JSON.stringify([owner, worker.threadId]));
255
+
256
+ if (
257
+ (record.status === "pending" || record.status === "parked") &&
258
+ record.receipt === null
259
+ )
260
+ retainKey(inputs, record.key.messageId);
261
+ else removeKey(inputs, record.key.messageId);
262
+ }
263
+ const deadline = messageDeliveryDeadline(record);
264
+ let ownerDeadlines = current.ownerDeadlineKeys.get(owner);
265
+
266
+ if (ownerDeadlines === undefined)
267
+ current.ownerDeadlineKeys.set(owner, (ownerDeadlines = []));
268
+ if (old?.deadlineKey !== undefined) {
269
+ current.deadlines.delete(old.deadlineKey);
270
+ removeKey(current.deadlineKeys, old.deadlineKey);
271
+ removeKey(ownerDeadlines, old.deadlineKey);
272
+ }
273
+
274
+ const deadlineKey =
275
+ deadline === null ? undefined : `${String(deadline).padStart(16, "0")}:${key}`;
104
276
 
105
- return { records: new Map(current.records).set(key, encoded), pending, workers };
277
+ if (deadlineKey !== undefined && deadline !== null) {
278
+ current.deadlines.set(deadlineKey, { key: record.key, deadline });
279
+ retainKey(current.deadlineKeys, deadlineKey);
280
+ retainKey(ownerDeadlines, deadlineKey);
281
+ }
282
+ current.metadata.set(key, {
283
+ update,
284
+ pending,
285
+ peer,
286
+ ...(deadlineKey === undefined ? {} : { deadlineKey }),
106
287
  });
288
+ if (pending)
289
+ current.work.set(key, {
290
+ id: workId("delivery", record.key.messageId),
291
+ owner: { _tag: "Delivery", messageId: record.key.messageId },
292
+ partition: update ? "update" : "ordinary",
293
+ stateReference: {
294
+ _tag: "Delivery",
295
+ messageId: record.key.messageId,
296
+ version: record.version,
297
+ },
298
+ state: record.status === "pending" ? "ready" : "waiting",
299
+ ...(record.status === "parked"
300
+ ? { wait: "parked" as const }
301
+ : record.receipt !== null
302
+ ? { wait: "destination" as const }
303
+ : {}),
304
+ ...(deadline === null ? {} : { notBeforeMillis: deadline }),
305
+ ...(record.receipt === null
306
+ ? {}
307
+ : {
308
+ receiptId: record.receipt.receiptId,
309
+ queueSequence: record.receipt.queueSequence,
310
+ }),
311
+ });
312
+ else current.work.delete(key);
313
+ current.records.set(key, encoded);
314
+ if (publish) {
315
+ journal.initializeWork(owner);
316
+ journal.retainDelivery(owner, record.key.messageId);
317
+ }
318
+ };
319
+
320
+ const commit = (record: MessageDeliveryRecord, encoded: string) =>
321
+ Effect.sync(() => commitInto(MutableRef.get(state.ref), record, encoded, true));
107
322
 
108
- const lock = yield* Semaphore.make(1);
109
323
  const failpoint = yield* MessageDeliveryFailpoint;
110
324
 
111
- const encode = Effect.fn("MemoryMessageDeliveryStore.encode")(function* (
112
- record: MessageDeliveryRecord,
113
- ) {
325
+ const encode = Effect.fnUntraced(function* (record: MessageDeliveryRecord) {
114
326
  const text = yield* Schema.encodeEffect(codec)(record).pipe(
115
327
  Effect.mapError(() =>
116
328
  MessageDeliveryError.make({ reason: "corrupt", operation: "encode" }),
@@ -127,133 +339,454 @@ export const memoryMessageDeliveryStoreLayer = (
127
339
  return text;
128
340
  });
129
341
 
130
- const all = Effect.fn("MemoryMessageDeliveryStore.all")(function* () {
131
- return yield* Effect.forEach((yield* Ref.get(state)).records.values(), decode);
342
+ const insert: MessageDeliveryStore["Service"]["insert"] = Effect.fnUntraced(
343
+ function* (record) {
344
+ const encoded = yield* encode(record);
345
+ const input = yield* decode(encoded);
346
+
347
+ if (
348
+ input.version !== 1 ||
349
+ input.status !== "pending" ||
350
+ input.leaseUntilMillis !== null ||
351
+ input.retry.attempts !== 0 ||
352
+ input.retry.generation !== 0 ||
353
+ input.retry.automaticAttempts !== 0 ||
354
+ input.retry.nextAttemptAtMillis !== input.createdAtMillis ||
355
+ input.retry.lastAttemptAtMillis !== null ||
356
+ input.retry.lastFailure !== null ||
357
+ input.deadlineAtMillis !== input.initialDeadlineAtMillis
358
+ ) {
359
+ return yield* MessageDeliveryError.make({
360
+ reason: "validation",
361
+ operation: "insert-state",
362
+ });
363
+ }
364
+ if (
365
+ new TextEncoder().encode(JSON.stringify(input.envelope)).byteLength >
366
+ config.maxEnvelopeBytes
367
+ ) {
368
+ return yield* MessageDeliveryError.make({
369
+ reason: "capacity",
370
+ operation: "envelope-bytes",
371
+ });
372
+ }
373
+ yield* failpoint.hit("message-delivery:insert:before");
374
+
375
+ const inserted = yield* journal.withMutation(
376
+ Effect.uninterruptible(
377
+ Effect.gen(function* () {
378
+ yield* journal.checkThreadCapacity(input.key.ownerThreadId).pipe(
379
+ Effect.mapError(() =>
380
+ MessageDeliveryError.make({
381
+ reason: "capacity",
382
+ operation: "Thread capacity",
383
+ }),
384
+ ),
385
+ );
386
+ const { records } = yield* Ref.get(state);
387
+ const key = messageDeliveryKeyString(input.key);
388
+ const existingText = records.get(key);
389
+
390
+ if (existingText !== undefined) {
391
+ const existing = yield* decode(existingText);
392
+
393
+ if (!sameMessageDeliveryIdentity(existing, input))
394
+ return yield* MessageDeliveryError.make({
395
+ reason: "conflict",
396
+ operation: "insert",
397
+ });
398
+
399
+ journal.retainDelivery(existing.key.ownerThreadId, existing.key.messageId);
400
+
401
+ return existing;
402
+ }
403
+
404
+ yield* checkTransfer(input);
405
+ const update = isWorkerUpdateDelivery(input);
406
+ const counts = MutableRef.get(state.ref).counts.get(input.key.ownerThreadId);
407
+ const capacity = messageDeliveryCapacity(config, update);
408
+
409
+ if ((update ? (counts?.update ?? 0) : (counts?.ordinary ?? 0)) >= capacity.pending)
410
+ return yield* MessageDeliveryError.make({
411
+ reason: "capacity",
412
+ operation: "insert",
413
+ });
414
+ yield* commit(input, encoded);
415
+
416
+ return input;
417
+ }),
418
+ ),
419
+ );
420
+
421
+ yield* failpoint.hit("message-delivery:insert:after");
422
+
423
+ return yield* decode(yield* encode(inserted));
424
+ },
425
+ );
426
+
427
+ const get: MessageDeliveryStore["Service"]["get"] = Effect.fnUntraced(function* (key) {
428
+ const input = yield* validateMessageDelivery(MessageDeliveryKey, key, "get");
429
+ const text = (yield* Ref.get(state)).records.get(messageDeliveryKeyString(input));
430
+
431
+ return text === undefined ? null : yield* decode(text);
132
432
  });
133
433
 
134
- const insert: MessageDeliveryStore["Service"]["insert"] = Effect.fn(
135
- "MemoryMessageDeliveryStore.insert",
136
- )(function* (record) {
137
- const encoded = yield* encode(record);
138
- const input = yield* decode(encoded);
139
-
140
- if (
141
- input.version !== 1 ||
142
- input.status !== "pending" ||
143
- input.leaseUntilMillis !== null ||
144
- input.retry.attempts !== 0 ||
145
- input.retry.generation !== 0 ||
146
- input.retry.automaticAttempts !== 0 ||
147
- input.retry.nextAttemptAtMillis !== input.createdAtMillis ||
148
- input.retry.lastAttemptAtMillis !== null ||
149
- input.retry.lastFailure !== null ||
150
- input.deadlineAtMillis !== input.initialDeadlineAtMillis
151
- ) {
152
- return yield* MessageDeliveryError.make({
153
- reason: "validation",
154
- operation: "insert-state",
155
- });
156
- }
157
- if (
158
- new TextEncoder().encode(JSON.stringify(input.envelope)).byteLength >
159
- config.maxEnvelopeBytes
160
- ) {
161
- return yield* MessageDeliveryError.make({
162
- reason: "capacity",
163
- operation: "envelope-bytes",
164
- });
165
- }
166
- yield* failpoint.hit("message-delivery:insert:before");
434
+ const change: MessageDeliveryStore["Service"]["change"] = Effect.fnUntraced(
435
+ function* (key, change) {
436
+ const input = yield* validateMessageDelivery(MessageDeliveryChange, change, "change");
437
+ const decodedKey = yield* validateMessageDelivery(MessageDeliveryKey, key, "change");
438
+ const point = `message-delivery:${input._tag.toLowerCase()}`;
167
439
 
168
- const inserted = yield* lock.withPermit(
169
- Effect.uninterruptible(
170
- Effect.gen(function* () {
171
- const { records } = yield* Ref.get(state);
172
- const key = messageDeliveryKeyString(input.key);
173
- const existingText = records.get(key);
440
+ yield* failpoint.hit(`${point}:before`);
174
441
 
175
- if (existingText !== undefined) {
176
- const existing = yield* decode(existingText);
442
+ const changed = yield* journal.withMutation(
443
+ Effect.uninterruptible(
444
+ Effect.gen(function* () {
445
+ const existing = yield* get(decodedKey);
177
446
 
178
- if (!sameMessageDeliveryIdentity(existing, input))
447
+ if (existing === null)
179
448
  return yield* MessageDeliveryError.make({
180
- reason: "conflict",
181
- operation: "insert",
449
+ reason: "not-found",
450
+ operation: "change",
182
451
  });
452
+ const next = yield* Effect.fromResult(applyMessageDeliveryChange(existing, input));
183
453
 
184
- return existing;
185
- }
454
+ if (next === existing) return existing;
455
+ yield* checkTransfer(next);
456
+ const encoded = yield* encode(next);
186
457
 
187
- const owned = (yield* all()).filter(
188
- (record) =>
189
- record.key.ownerThreadId === input.key.ownerThreadId &&
190
- isWorkerUpdateDelivery(record) === isWorkerUpdateDelivery(input),
191
- );
458
+ yield* commit(next, encoded);
192
459
 
193
- const capacity = messageDeliveryCapacity(config, isWorkerUpdateDelivery(input));
460
+ return next;
461
+ }),
462
+ ),
463
+ );
194
464
 
195
- if (
196
- owned.length >= capacity.retained ||
197
- owned.filter(messageDeliveryUsesCapacity).length >= capacity.pending
198
- ) {
199
- return yield* MessageDeliveryError.make({
200
- reason: "capacity",
201
- operation: "insert",
202
- });
203
- }
204
- yield* commit(input, encoded);
465
+ yield* failpoint.hit(`${point}:after`);
205
466
 
206
- return input;
467
+ return yield* decode(yield* encode(changed));
468
+ },
469
+ );
470
+
471
+ const transferDecode = (text: string) =>
472
+ decode(text).pipe(
473
+ Effect.mapError(() =>
474
+ ThreadStoreError.make({
475
+ operation: "delivery transfer",
476
+ message: "Invalid retained delivery wire",
207
477
  }),
208
478
  ),
209
479
  );
210
480
 
211
- yield* failpoint.hit("message-delivery:insert:after");
481
+ yield* journal.registerDeliveryTransfer({
482
+ record: (threadId, messageId) => {
483
+ const text = MutableRef.get(state.ref).records.get(
484
+ messageDeliveryKeyString({ ownerThreadId: threadId, messageId }),
485
+ );
212
486
 
213
- return yield* decode(yield* encode(inserted));
214
- });
215
-
216
- const get: MessageDeliveryStore["Service"]["get"] = Effect.fn(
217
- "MemoryMessageDeliveryStore.get",
218
- )(function* (key) {
219
- const input = yield* validateMessageDelivery(MessageDeliveryKey, key, "get");
220
- const text = (yield* Ref.get(state)).records.get(messageDeliveryKeyString(input));
221
-
222
- return text === undefined ? null : yield* decode(text);
223
- });
487
+ return text === undefined ? Effect.succeed(undefined) : transferDecode(text);
488
+ },
489
+ snapshot: (threadId) => {
490
+ const counts = MutableRef.get(state.ref).counts.get(threadId);
224
491
 
225
- const change: MessageDeliveryStore["Service"]["change"] = Effect.fn(
226
- "MemoryMessageDeliveryStore.change",
227
- )(function* (key, change) {
228
- const input = yield* validateMessageDelivery(MessageDeliveryChange, change, "change");
229
- const decodedKey = yield* validateMessageDelivery(MessageDeliveryKey, key, "change");
230
- const point = `message-delivery:${input._tag.toLowerCase()}`;
231
-
232
- yield* failpoint.hit(`${point}:before`);
492
+ return {
493
+ idle: (MutableRef.get(state.ref).pending.get(threadId)?.size ?? 0) === 0,
494
+ deliveries: counts?.retained ?? 0,
495
+ };
496
+ },
497
+ pendingPeerCount: (threadId, limit) =>
498
+ Math.min(MutableRef.get(state.ref).counts.get(threadId)?.peer ?? 0, limit),
499
+ facts: Effect.fnUntraced(function* (threadId, after) {
500
+ const current = MutableRef.get(state.ref);
501
+ const keys = current.ordered.get(ownerKey(threadId)) ?? [];
502
+
503
+ const at =
504
+ after === undefined
505
+ ? 0
506
+ : position(keys, after) + Number(keys[position(keys, after)] === after);
507
+
508
+ const messageId = keys[at];
509
+
510
+ const record =
511
+ messageId === undefined
512
+ ? undefined
513
+ : yield* transferDecode(
514
+ current.records.get(
515
+ messageDeliveryKeyString({
516
+ ownerThreadId: threadId,
517
+ messageId: Schema.decodeSync(MessageDeliveryKey.fields.messageId)(messageId),
518
+ }),
519
+ ) ?? "",
520
+ );
233
521
 
234
- const changed = yield* lock.withPermit(
235
- Effect.uninterruptible(
236
- Effect.gen(function* () {
237
- const existing = yield* get(decodedKey);
522
+ return {
523
+ facts: {
524
+ admissions: [],
525
+ commands: { aborts: [], approvals: [], resolutions: [] },
526
+ deliveries:
527
+ record === undefined
528
+ ? []
529
+ : [MessageDeliveryRecord.make({ ...record, leaseUntilMillis: null })],
530
+ },
531
+ after: messageId ?? after ?? "",
532
+ };
533
+ }),
534
+ startImport: Effect.fnUntraced(function* (threadId) {
535
+ const current = MutableRef.get(state.ref);
238
536
 
239
- if (existing === null)
240
- return yield* MessageDeliveryError.make({
241
- reason: "not-found",
242
- operation: "change",
243
- });
244
- const next = yield* Effect.fromResult(applyMessageDeliveryChange(existing, input));
245
- const encoded = yield* encode(next);
537
+ if ((current.counts.get(threadId)?.retained ?? 0) > 0)
538
+ return yield* ThreadImportRejected.make({
539
+ threadId,
540
+ reason: "target-not-empty",
541
+ message: "Destination Thread owns retained deliveries",
542
+ });
543
+ const staged = backend();
246
544
 
247
- yield* commit(next, encoded);
545
+ return {
546
+ record: (messageId) => {
547
+ const text = staged.records.get(
548
+ messageDeliveryKeyString({ ownerThreadId: threadId, messageId }),
549
+ );
248
550
 
249
- return next;
551
+ return text === undefined ? Effect.succeed(undefined) : transferDecode(text);
552
+ },
553
+ has: (messageId) =>
554
+ staged.records.has(messageDeliveryKeyString({ ownerThreadId: threadId, messageId })),
555
+ stage: Effect.fnUntraced(function* (page) {
556
+ for (const record of page.archive.deliveries) {
557
+ const key = messageDeliveryKeyString(record.key);
558
+
559
+ if (staged.records.has(key) || current.records.has(key))
560
+ return yield* invalidThreadArchive("Duplicate retained delivery", threadId);
561
+
562
+ const text = yield* encode(record).pipe(
563
+ Effect.mapError(() =>
564
+ ThreadImportRejected.make({
565
+ threadId,
566
+ reason: "unsupported-capacity",
567
+ message: "Delivery wire exceeds destination stored-value bound",
568
+ }),
569
+ ),
570
+ );
571
+
572
+ if (
573
+ new TextEncoder().encode(
574
+ JSON.stringify(
575
+ yield* Schema.encodeEffect(PreparedInput)(record.envelope).pipe(
576
+ Effect.mapError(() =>
577
+ invalidThreadArchive("Invalid delivery envelope", threadId),
578
+ ),
579
+ ),
580
+ ),
581
+ ).byteLength > config.maxEnvelopeBytes
582
+ )
583
+ return yield* ThreadImportRejected.make({
584
+ threadId,
585
+ reason: "unsupported-capacity",
586
+ message: "Delivery envelope exceeds destination bound",
587
+ });
588
+ const counts = staged.counts.get(threadId);
589
+
590
+ if (
591
+ messageDeliveryUsesCapacity(record) &&
592
+ (isWorkerUpdateDelivery(record)
593
+ ? (counts?.update ?? 0)
594
+ : (counts?.ordinary ?? 0)) >=
595
+ messageDeliveryCapacity(config, isWorkerUpdateDelivery(record)).pending
596
+ )
597
+ return yield* ThreadImportRejected.make({
598
+ threadId,
599
+ reason: "unsupported-capacity",
600
+ message: "Pending delivery capacity exceeded",
601
+ });
602
+ commitInto(staged, record, text, false);
603
+ }
250
604
  }),
251
- ),
252
- );
605
+ prepareCommit: Effect.fnUntraced(function* () {
606
+ const reader = yield* ThreadDeliveryImportReader;
607
+
608
+ const read = (messageId: MessageDeliveryKey["messageId"]) => {
609
+ const text = staged.records.get(
610
+ messageDeliveryKeyString({ ownerThreadId: threadId, messageId }),
611
+ );
612
+
613
+ return text === undefined
614
+ ? Effect.fail(invalidThreadArchive("Missing delivery predecessor", threadId))
615
+ : transferDecode(text);
616
+ };
617
+
618
+ const visit = (messageId: MessageDeliveryKey["messageId"]) => {
619
+ const metadata = staged.metadata.get(
620
+ messageDeliveryKeyString({ ownerThreadId: threadId, messageId }),
621
+ );
622
+
623
+ return metadata === undefined
624
+ ? Effect.fail(
625
+ invalidThreadArchive("Missing delivery predecessor metadata", threadId),
626
+ )
627
+ : Effect.succeed(metadata);
628
+ };
629
+
630
+ const verifyPredecessors = Effect.fnUntraced(function* (
631
+ start: MessageDeliveryKey["messageId"],
632
+ ) {
633
+ let cursor: MessageDeliveryKey["messageId"] | undefined = start;
634
+
635
+ while (cursor !== undefined) {
636
+ const metadata = yield* visit(cursor);
637
+
638
+ if (metadata.importVisit === "verified") break;
639
+ if (metadata.importVisit === "visiting")
640
+ return yield* invalidThreadArchive("Delivery predecessor cycle", threadId);
641
+ metadata.importVisit = "visiting";
642
+ const record: MessageDeliveryRecord = yield* read(cursor);
643
+
644
+ if (
645
+ record.predecessor !== undefined &&
646
+ (yield* read(record.predecessor)).createdAtMillis > record.createdAtMillis
647
+ )
648
+ return yield* invalidThreadArchive(
649
+ "Delivery predecessor has a later boundary",
650
+ threadId,
651
+ );
652
+ cursor = record.predecessor;
653
+ }
654
+ cursor = start;
655
+ while (cursor !== undefined) {
656
+ const metadata = yield* visit(cursor);
657
+
658
+ if (metadata.importVisit === "verified") break;
659
+ metadata.importVisit = "verified";
660
+ cursor = (yield* read(cursor)).predecessor;
661
+ }
662
+ });
663
+
664
+ for (const text of staged.records.values()) {
665
+ const d = yield* transferDecode(text);
666
+
667
+ yield* verifyPredecessors(d.key.messageId);
668
+ if (d.receipt !== null) {
669
+ if (
670
+ d.settlement !== null &&
671
+ d.settlement.settlementId !== submissionSettlementId(d.receipt.submissionId)
672
+ )
673
+ return yield* invalidThreadArchive(
674
+ "Delivery settlement identity is not canonical",
675
+ threadId,
676
+ );
677
+ const foreign = d.receipt.threadId !== threadId;
678
+
679
+ if (foreign && d.status !== "processed")
680
+ return yield* ThreadImportRejected.make({
681
+ threadId,
682
+ reason: "unsupported-obligations",
683
+ message: "Live foreign delivery receipt cannot be restored",
684
+ });
685
+
686
+ const destination = yield* reader.admission(
687
+ d.receipt.threadId,
688
+ d.receipt.submissionId,
689
+ );
690
+
691
+ // A closed foreign delivery retains frozen evidence, not the receiver's execution authority.
692
+ if (destination === undefined) {
693
+ if (foreign && d.status === "processed") continue;
694
+
695
+ return yield* invalidThreadArchive(
696
+ "Destination delivery receipt evidence is missing",
697
+ threadId,
698
+ );
699
+ }
700
+
701
+ const expected = PreparedInput.make({
702
+ schemaVersion: 1,
703
+ authorization: d.envelope.authorization,
704
+ threadId: destination.threadId,
705
+ deliveryPrincipal: destination.principal,
706
+ admissionKey: destination.idempotencyKey,
707
+ agentId: destination.agentId,
708
+ definitions: destination.agentDigests,
709
+ input: destination.inputPayload,
710
+ inputDigest: destination.inputDigest,
711
+ ...(destination.admissionGroup === undefined
712
+ ? {}
713
+ : { admissionGroup: destination.admissionGroup }),
714
+ ...(destination.admissionFence === undefined
715
+ ? {}
716
+ : { admissionFence: destination.admissionFence }),
717
+ ...(destination.workerAdmission === undefined
718
+ ? {}
719
+ : { workerAdmission: destination.workerAdmission }),
720
+ ...(destination.messageAdmission === undefined
721
+ ? {}
722
+ : { messageAdmission: destination.messageAdmission }),
723
+ });
253
724
 
254
- yield* failpoint.hit(`${point}:after`);
725
+ if (
726
+ d.receipt.receiptId !== destination.receiptId ||
727
+ d.receipt.queueSequence !== destination.queueSequence ||
728
+ !Schema.toEquivalence(PreparedInput)(d.envelope, expected)
729
+ )
730
+ return yield* invalidThreadArchive(
731
+ "Delivery envelope differs from destination admission",
732
+ threadId,
733
+ );
734
+ if (d.settlement !== null) {
735
+ const record = yield* reader.record(
736
+ destination.threadId,
737
+ submissionSettlementRecordId(destination.submissionId),
738
+ );
739
+
740
+ if (record === undefined) {
741
+ if (foreign) continue;
742
+
743
+ return yield* invalidThreadArchive(
744
+ "Processed delivery has no destination settlement",
745
+ threadId,
746
+ );
747
+ }
748
+
749
+ const settled = yield* validateCanonicalSettlement(record, destination).pipe(
750
+ Effect.mapError(() =>
751
+ invalidThreadArchive("Invalid destination settlement", threadId),
752
+ ),
753
+ );
754
+
755
+ const failure = settlementFailureFromRecord(record);
756
+
757
+ const expected = Settlement.make({
758
+ submissionId: settled.submissionId,
759
+ receiptId: settled.receiptId,
760
+ settlementId: settled.settlementId,
761
+ outcome: settled.outcome,
762
+ settledAt: d.settlement.settledAt,
763
+ ...(failure === undefined ? {} : { failure }),
764
+ ...(d.settlement.runDisposition === undefined ||
765
+ settled.runDisposition === undefined
766
+ ? {}
767
+ : { runDisposition: settled.runDisposition }),
768
+ ...(d.settlement.usageSummary === undefined ||
769
+ settled.usageSummary === undefined
770
+ ? {}
771
+ : { usageSummary: settled.usageSummary }),
772
+ });
773
+
774
+ if (!Schema.toEquivalence(Settlement)(d.settlement, expected))
775
+ return yield* invalidThreadArchive(
776
+ "Delivery settlement differs from destination evidence",
777
+ threadId,
778
+ );
779
+ }
780
+ }
781
+ }
255
782
 
256
- return yield* decode(yield* encode(changed));
783
+ return () => {
784
+ for (const text of staged.records.values())
785
+ commitInto(current, Schema.decodeSync(codec)(text), text, true);
786
+ };
787
+ }),
788
+ };
789
+ }),
257
790
  });
258
791
 
259
792
  return MessageDeliveryStore.of({
@@ -262,7 +795,7 @@ export const memoryMessageDeliveryStoreLayer = (
262
795
  insert,
263
796
  get,
264
797
  change,
265
- list: Effect.fn("MemoryMessageDeliveryStore.list")(function* (request) {
798
+ list: Effect.fnUntraced(function* (request) {
266
799
  const input = yield* validateMessageDelivery(MessageDeliveryPageRequest, request, "list");
267
800
 
268
801
  const current = yield* Ref.get(state);
@@ -278,94 +811,97 @@ export const memoryMessageDeliveryStoreLayer = (
278
811
  ? JSON.stringify([input.ownerThreadId, input.pendingWorker])
279
812
  : undefined;
280
813
 
281
- const retained =
282
- workerKey !== undefined || input.pendingOnly
283
- ? yield* Effect.forEach(
284
- [
285
- ...((workerKey === undefined
286
- ? current.pending.get(input.ownerThreadId)
287
- : current.workers.get(workerKey)) ?? []),
288
- ]
289
- .filter((messageId) => input.after === undefined || messageId > input.after)
290
- .sort()
291
- .slice(0, input.limit + 1),
292
- (messageId) =>
293
- decode(
294
- current.records.get(
295
- messageDeliveryKeyString({ ownerThreadId: input.ownerThreadId, messageId }),
296
- ) ?? "",
297
- ),
298
- )
299
- : yield* all();
300
-
301
- const records = retained
302
- .filter(
303
- (record) =>
304
- record.key.ownerThreadId === input.ownerThreadId &&
305
- (input.after === undefined || record.key.messageId > input.after),
306
- )
307
- .sort((left, right) =>
308
- left.key.messageId < right.key.messageId
309
- ? -1
310
- : left.key.messageId > right.key.messageId
311
- ? 1
312
- : 0,
814
+ const keys =
815
+ workerKey === undefined
816
+ ? input.pendingOnly
817
+ ? Array.from(current.pending.get(input.ownerThreadId) ?? []).sort()
818
+ : (current.ordered.get(ownerKey(input.ownerThreadId)) ?? [])
819
+ : (current.ordered.get(workerKey) ?? []);
820
+
821
+ const start =
822
+ input.after === undefined
823
+ ? 0
824
+ : position(keys, input.after) +
825
+ Number(keys[position(keys, input.after)] === input.after);
826
+
827
+ const items: Array<MessageDeliveryRecord> = [];
828
+ let bytes = 0;
829
+ let at = start;
830
+
831
+ while (at < keys.length && items.length < input.limit) {
832
+ const text = current.records.get(
833
+ messageDeliveryKeyString({
834
+ ownerThreadId: input.ownerThreadId,
835
+ messageId: Schema.decodeSync(MessageDeliveryKey.fields.messageId)(keys[at]),
836
+ }),
313
837
  );
314
838
 
315
- const items = records.slice(0, input.limit);
839
+ if (text === undefined)
840
+ return yield* MessageDeliveryError.make({
841
+ reason: "corrupt",
842
+ operation: "list locator",
843
+ });
844
+ const size = new TextEncoder().encode(text).byteLength;
845
+
846
+ if (size > 32 * 1024 * 1024)
847
+ return yield* MessageDeliveryError.make({
848
+ reason: "capacity",
849
+ operation: "list byte bound",
850
+ });
851
+ if (bytes + size > 32 * 1024 * 1024) break;
852
+ items.push(yield* decode(text));
853
+ bytes += size;
854
+ at++;
855
+ }
316
856
 
317
857
  return {
318
858
  items,
319
- next: records.length > input.limit ? (items.at(-1)?.key.messageId ?? null) : null,
859
+ next: at < keys.length ? (items.at(-1)?.key.messageId ?? null) : null,
320
860
  };
321
861
  }),
322
- due: Effect.fn("MemoryMessageDeliveryStore.due")(
323
- function* (nowMillis, limit, ownerThreadId) {
324
- const input = yield* validateMessageDelivery(
325
- Scan,
326
- { nowMillis, limit, ...(ownerThreadId === undefined ? {} : { ownerThreadId }) },
327
- "due",
328
- );
329
-
330
- return (yield* all())
331
- .flatMap((record) => {
332
- const deadline = messageDeliveryDeadline(record);
333
-
334
- return deadline !== null &&
335
- deadline <= input.nowMillis &&
336
- (input.ownerThreadId === undefined ||
337
- input.ownerThreadId === record.key.ownerThreadId)
338
- ? [{ key: record.key, deadline }]
339
- : [];
340
- })
341
- .sort(
342
- (left, right) =>
343
- left.deadline - right.deadline ||
344
- (messageDeliveryKeyString(left.key) < messageDeliveryKeyString(right.key)
345
- ? -1
346
- : 1),
347
- )
348
- .slice(0, input.limit)
349
- .map((record) => record.key);
350
- },
351
- ),
352
- nextDeadline: Effect.fn("MemoryMessageDeliveryStore.nextDeadline")(
353
- function* (ownerThreadId) {
354
- if (ownerThreadId !== undefined)
355
- yield* validateMessageDelivery(ThreadId, ownerThreadId, "nextDeadline");
356
- let next: number | null = null;
357
-
358
- for (const record of yield* all()) {
359
- if (ownerThreadId !== undefined && ownerThreadId !== record.key.ownerThreadId)
360
- continue;
361
- const deadline = messageDeliveryDeadline(record);
362
-
363
- if (deadline !== null && (next === null || deadline < next)) next = deadline;
364
- }
862
+ due: Effect.fnUntraced(function* (nowMillis, limit, ownerThreadId) {
863
+ const input = yield* validateMessageDelivery(
864
+ Scan,
865
+ { nowMillis, limit, ...(ownerThreadId === undefined ? {} : { ownerThreadId }) },
866
+ "due",
867
+ );
868
+
869
+ const current = MutableRef.get(state.ref);
870
+
871
+ const keys =
872
+ input.ownerThreadId === undefined
873
+ ? current.deadlineKeys
874
+ : (current.ownerDeadlineKeys.get(input.ownerThreadId) ?? []);
875
+
876
+ const selected: Array<MessageDeliveryKey> = [];
877
+
878
+ for (const key of keys) {
879
+ const row = current.deadlines.get(key);
880
+
881
+ if (row === undefined)
882
+ return yield* MessageDeliveryError.make({
883
+ reason: "corrupt",
884
+ operation: "deadline index",
885
+ });
886
+ if (row.deadline > input.nowMillis || selected.length === input.limit) break;
887
+ selected.push(row.key);
888
+ }
365
889
 
366
- return next;
367
- },
368
- ),
890
+ return selected;
891
+ }),
892
+ nextDeadline: Effect.fnUntraced(function* (ownerThreadId) {
893
+ if (ownerThreadId !== undefined)
894
+ yield* validateMessageDelivery(ThreadId, ownerThreadId, "nextDeadline");
895
+ const current = MutableRef.get(state.ref);
896
+
897
+ const first = (
898
+ ownerThreadId === undefined
899
+ ? current.deadlineKeys
900
+ : current.ownerDeadlineKeys.get(ownerThreadId)
901
+ )?.[0];
902
+
903
+ return first === undefined ? null : (current.deadlines.get(first)?.deadline ?? null);
904
+ }),
369
905
  });
370
906
  }),
371
907
  );