@effect-agent/storage-cloudflare 0.1.0-beta.37 → 0.1.0-beta.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/do-storage-failpoint-9XB9tuif.d.mts +107 -0
- package/dist/do-storage-failpoint-C-Qtchs7.mjs +12 -0
- package/dist/do-storage-failpoint-C-Qtchs7.mjs.map +1 -0
- package/dist/index.d.mts +160 -242
- package/dist/index.mjs +1199 -522
- package/dist/index.mjs.map +1 -1
- package/dist/testing.d.mts +33 -0
- package/dist/testing.mjs +35 -0
- package/dist/testing.mjs.map +1 -0
- package/package.json +12 -5
- package/src/do-journal.ts +148 -157
- package/src/do-ledger.ts +66 -66
- package/src/do-schedule-store.ts +7 -3
- package/src/do-storage-failpoint-testing.ts +73 -0
- package/src/do-storage-failpoint.ts +1 -68
- package/src/do-subscription-store.ts +1122 -0
- package/src/{do-conversation-store.ts → do-thread-store.ts} +313 -334
- package/src/errors.ts +3 -3
- package/src/index.ts +9 -8
- package/src/migrations.ts +26 -26
- package/src/port-protocol.ts +29 -29
- package/src/routing.ts +146 -188
- package/src/testing.ts +2 -0
package/src/do-ledger.ts
CHANGED
|
@@ -73,22 +73,22 @@ import {
|
|
|
73
73
|
submissionAbortRecordId,
|
|
74
74
|
type ChildSettledOutcome,
|
|
75
75
|
type SuspensionOutcome,
|
|
76
|
-
} from "@effect-agent/
|
|
76
|
+
} from "@effect-agent/thread";
|
|
77
77
|
import { BrowserCrypto } from "@effect/platform-browser";
|
|
78
78
|
import { SqliteClient } from "@effect/sql-sqlite-do";
|
|
79
79
|
import { Clock, Context, Crypto, DateTime, Effect, Layer, Option, Schema, Stream } from "effect";
|
|
80
80
|
import * as SqlClientService from "effect/unstable/sql/SqlClient";
|
|
81
81
|
import type { SqlError } from "effect/unstable/sql/SqlError";
|
|
82
82
|
|
|
83
|
+
import { decodeRows, initializeDoJournal } from "./do-journal.ts";
|
|
84
|
+
import { DoStorageConfig } from "./do-storage-config.ts";
|
|
85
|
+
import { DoStorageFailpoint } from "./do-storage-failpoint.ts";
|
|
83
86
|
import {
|
|
84
87
|
storageConfigLayer,
|
|
85
88
|
storageFailpointLayer,
|
|
86
89
|
type DoStorageInitializationError,
|
|
87
90
|
type DoStorageOptions,
|
|
88
|
-
} from "./do-
|
|
89
|
-
import { decodeRows, initializeDoJournal } from "./do-journal.ts";
|
|
90
|
-
import { DoStorageConfig } from "./do-storage-config.ts";
|
|
91
|
-
import { DoStorageFailpoint } from "./do-storage-failpoint.ts";
|
|
91
|
+
} from "./do-thread-store.ts";
|
|
92
92
|
import {
|
|
93
93
|
DoLedgerError,
|
|
94
94
|
DoStorageCorruptionError,
|
|
@@ -117,7 +117,7 @@ const MAX_IDENTIFIER_LENGTH = 1_024;
|
|
|
117
117
|
|
|
118
118
|
class SubmissionRow extends Schema.Class<SubmissionRow>("SubmissionRow")({
|
|
119
119
|
submission_id: BoundedIdentifier,
|
|
120
|
-
|
|
120
|
+
thread_id: BoundedIdentifier,
|
|
121
121
|
queue_sequence: QueueSequence,
|
|
122
122
|
principal: BoundedIdentifier,
|
|
123
123
|
idempotency_key: BoundedIdentifier,
|
|
@@ -221,7 +221,7 @@ class CanonicalRecordIdRow extends Schema.Class<CanonicalRecordIdRow>("Canonical
|
|
|
221
221
|
|
|
222
222
|
const SUBMISSION_COLUMNS = `
|
|
223
223
|
submission_id,
|
|
224
|
-
|
|
224
|
+
thread_id,
|
|
225
225
|
queue_sequence,
|
|
226
226
|
principal,
|
|
227
227
|
idempotency_key,
|
|
@@ -260,7 +260,7 @@ const CHILD_RESERVATION_COLUMNS = `
|
|
|
260
260
|
released_at
|
|
261
261
|
`;
|
|
262
262
|
|
|
263
|
-
/** The branded ToolCallId schema, reached through the
|
|
263
|
+
/** The branded ToolCallId schema, reached through the thread port so no core import is needed. */
|
|
264
264
|
const ToolCallIdSchema = ApprovalDecisionCommand.fields.toolCallId;
|
|
265
265
|
const ToolCallIdList = Schema.Array(ToolCallIdSchema);
|
|
266
266
|
|
|
@@ -452,20 +452,20 @@ const makeServices = Effect.fn("DoSubmissionLedger.makeServices")(function* () {
|
|
|
452
452
|
return decoded.length === 0 ? Option.none() : Option.some(decoded[0]);
|
|
453
453
|
});
|
|
454
454
|
|
|
455
|
-
const
|
|
455
|
+
const threadEpoch = Effect.fn("DoSubmissionLedger.threadEpoch")(function* (
|
|
456
456
|
operation: string,
|
|
457
|
-
|
|
457
|
+
threadId: string,
|
|
458
458
|
): Effect.fn.Return<ProducerEpoch, LedgerError> {
|
|
459
|
-
const
|
|
460
|
-
.
|
|
459
|
+
const threads = yield* journal
|
|
460
|
+
.getThread(threadId)
|
|
461
461
|
.pipe(Effect.mapError(internalFailure(operation)));
|
|
462
|
-
return
|
|
462
|
+
return threads.length === 0 ? EPOCH_ZERO : threads[0].producer_epoch;
|
|
463
463
|
});
|
|
464
464
|
|
|
465
465
|
/**
|
|
466
466
|
* Verify inside the surrounding write transaction that the presented token still owns the
|
|
467
467
|
* Submission's lane; a superseded or missing token fails with OwnershipLost carrying the
|
|
468
|
-
*
|
|
468
|
+
* Thread's current producer epoch (DUR-006).
|
|
469
469
|
*/
|
|
470
470
|
const requireOwnership = Effect.fn("DoSubmissionLedger.requireOwnership")(function* (
|
|
471
471
|
operation: string,
|
|
@@ -474,7 +474,7 @@ const makeServices = Effect.fn("DoSubmissionLedger.makeServices")(function* () {
|
|
|
474
474
|
): Effect.fn.Return<OwnershipRow, OwnershipLost | LedgerError> {
|
|
475
475
|
const ownership = yield* readOwnership(operation, submission.submission_id);
|
|
476
476
|
if (Option.isNone(ownership) || ownership.value.ownership_token !== ownershipToken) {
|
|
477
|
-
const actualEpoch = yield*
|
|
477
|
+
const actualEpoch = yield* threadEpoch(operation, submission.thread_id);
|
|
478
478
|
const submissionId = yield* Schema.decodeUnknownEffect(
|
|
479
479
|
SubmissionSnapshot.fields.submissionId,
|
|
480
480
|
)(submission.submission_id).pipe(Effect.mapError(internalFailure(operation)));
|
|
@@ -518,7 +518,7 @@ const makeServices = Effect.fn("DoSubmissionLedger.makeServices")(function* () {
|
|
|
518
518
|
}
|
|
519
519
|
return yield* decodeSubmissionSnapshotUnknown({
|
|
520
520
|
submissionId: row.submission_id,
|
|
521
|
-
|
|
521
|
+
threadId: row.thread_id,
|
|
522
522
|
queueSequence: row.queue_sequence,
|
|
523
523
|
principal: row.principal,
|
|
524
524
|
idempotencyKey: row.idempotency_key,
|
|
@@ -889,20 +889,20 @@ const makeServices = Effect.fn("DoSubmissionLedger.makeServices")(function* () {
|
|
|
889
889
|
*/
|
|
890
890
|
const canonicalAbortRecordId = Effect.fn("DoSubmissionLedger.canonicalAbortRecordId")(function* (
|
|
891
891
|
operation: string,
|
|
892
|
-
|
|
892
|
+
threadId: string,
|
|
893
893
|
submissionId: SubmissionId,
|
|
894
894
|
): Effect.fn.Return<string | undefined, LedgerError> {
|
|
895
895
|
const recordId = submissionAbortRecordId(submissionId);
|
|
896
896
|
const rows = yield* sql<Record<string, unknown>>`
|
|
897
897
|
SELECT record_id
|
|
898
898
|
FROM effect_agent_canonical_records
|
|
899
|
-
WHERE
|
|
899
|
+
WHERE thread_id = ${threadId}
|
|
900
900
|
AND record_id = ${recordId}
|
|
901
901
|
`.pipe(Effect.mapError(sqlFailure(operation)));
|
|
902
902
|
const decoded = yield* decodeRows(
|
|
903
903
|
Schema.Array(CanonicalRecordIdRow),
|
|
904
904
|
"effect_agent_canonical_records",
|
|
905
|
-
`${
|
|
905
|
+
`${threadId}/${recordId}`,
|
|
906
906
|
rows,
|
|
907
907
|
).pipe(Effect.mapError(internalFailure(operation)));
|
|
908
908
|
return decoded.length === 0 ? undefined : recordId;
|
|
@@ -916,7 +916,7 @@ const makeServices = Effect.fn("DoSubmissionLedger.makeServices")(function* () {
|
|
|
916
916
|
): Effect.fn.Return<AbortIntent, LedgerError> {
|
|
917
917
|
const canonicalRecordId = yield* canonicalAbortRecordId(
|
|
918
918
|
operation,
|
|
919
|
-
submission.
|
|
919
|
+
submission.thread_id,
|
|
920
920
|
submissionId,
|
|
921
921
|
);
|
|
922
922
|
return yield* decodeAbortIntent({
|
|
@@ -961,17 +961,17 @@ const makeServices = Effect.fn("DoSubmissionLedger.makeServices")(function* () {
|
|
|
961
961
|
const agentDigestsJson = yield* encodeDefinitionDigestsText(validated.agentDigests).pipe(
|
|
962
962
|
Effect.mapError(internalFailure(operation)),
|
|
963
963
|
);
|
|
964
|
-
// Routable Submission identity (D-P6-5): `{uuidv7}:{
|
|
964
|
+
// Routable Submission identity (D-P6-5): `{uuidv7}:{threadId}`. The cross-DO
|
|
965
965
|
// routing layer parses ITS OWN minted format (split at the first ":") to address
|
|
966
|
-
// submissionId-only operations to the owning
|
|
966
|
+
// submissionId-only operations to the owning Thread Object; the id stays opaque
|
|
967
967
|
// to every other component, exactly like DN's `submission-{uuid}` prefix.
|
|
968
|
-
const mintedSubmissionId = `${yield* mintUuid(operation)}:${validated.
|
|
968
|
+
const mintedSubmissionId = `${yield* mintUuid(operation)}:${validated.threadId}`;
|
|
969
969
|
if (mintedSubmissionId.length > MAX_IDENTIFIER_LENGTH) {
|
|
970
970
|
return yield* LedgerError.make({
|
|
971
971
|
operation,
|
|
972
972
|
message:
|
|
973
973
|
`A routable Submission identity of ${mintedSubmissionId.length} characters exceeds ` +
|
|
974
|
-
`the ${MAX_IDENTIFIER_LENGTH}-character ledger row bound; shorten the
|
|
974
|
+
`the ${MAX_IDENTIFIER_LENGTH}-character ledger row bound; shorten the Thread identity.`,
|
|
975
975
|
});
|
|
976
976
|
}
|
|
977
977
|
const mintedReceiptId = `receipt-${yield* mintUuid(operation)}`;
|
|
@@ -979,11 +979,11 @@ const makeServices = Effect.fn("DoSubmissionLedger.makeServices")(function* () {
|
|
|
979
979
|
const result = yield* inWriteTransaction(
|
|
980
980
|
operation,
|
|
981
981
|
Effect.gen(function* () {
|
|
982
|
-
const keyRowKey = `${validated.
|
|
982
|
+
const keyRowKey = `${validated.threadId}/${validated.principal}/${validated.idempotencyKey}`;
|
|
983
983
|
const existingRows = yield* sql<Record<string, unknown>>`
|
|
984
984
|
SELECT ${sql.literal(SUBMISSION_COLUMNS)}
|
|
985
985
|
FROM effect_agent_submissions
|
|
986
|
-
WHERE
|
|
986
|
+
WHERE thread_id = ${validated.threadId}
|
|
987
987
|
AND principal = ${validated.principal}
|
|
988
988
|
AND idempotency_key = ${validated.idempotencyKey}
|
|
989
989
|
`.pipe(Effect.mapError(sqlFailure(operation)));
|
|
@@ -1007,7 +1007,7 @@ const makeServices = Effect.fn("DoSubmissionLedger.makeServices")(function* () {
|
|
|
1007
1007
|
existing[0].parent_tool_call_id === validated.parentLinkage.parentToolCallId;
|
|
1008
1008
|
if (existing[0].input_digest !== validated.inputDigest || !sameLinkage) {
|
|
1009
1009
|
return yield* AdmissionConflict.make({
|
|
1010
|
-
|
|
1010
|
+
threadId: validated.threadId,
|
|
1011
1011
|
principal: validated.principal,
|
|
1012
1012
|
idempotencyKey: validated.idempotencyKey,
|
|
1013
1013
|
existingInputDigest: existing[0].input_digest,
|
|
@@ -1026,12 +1026,12 @@ const makeServices = Effect.fn("DoSubmissionLedger.makeServices")(function* () {
|
|
|
1026
1026
|
const maxRows = yield* sql<Record<string, unknown>>`
|
|
1027
1027
|
SELECT COALESCE(MAX(queue_sequence), 0) AS max_queue_sequence
|
|
1028
1028
|
FROM effect_agent_submissions
|
|
1029
|
-
WHERE
|
|
1029
|
+
WHERE thread_id = ${validated.threadId}
|
|
1030
1030
|
`.pipe(Effect.mapError(sqlFailure(operation)));
|
|
1031
1031
|
const decodedMax = yield* decodeRows(
|
|
1032
1032
|
Schema.Array(MaxQueueSequenceRow),
|
|
1033
1033
|
"effect_agent_submissions",
|
|
1034
|
-
validated.
|
|
1034
|
+
validated.threadId,
|
|
1035
1035
|
maxRows,
|
|
1036
1036
|
).pipe(Effect.mapError(internalFailure(operation)));
|
|
1037
1037
|
const queueSequence = yield* decodeQueueSequence(
|
|
@@ -1042,7 +1042,7 @@ const makeServices = Effect.fn("DoSubmissionLedger.makeServices")(function* () {
|
|
|
1042
1042
|
yield* sql`
|
|
1043
1043
|
INSERT INTO effect_agent_submissions (
|
|
1044
1044
|
submission_id,
|
|
1045
|
-
|
|
1045
|
+
thread_id,
|
|
1046
1046
|
queue_sequence,
|
|
1047
1047
|
principal,
|
|
1048
1048
|
idempotency_key,
|
|
@@ -1058,7 +1058,7 @@ const makeServices = Effect.fn("DoSubmissionLedger.makeServices")(function* () {
|
|
|
1058
1058
|
parent_tool_call_id
|
|
1059
1059
|
) VALUES (
|
|
1060
1060
|
${mintedSubmissionId},
|
|
1061
|
-
${validated.
|
|
1061
|
+
${validated.threadId},
|
|
1062
1062
|
${queueSequence},
|
|
1063
1063
|
${validated.principal},
|
|
1064
1064
|
${validated.idempotencyKey},
|
|
@@ -1127,20 +1127,20 @@ const makeServices = Effect.fn("DoSubmissionLedger.makeServices")(function* () {
|
|
|
1127
1127
|
const rows = yield* sql<Record<string, unknown>>`
|
|
1128
1128
|
SELECT ${sql.literal(SUBMISSION_COLUMNS)}
|
|
1129
1129
|
FROM effect_agent_submissions
|
|
1130
|
-
WHERE
|
|
1130
|
+
WHERE thread_id = ${validated.threadId}
|
|
1131
1131
|
AND principal = ${validated.principal}
|
|
1132
1132
|
AND idempotency_key = ${validated.idempotencyKey}
|
|
1133
1133
|
`.pipe(Effect.mapError(sqlFailure(operation)));
|
|
1134
1134
|
const decoded = yield* decodeSubmissionRows(
|
|
1135
1135
|
operation,
|
|
1136
|
-
`${validated.
|
|
1136
|
+
`${validated.threadId}/${validated.principal}/${validated.idempotencyKey}`,
|
|
1137
1137
|
rows,
|
|
1138
1138
|
);
|
|
1139
1139
|
if (decoded.length > 1) {
|
|
1140
1140
|
return yield* corruptionFailure(
|
|
1141
1141
|
operation,
|
|
1142
1142
|
"effect_agent_submissions",
|
|
1143
|
-
`${validated.
|
|
1143
|
+
`${validated.threadId}/${validated.principal}/${validated.idempotencyKey}`,
|
|
1144
1144
|
"An admission idempotency key returned more than one row.",
|
|
1145
1145
|
);
|
|
1146
1146
|
}
|
|
@@ -1149,7 +1149,7 @@ const makeServices = Effect.fn("DoSubmissionLedger.makeServices")(function* () {
|
|
|
1149
1149
|
},
|
|
1150
1150
|
);
|
|
1151
1151
|
|
|
1152
|
-
// This LOCAL facet is the authoritative owner of every
|
|
1152
|
+
// This LOCAL facet is the authoritative owner of every Thread stored in this Durable
|
|
1153
1153
|
// Object, so the key-scoped read IS the admission truth and the tri-state degenerates to
|
|
1154
1154
|
// NotAdmitted or Admitted (SUB-031). `AdmissionIndeterminate` becomes real one layer out:
|
|
1155
1155
|
// the WP2 routed decorator answers it when the OWNING Durable Object is unreachable.
|
|
@@ -1163,20 +1163,20 @@ const makeServices = Effect.fn("DoSubmissionLedger.makeServices")(function* () {
|
|
|
1163
1163
|
const rows = yield* sql<Record<string, unknown>>`
|
|
1164
1164
|
SELECT ${sql.literal(SUBMISSION_COLUMNS)}
|
|
1165
1165
|
FROM effect_agent_submissions
|
|
1166
|
-
WHERE
|
|
1166
|
+
WHERE thread_id = ${validated.threadId}
|
|
1167
1167
|
AND principal = ${validated.principal}
|
|
1168
1168
|
AND idempotency_key = ${validated.idempotencyKey}
|
|
1169
1169
|
`.pipe(Effect.mapError(sqlFailure(operation)));
|
|
1170
1170
|
const decoded = yield* decodeSubmissionRows(
|
|
1171
1171
|
operation,
|
|
1172
|
-
`${validated.
|
|
1172
|
+
`${validated.threadId}/${validated.principal}/${validated.idempotencyKey}`,
|
|
1173
1173
|
rows,
|
|
1174
1174
|
);
|
|
1175
1175
|
if (decoded.length > 1) {
|
|
1176
1176
|
return yield* corruptionFailure(
|
|
1177
1177
|
operation,
|
|
1178
1178
|
"effect_agent_submissions",
|
|
1179
|
-
`${validated.
|
|
1179
|
+
`${validated.threadId}/${validated.principal}/${validated.idempotencyKey}`,
|
|
1180
1180
|
"An admission idempotency key returned more than one row.",
|
|
1181
1181
|
);
|
|
1182
1182
|
}
|
|
@@ -1201,12 +1201,12 @@ const makeServices = Effect.fn("DoSubmissionLedger.makeServices")(function* () {
|
|
|
1201
1201
|
const headRows = yield* sql<Record<string, unknown>>`
|
|
1202
1202
|
SELECT ${sql.literal(SUBMISSION_COLUMNS)}
|
|
1203
1203
|
FROM effect_agent_submissions
|
|
1204
|
-
WHERE
|
|
1204
|
+
WHERE thread_id = ${validated.threadId}
|
|
1205
1205
|
AND state <> 'settled'
|
|
1206
1206
|
ORDER BY queue_sequence ASC
|
|
1207
1207
|
LIMIT 1
|
|
1208
1208
|
`.pipe(Effect.mapError(sqlFailure(operation)));
|
|
1209
|
-
const heads = yield* decodeSubmissionRows(operation, validated.
|
|
1209
|
+
const heads = yield* decodeSubmissionRows(operation, validated.threadId, headRows);
|
|
1210
1210
|
if (heads.length === 0) return Option.none<Claim>();
|
|
1211
1211
|
const head = heads[0];
|
|
1212
1212
|
|
|
@@ -1236,25 +1236,25 @@ const makeServices = Effect.fn("DoSubmissionLedger.makeServices")(function* () {
|
|
|
1236
1236
|
if (expiresAt > now.millis) return Option.none<Claim>();
|
|
1237
1237
|
}
|
|
1238
1238
|
|
|
1239
|
-
// Bump the
|
|
1240
|
-
// Attempt is fenced out of canonical appends (DUR-006). A
|
|
1239
|
+
// Bump the Thread's producer epoch atomically with the claim so every stale
|
|
1240
|
+
// Attempt is fenced out of canonical appends (DUR-006). A Thread that was
|
|
1241
1241
|
// never materialized (eviction between admission and materialization) is created
|
|
1242
1242
|
// here so recovery can claim first and re-materialize idempotently at this epoch.
|
|
1243
|
-
const
|
|
1244
|
-
.
|
|
1243
|
+
const threads = yield* journal
|
|
1244
|
+
.getThread(head.thread_id)
|
|
1245
1245
|
.pipe(Effect.mapError(internalFailure(operation)));
|
|
1246
1246
|
let producerEpoch: number;
|
|
1247
|
-
if (
|
|
1247
|
+
if (threads.length === 0) {
|
|
1248
1248
|
producerEpoch = 1;
|
|
1249
1249
|
yield* sql`
|
|
1250
|
-
INSERT INTO
|
|
1251
|
-
|
|
1250
|
+
INSERT INTO effect_agent_threads (
|
|
1251
|
+
thread_id,
|
|
1252
1252
|
created_at,
|
|
1253
1253
|
tail_sequence,
|
|
1254
1254
|
tail_digest,
|
|
1255
1255
|
producer_epoch
|
|
1256
1256
|
) VALUES (
|
|
1257
|
-
${head.
|
|
1257
|
+
${head.thread_id},
|
|
1258
1258
|
${now.iso},
|
|
1259
1259
|
0,
|
|
1260
1260
|
${EMPTY_TAIL_DIGEST},
|
|
@@ -1262,11 +1262,11 @@ const makeServices = Effect.fn("DoSubmissionLedger.makeServices")(function* () {
|
|
|
1262
1262
|
)
|
|
1263
1263
|
`.pipe(Effect.mapError(sqlFailure(operation)));
|
|
1264
1264
|
} else {
|
|
1265
|
-
producerEpoch =
|
|
1265
|
+
producerEpoch = threads[0].producer_epoch + 1;
|
|
1266
1266
|
yield* sql`
|
|
1267
|
-
UPDATE
|
|
1267
|
+
UPDATE effect_agent_threads
|
|
1268
1268
|
SET producer_epoch = ${producerEpoch}
|
|
1269
|
-
WHERE
|
|
1269
|
+
WHERE thread_id = ${head.thread_id}
|
|
1270
1270
|
`.pipe(Effect.mapError(sqlFailure(operation)));
|
|
1271
1271
|
}
|
|
1272
1272
|
|
|
@@ -1299,14 +1299,14 @@ const makeServices = Effect.fn("DoSubmissionLedger.makeServices")(function* () {
|
|
|
1299
1299
|
INSERT INTO effect_agent_attempts (
|
|
1300
1300
|
attempt_id,
|
|
1301
1301
|
submission_id,
|
|
1302
|
-
|
|
1302
|
+
thread_id,
|
|
1303
1303
|
owner_producer_id,
|
|
1304
1304
|
producer_epoch,
|
|
1305
1305
|
claimed_at
|
|
1306
1306
|
) VALUES (
|
|
1307
1307
|
${attemptId},
|
|
1308
1308
|
${head.submission_id},
|
|
1309
|
-
${head.
|
|
1309
|
+
${head.thread_id},
|
|
1310
1310
|
${validated.producerId},
|
|
1311
1311
|
${producerEpoch},
|
|
1312
1312
|
${now.iso}
|
|
@@ -1743,7 +1743,7 @@ const makeServices = Effect.fn("DoSubmissionLedger.makeServices")(function* () {
|
|
|
1743
1743
|
`.pipe(Effect.mapError(sqlFailure(operation)));
|
|
1744
1744
|
const canonicalRecordId = yield* canonicalAbortRecordId(
|
|
1745
1745
|
operation,
|
|
1746
|
-
submission.
|
|
1746
|
+
submission.thread_id,
|
|
1747
1747
|
validated.submissionId,
|
|
1748
1748
|
);
|
|
1749
1749
|
return yield* decodeAbortIntent({
|
|
@@ -1771,10 +1771,10 @@ const makeServices = Effect.fn("DoSubmissionLedger.makeServices")(function* () {
|
|
|
1771
1771
|
operation,
|
|
1772
1772
|
Effect.gen(function* () {
|
|
1773
1773
|
const host = yield* requireSubmission(operation, validated.hostSubmissionId);
|
|
1774
|
-
if (host.
|
|
1774
|
+
if (host.thread_id !== validated.threadId) {
|
|
1775
1775
|
return yield* LedgerError.make({
|
|
1776
1776
|
operation,
|
|
1777
|
-
message: `Host submission ${validated.hostSubmissionId} does not belong to
|
|
1777
|
+
message: `Host submission ${validated.hostSubmissionId} does not belong to thread ${validated.threadId}.`,
|
|
1778
1778
|
});
|
|
1779
1779
|
}
|
|
1780
1780
|
// The host Attempt already owns the lane; no epoch bump happens here (plan §2.5).
|
|
@@ -1782,11 +1782,11 @@ const makeServices = Effect.fn("DoSubmissionLedger.makeServices")(function* () {
|
|
|
1782
1782
|
const laterRows = yield* sql<Record<string, unknown>>`
|
|
1783
1783
|
SELECT ${sql.literal(SUBMISSION_COLUMNS)}
|
|
1784
1784
|
FROM effect_agent_submissions
|
|
1785
|
-
WHERE
|
|
1785
|
+
WHERE thread_id = ${validated.threadId}
|
|
1786
1786
|
AND queue_sequence > ${host.queue_sequence}
|
|
1787
1787
|
ORDER BY queue_sequence ASC
|
|
1788
1788
|
`.pipe(Effect.mapError(sqlFailure(operation)));
|
|
1789
|
-
const later = yield* decodeSubmissionRows(operation, validated.
|
|
1789
|
+
const later = yield* decodeSubmissionRows(operation, validated.threadId, laterRows);
|
|
1790
1790
|
const claimed: Array<JoiningClaim> = [];
|
|
1791
1791
|
for (const row of later) {
|
|
1792
1792
|
if (claimed.length >= validated.maxCount) break;
|
|
@@ -1956,7 +1956,7 @@ const makeServices = Effect.fn("DoSubmissionLedger.makeServices")(function* () {
|
|
|
1956
1956
|
// A covering event that raced ahead of the suspend transaction resumes the caller
|
|
1957
1957
|
// immediately WITHOUT releasing the lane (plan §2.6, §12). For WaitingForChild the
|
|
1958
1958
|
// covering evidence is EITHER a locally settled child row OR a durable cross-store
|
|
1959
|
-
// notification marker: parent and child
|
|
1959
|
+
// notification marker: parent and child Threads live in different Durable
|
|
1960
1960
|
// Objects, and the port contract requires that a child settlement reported (via
|
|
1961
1961
|
// `recordChildSettled` → marker) before this suspend commits is observed here.
|
|
1962
1962
|
if (validated.reason._tag === "ApprovalPending") {
|
|
@@ -2667,7 +2667,7 @@ const makeServices = Effect.fn("DoSubmissionLedger.makeServices")(function* () {
|
|
|
2667
2667
|
});
|
|
2668
2668
|
|
|
2669
2669
|
interface ScanCursor {
|
|
2670
|
-
readonly
|
|
2670
|
+
readonly threadId: string;
|
|
2671
2671
|
readonly queueSequence: number;
|
|
2672
2672
|
}
|
|
2673
2673
|
|
|
@@ -2684,7 +2684,7 @@ const makeServices = Effect.fn("DoSubmissionLedger.makeServices")(function* () {
|
|
|
2684
2684
|
SELECT ${sql.literal(SUBMISSION_COLUMNS)}
|
|
2685
2685
|
FROM effect_agent_submissions
|
|
2686
2686
|
WHERE state <> 'settled'
|
|
2687
|
-
ORDER BY
|
|
2687
|
+
ORDER BY thread_id ASC, queue_sequence ASC
|
|
2688
2688
|
LIMIT ${SCAN_PAGE_SIZE}
|
|
2689
2689
|
`
|
|
2690
2690
|
: sql<Record<string, unknown>>`
|
|
@@ -2692,13 +2692,13 @@ const makeServices = Effect.fn("DoSubmissionLedger.makeServices")(function* () {
|
|
|
2692
2692
|
FROM effect_agent_submissions
|
|
2693
2693
|
WHERE state <> 'settled'
|
|
2694
2694
|
AND (
|
|
2695
|
-
|
|
2695
|
+
thread_id > ${cursor.threadId}
|
|
2696
2696
|
OR (
|
|
2697
|
-
|
|
2697
|
+
thread_id = ${cursor.threadId}
|
|
2698
2698
|
AND queue_sequence > ${cursor.queueSequence}
|
|
2699
2699
|
)
|
|
2700
2700
|
)
|
|
2701
|
-
ORDER BY
|
|
2701
|
+
ORDER BY thread_id ASC, queue_sequence ASC
|
|
2702
2702
|
LIMIT ${SCAN_PAGE_SIZE}
|
|
2703
2703
|
`
|
|
2704
2704
|
).pipe(Effect.mapError(sqlFailure(operation)));
|
|
@@ -2711,7 +2711,7 @@ const makeServices = Effect.fn("DoSubmissionLedger.makeServices")(function* () {
|
|
|
2711
2711
|
last === undefined || decoded.length < SCAN_PAGE_SIZE
|
|
2712
2712
|
? Option.none()
|
|
2713
2713
|
: Option.some({
|
|
2714
|
-
|
|
2714
|
+
threadId: last.thread_id,
|
|
2715
2715
|
queueSequence: last.queue_sequence,
|
|
2716
2716
|
});
|
|
2717
2717
|
return [snapshots, next] as const;
|
|
@@ -2987,7 +2987,7 @@ export const submissionLedgerLayer: Layer.Layer<
|
|
|
2987
2987
|
|
|
2988
2988
|
/**
|
|
2989
2989
|
* A composition-root convenience Layer for the durable Submission Ledger. Point it at the
|
|
2990
|
-
* same `ctx.storage` as the
|
|
2990
|
+
* same `ctx.storage` as the ThreadStore so claims fence the same producer epochs.
|
|
2991
2991
|
*/
|
|
2992
2992
|
export const ledgerLayer = (
|
|
2993
2993
|
options: DoStorageOptions,
|
package/src/do-schedule-store.ts
CHANGED
|
@@ -19,11 +19,11 @@ import {
|
|
|
19
19
|
ScheduleRecord,
|
|
20
20
|
ScheduleStorageError,
|
|
21
21
|
ScheduleStore,
|
|
22
|
-
} from "@effect-agent/
|
|
22
|
+
} from "@effect-agent/thread";
|
|
23
23
|
import { Context, Effect, Layer, Result, Schema } from "effect";
|
|
24
24
|
import * as SqlClientService from "effect/unstable/sql/SqlClient";
|
|
25
25
|
|
|
26
|
-
const CURRENT_SCHEDULE_STORE_VERSION =
|
|
26
|
+
const CURRENT_SCHEDULE_STORE_VERSION = 2;
|
|
27
27
|
const MAX_STORED_SCHEDULE_BYTES = 1_900_000;
|
|
28
28
|
|
|
29
29
|
const StoredScheduleJson = Schema.String.check(Schema.isMaxLength(MAX_STORED_SCHEDULE_BYTES));
|
|
@@ -226,7 +226,11 @@ const initializeScheduleStore = Effect.fn("DoScheduleStore.initialize")(function
|
|
|
226
226
|
`.pipe(Effect.mapError(() => unavailable(operation)));
|
|
227
227
|
const state = yield* decodeRows(Schema.Array(ScheduleStoreStateRow), rawState, operation);
|
|
228
228
|
if (state.length !== 1 || state[0].storage_version !== CURRENT_SCHEDULE_STORE_VERSION) {
|
|
229
|
-
return yield* corrupt(
|
|
229
|
+
return yield* corrupt(
|
|
230
|
+
state.length === 1
|
|
231
|
+
? `${operation}: incompatible storage version ${state[0].storage_version}; expected ${CURRENT_SCHEDULE_STORE_VERSION}`
|
|
232
|
+
: `${operation}: invalid storage version row`,
|
|
233
|
+
);
|
|
230
234
|
}
|
|
231
235
|
});
|
|
232
236
|
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { Context, Effect, Layer, Ref } from "effect";
|
|
2
|
+
|
|
3
|
+
import { DoStorageFailpoint, type DoStorageFailpointHandler } from "./do-storage-failpoint.ts";
|
|
4
|
+
import type { DoStorageFailpointLocation } from "./errors.ts";
|
|
5
|
+
|
|
6
|
+
const noFailpoint: DoStorageFailpointHandler = () => Effect.void;
|
|
7
|
+
|
|
8
|
+
/** Test-only control for replacing the active Durable Object failpoint handler. */
|
|
9
|
+
export class DoStorageFailpointTestControl extends Context.Service<
|
|
10
|
+
DoStorageFailpointTestControl,
|
|
11
|
+
{
|
|
12
|
+
readonly clear: Effect.Effect<void>;
|
|
13
|
+
readonly setHandler: (handler: DoStorageFailpointHandler) => Effect.Effect<void>;
|
|
14
|
+
}
|
|
15
|
+
>()("@effect-agent/storage-cloudflare/DoStorageFailpointTestControl") {
|
|
16
|
+
/** Reusable test Layer with a control service backed by the same handler Ref. */
|
|
17
|
+
static readonly layer = Layer.effectContext(
|
|
18
|
+
Effect.gen(function* () {
|
|
19
|
+
const handler = yield* Ref.make<DoStorageFailpointHandler>(noFailpoint);
|
|
20
|
+
return Context.make(
|
|
21
|
+
DoStorageFailpoint,
|
|
22
|
+
DoStorageFailpoint.of({
|
|
23
|
+
hit: (location) => Ref.get(handler).pipe(Effect.flatMap((current) => current(location))),
|
|
24
|
+
}),
|
|
25
|
+
).pipe(
|
|
26
|
+
Context.add(
|
|
27
|
+
DoStorageFailpointTestControl,
|
|
28
|
+
DoStorageFailpointTestControl.of({
|
|
29
|
+
clear: Ref.set(handler, noFailpoint),
|
|
30
|
+
setHandler: (next) => Ref.set(handler, next),
|
|
31
|
+
}),
|
|
32
|
+
),
|
|
33
|
+
);
|
|
34
|
+
}),
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The DC-specific eviction failpoint mode: instead of failing typed, an armed hit evicts the
|
|
40
|
+
* Durable Object through an injected `evict` thunk — in production-shaped harnesses that thunk
|
|
41
|
+
* is `() => ctx.abort()`, the platform's real failure mode. `ctx.abort()` never returns (it
|
|
42
|
+
* throws while destroying the in-memory instance and every in-flight implicit or explicit
|
|
43
|
+
* storage transaction rolls back), so an armed hit ends the current Attempt exactly like an
|
|
44
|
+
* unannounced platform eviction; DO storage — the only correctness-critical state — survives
|
|
45
|
+
* for the next incarnation, which the persisted alarm wakes without any incoming request.
|
|
46
|
+
*
|
|
47
|
+
* The handles stay injected: this package never imports `cloudflare:workers`, so the harness
|
|
48
|
+
* that owns a `DurableObjectState` supplies the thunk.
|
|
49
|
+
*/
|
|
50
|
+
export const evictionFailpointHandler =
|
|
51
|
+
(options: {
|
|
52
|
+
readonly isArmed: (location: DoStorageFailpointLocation) => Effect.Effect<boolean>;
|
|
53
|
+
/** Kills the incarnation — e.g. `() => ctx.abort()`. Typed `void` because the platform
|
|
54
|
+
* declares `abort` as returning, but it throws while destroying the instance. */
|
|
55
|
+
readonly evict: (location: DoStorageFailpointLocation) => void;
|
|
56
|
+
}): DoStorageFailpointHandler =>
|
|
57
|
+
(location) =>
|
|
58
|
+
options.isArmed(location).pipe(
|
|
59
|
+
Effect.flatMap((armed) =>
|
|
60
|
+
armed
|
|
61
|
+
? // `ctx.abort()` throws while destroying the instance; that throw surfaces as a
|
|
62
|
+
// defect in the (already dying) incarnation. The defensive throw below keeps the
|
|
63
|
+
// guarantee — nothing after an armed hit may observe in-memory state — even if a
|
|
64
|
+
// harness supplies an evict thunk that returns.
|
|
65
|
+
Effect.sync((): never => {
|
|
66
|
+
options.evict(location);
|
|
67
|
+
throw new Error(
|
|
68
|
+
`Durable Object eviction did not interrupt execution at ${location}.`,
|
|
69
|
+
);
|
|
70
|
+
})
|
|
71
|
+
: Effect.void,
|
|
72
|
+
),
|
|
73
|
+
);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Context, Effect, Layer
|
|
1
|
+
import { Context, Effect, Layer } from "effect";
|
|
2
2
|
|
|
3
3
|
import type { DoStorageFailpointError, DoStorageFailpointLocation } from "./errors.ts";
|
|
4
4
|
|
|
@@ -8,15 +8,6 @@ export type DoStorageFailpointHandler = (
|
|
|
8
8
|
|
|
9
9
|
const noFailpoint: DoStorageFailpointHandler = () => Effect.void;
|
|
10
10
|
|
|
11
|
-
/** Test-only control for replacing the active Durable Object failpoint handler. */
|
|
12
|
-
export class DoStorageFailpointTestControl extends Context.Service<
|
|
13
|
-
DoStorageFailpointTestControl,
|
|
14
|
-
{
|
|
15
|
-
readonly clear: Effect.Effect<void>;
|
|
16
|
-
readonly setHandler: (handler: DoStorageFailpointHandler) => Effect.Effect<void>;
|
|
17
|
-
}
|
|
18
|
-
>()("@effect-agent/storage-cloudflare/DoStorageFailpointTestControl") {}
|
|
19
|
-
|
|
20
11
|
/** Explicit fault-injection authority used at Durable Object storage operation boundaries. */
|
|
21
12
|
export class DoStorageFailpoint extends Context.Service<
|
|
22
13
|
DoStorageFailpoint,
|
|
@@ -26,62 +17,4 @@ export class DoStorageFailpoint extends Context.Service<
|
|
|
26
17
|
>()("@effect-agent/storage-cloudflare/DoStorageFailpoint") {
|
|
27
18
|
/** Production default: no fault injection. */
|
|
28
19
|
static readonly layer = Layer.succeed(this)({ hit: noFailpoint });
|
|
29
|
-
|
|
30
|
-
/** Reusable test Layer with a control service backed by the same handler Ref. */
|
|
31
|
-
static readonly layerTest = Layer.effectContext(
|
|
32
|
-
Effect.gen(function* () {
|
|
33
|
-
const handler = yield* Ref.make<DoStorageFailpointHandler>(noFailpoint);
|
|
34
|
-
return Context.make(
|
|
35
|
-
DoStorageFailpoint,
|
|
36
|
-
DoStorageFailpoint.of({
|
|
37
|
-
hit: (location) => Ref.get(handler).pipe(Effect.flatMap((current) => current(location))),
|
|
38
|
-
}),
|
|
39
|
-
).pipe(
|
|
40
|
-
Context.add(
|
|
41
|
-
DoStorageFailpointTestControl,
|
|
42
|
-
DoStorageFailpointTestControl.of({
|
|
43
|
-
clear: Ref.set(handler, noFailpoint),
|
|
44
|
-
setHandler: (next) => Ref.set(handler, next),
|
|
45
|
-
}),
|
|
46
|
-
),
|
|
47
|
-
);
|
|
48
|
-
}),
|
|
49
|
-
);
|
|
50
20
|
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* The DC-specific eviction failpoint mode: instead of failing typed, an armed hit evicts the
|
|
54
|
-
* Durable Object through an injected `evict` thunk — in production-shaped harnesses that thunk
|
|
55
|
-
* is `() => ctx.abort()`, the platform's real failure mode. `ctx.abort()` never returns (it
|
|
56
|
-
* throws while destroying the in-memory instance and every in-flight implicit or explicit
|
|
57
|
-
* storage transaction rolls back), so an armed hit ends the current Attempt exactly like an
|
|
58
|
-
* unannounced platform eviction; DO storage — the only correctness-critical state — survives
|
|
59
|
-
* for the next incarnation, which the persisted alarm wakes without any incoming request.
|
|
60
|
-
*
|
|
61
|
-
* The handles stay injected: this package never imports `cloudflare:workers`, so the harness
|
|
62
|
-
* that owns a `DurableObjectState` supplies the thunk.
|
|
63
|
-
*/
|
|
64
|
-
export const evictionFailpointHandler =
|
|
65
|
-
(options: {
|
|
66
|
-
readonly isArmed: (location: DoStorageFailpointLocation) => Effect.Effect<boolean>;
|
|
67
|
-
/** Kills the incarnation — e.g. `() => ctx.abort()`. Typed `void` because the platform
|
|
68
|
-
* declares `abort` as returning, but it throws while destroying the instance. */
|
|
69
|
-
readonly evict: (location: DoStorageFailpointLocation) => void;
|
|
70
|
-
}): DoStorageFailpointHandler =>
|
|
71
|
-
(location) =>
|
|
72
|
-
options.isArmed(location).pipe(
|
|
73
|
-
Effect.flatMap((armed) =>
|
|
74
|
-
armed
|
|
75
|
-
? // `ctx.abort()` throws while destroying the instance; that throw surfaces as a
|
|
76
|
-
// defect in the (already dying) incarnation. The defensive throw below keeps the
|
|
77
|
-
// guarantee — nothing after an armed hit may observe in-memory state — even if a
|
|
78
|
-
// harness supplies an evict thunk that returns.
|
|
79
|
-
Effect.sync((): never => {
|
|
80
|
-
options.evict(location);
|
|
81
|
-
throw new Error(
|
|
82
|
-
`Durable Object eviction did not interrupt execution at ${location}.`,
|
|
83
|
-
);
|
|
84
|
-
})
|
|
85
|
-
: Effect.void,
|
|
86
|
-
),
|
|
87
|
-
);
|