@sekiban/dcb-runtime 0.2.0 → 0.2.2
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/README.md +32 -1
- package/dist/allocator/AllocatorDurableObject.js +25 -2
- package/dist/bootstrap/BootstrapCoordinatorDurableObject.d.ts +2 -0
- package/dist/bootstrap/BootstrapCoordinatorDurableObject.js +64 -1
- package/dist/bootstrap/OperatorBootstrap.d.ts +5 -1
- package/dist/bootstrap/OperatorBootstrap.js +17 -10
- package/dist/bootstrap/types.d.ts +6 -0
- package/dist/cli/OperatorRepairCli.d.ts +6 -1
- package/dist/cli/OperatorRepairCli.js +26 -6
- package/dist/cloudflare.d.ts +5 -0
- package/dist/cloudflare.js +1567 -465
- package/dist/commit/CommitWorker.d.ts +19 -0
- package/dist/commit/CommitWorker.js +176 -27
- package/dist/completeness/IncidentLifecycle.d.ts +75 -0
- package/dist/completeness/IncidentLifecycle.js +503 -0
- package/dist/completeness/types.d.ts +67 -0
- package/dist/cosmos.d.ts +34 -4
- package/dist/cosmos.js +214 -49
- package/dist/downstream/OutboxDrain.d.ts +4 -1
- package/dist/downstream/OutboxDrain.js +24 -2
- package/dist/generated/cosmos-layout.d.ts +87 -0
- package/dist/generated/cosmos-layout.js +99 -0
- package/dist/http/ConfiguredBearer.d.ts +12 -0
- package/dist/http/ConfiguredBearer.js +33 -0
- package/dist/http/IncidentMaintenance.d.ts +8 -0
- package/dist/http/IncidentMaintenance.js +124 -0
- package/dist/http/SerializedQueryWorker.js +37 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +1627 -465
- package/dist/read/SerializedReadWorker.d.ts +3 -0
- package/dist/read/SerializedReadWorker.js +3 -3
- package/dist/shard/ShardRotation.d.ts +48 -0
- package/dist/shard/ShardRotation.js +120 -0
- package/dist/store/CosmosEventStore.js +56 -46
- package/dist/store/PostgresEventStore.d.ts +20 -0
- package/dist/store/PostgresEventStore.js +53 -0
- package/dist/store/provider.d.ts +4 -0
- package/dist/tag/TagDurableObject.js +66 -14
- package/dist/tag/TagSqlSchema.d.ts +6 -1
- package/dist/tag/TagSqlSchema.js +15 -0
- package/dist/tag/types.d.ts +9 -0
- package/dist/trace/CommitTrace.d.ts +3 -3
- package/dist/trace/CommitTraceVerifier.js +1 -1
- package/package.json +6 -15
|
@@ -24,6 +24,17 @@ export interface CommitWorkerHooks {
|
|
|
24
24
|
/** Test-only barrier after entry admission is released and before /allocate. */
|
|
25
25
|
beforeBootstrapAllocation?(): Promise<void> | void;
|
|
26
26
|
beforeBootstrapFinalization?(): Promise<void> | void;
|
|
27
|
+
/**
|
|
28
|
+
* SDT-G36 test-only hook invoked after successful `finalize` and before
|
|
29
|
+
* `appendAllTags`. The bootstrap write permit is held across this hook.
|
|
30
|
+
*/
|
|
31
|
+
beforeAuthoritativeAppend?(): Promise<void> | void;
|
|
32
|
+
/**
|
|
33
|
+
* SDT-G36 test-only hook invoked after all appends resolved with no
|
|
34
|
+
* pending tags and before result assembly. Lets a test interleave a
|
|
35
|
+
* concurrent append to prove `tagWriteResults` are not re-read from head.
|
|
36
|
+
*/
|
|
37
|
+
afterAppendBeforeResponse?(): Promise<void> | void;
|
|
27
38
|
/** Test-only allocator identity; production uses the canonical allocator identity. */
|
|
28
39
|
allocatorScopeIdentity?: string;
|
|
29
40
|
/** Runtime composition value; never sourced from a caller-controlled V1 body. */
|
|
@@ -72,6 +83,14 @@ export declare class CommitWorker {
|
|
|
72
83
|
*/
|
|
73
84
|
private retiredJournalMilestone;
|
|
74
85
|
private bootstrapCommand;
|
|
86
|
+
/**
|
|
87
|
+
* SDT-G36 bootstrap write permit. Deliberately untraced: it must not add
|
|
88
|
+
* `sdt.commit/v2` rows or new trace identities. Only a bootstrap barrier,
|
|
89
|
+
* never commit-outcome authority. No JOURNAL, no alarm, no lease expiry.
|
|
90
|
+
*/
|
|
91
|
+
private bootstrapStub;
|
|
92
|
+
private bootstrapPermitAcquire;
|
|
93
|
+
private bootstrapPermitRelease;
|
|
75
94
|
private postJson;
|
|
76
95
|
private tagRequest;
|
|
77
96
|
private acquireReservations;
|
|
@@ -12,6 +12,25 @@ import { envServiceIdentity, requestServiceIdentity, } from "../service/ServiceI
|
|
|
12
12
|
const INITIAL_OWNER_EPOCH = 0;
|
|
13
13
|
const MAX_WRITE_ATTEMPTS = 2;
|
|
14
14
|
const OUTCOME_UNDETERMINED_ERROR = "Commit outcome is undetermined; reread tag heads and event/query state before retrying because blind retry may create duplicate events.";
|
|
15
|
+
/** SDT-G36 stable digest of the attempt tuple (tags + event ids + payloads). */
|
|
16
|
+
function commitPermitDigest(allTags, candidates) {
|
|
17
|
+
const normalized = JSON.stringify({
|
|
18
|
+
tags: [...allTags].sort(),
|
|
19
|
+
events: candidates
|
|
20
|
+
.map((candidate) => ({
|
|
21
|
+
eventId: candidate.eventId,
|
|
22
|
+
payload: candidate.payload,
|
|
23
|
+
tags: [...candidate.tags].sort(),
|
|
24
|
+
}))
|
|
25
|
+
.sort((left, right) => (left.eventId < right.eventId ? -1 : left.eventId > right.eventId ? 1 : 0)),
|
|
26
|
+
});
|
|
27
|
+
let hash = 0x811c9dc5;
|
|
28
|
+
for (let index = 0; index < normalized.length; index += 1) {
|
|
29
|
+
hash ^= normalized.charCodeAt(index);
|
|
30
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
31
|
+
}
|
|
32
|
+
return `fnv1a32:${hash.toString(16).padStart(8, "0")}`;
|
|
33
|
+
}
|
|
15
34
|
const GLOBAL_ADMISSION_HEADER = "x-sdt-global-admission";
|
|
16
35
|
/**
|
|
17
36
|
* The static authority checker proves the manifest bytes; this is the
|
|
@@ -440,30 +459,61 @@ export class CommitWorker {
|
|
|
440
459
|
// S05 trace identities are compatibility observations only; they cannot
|
|
441
460
|
// resolve JOURNAL and are removed from the actual dependency path.
|
|
442
461
|
await this.retiredJournalMilestone("S04", traceState?.scope);
|
|
462
|
+
// SDT-G36: durable bootstrap write permit. Acquired after admit/release
|
|
463
|
+
// and before allocate (before `beforeBootstrapAllocation`), held until
|
|
464
|
+
// every target tag of this attempt is durably resolved. It is only a
|
|
465
|
+
// bootstrap barrier, never commit-outcome authority. No JOURNAL, no
|
|
466
|
+
// alarm, no lease expiry.
|
|
467
|
+
const permitDigest = commitPermitDigest(input.allTags, candidates);
|
|
468
|
+
let permitHeld = false;
|
|
469
|
+
const releasePermit = async () => {
|
|
470
|
+
if (!permitHeld)
|
|
471
|
+
return;
|
|
472
|
+
permitHeld = false;
|
|
473
|
+
await this.bootstrapPermitRelease(attemptId, permitDigest);
|
|
474
|
+
};
|
|
475
|
+
const permit = await this.bootstrapPermitAcquire(attemptId, permitDigest);
|
|
476
|
+
if (!permit.ok) {
|
|
477
|
+
if (permit.code === "bootstrap_permit_digest_mismatch") {
|
|
478
|
+
return responseWithAttempt(error(409, "bootstrap_permit_digest_mismatch", "Bootstrap write permit digest does not match"), attemptId, fault !== undefined);
|
|
479
|
+
}
|
|
480
|
+
return responseWithAttempt(error(409, "bootstrap_command_rejected", "Bootstrap is importing; commit is unavailable"), attemptId, fault !== undefined);
|
|
481
|
+
}
|
|
482
|
+
permitHeld = this.env.BOOTSTRAP !== undefined;
|
|
443
483
|
const reservations = await this.acquireReservations(input, attemptId, fault, traceState?.scope);
|
|
444
484
|
await this.retiredJournalMilestone("S05a", traceState?.scope, 0);
|
|
445
485
|
if (reservations.failure !== undefined) {
|
|
446
|
-
|
|
486
|
+
const failure = await this.finishReservationFailure([...reservations.successes.keys()], attemptId, reservations.failure, fault, traceState?.scope);
|
|
487
|
+
if (failure.resolved)
|
|
488
|
+
await releasePermit();
|
|
489
|
+
return failure.response;
|
|
447
490
|
}
|
|
448
491
|
if (fault === "after-reservations-before-allocation") {
|
|
449
492
|
// Boundary 1: every durable prepare fact is force-tombstoned before
|
|
450
493
|
// returning the intentionally undetermined outcome. There is no
|
|
451
494
|
// attempt-level Journal recovery record to make this safe later.
|
|
452
|
-
await this.cancelReservations([...reservations.successes.keys()], attemptId, fault, traceState?.scope);
|
|
495
|
+
const cleanup = await this.cancelReservations([...reservations.successes.keys()], attemptId, fault, traceState?.scope);
|
|
496
|
+
if (cleanup.failedTags.length === 0)
|
|
497
|
+
await releasePermit();
|
|
453
498
|
return this.noApplicationOutcome(attemptId, true);
|
|
454
499
|
}
|
|
455
500
|
await this.hooks.beforeBootstrapAllocation?.();
|
|
456
501
|
const allocation = await this.allocate(candidates, attemptId, fault, bootstrapEpoch, traceState?.scope);
|
|
457
502
|
if (allocation === undefined) {
|
|
458
|
-
|
|
503
|
+
const failure = await this.finishReservationFailure([...reservations.successes.keys()], attemptId, {
|
|
459
504
|
outcome: "FAILED",
|
|
460
505
|
failureCause: "allocator-failure",
|
|
461
506
|
reason: "allocator could not durably allocate the complete vector",
|
|
462
507
|
}, fault, traceState?.scope);
|
|
508
|
+
if (failure.resolved)
|
|
509
|
+
await releasePermit();
|
|
510
|
+
return failure.response;
|
|
463
511
|
}
|
|
464
512
|
const allocatedCandidates = this.withAllocatedSuids(candidates, allocation);
|
|
465
513
|
if (allocatedCandidates === undefined) {
|
|
466
|
-
await this.cancelReservations([...reservations.successes.keys()], attemptId, fault, traceState?.scope);
|
|
514
|
+
const cleanup = await this.cancelReservations([...reservations.successes.keys()], attemptId, fault, traceState?.scope);
|
|
515
|
+
if (cleanup.failedTags.length === 0)
|
|
516
|
+
await releasePermit();
|
|
467
517
|
return this.noApplicationOutcome(attemptId, fault !== undefined);
|
|
468
518
|
}
|
|
469
519
|
// Allocation supplies the exact EventId/SUID pair needed by the durable
|
|
@@ -485,7 +535,9 @@ export class CommitWorker {
|
|
|
485
535
|
// This retained fault marks the allocation-to-first-append crash
|
|
486
536
|
// boundary. With no Journal alarm, tags receive the same best-effort
|
|
487
537
|
// tombstone barrier immediately and retain their own expiry alarm.
|
|
488
|
-
await this.cancelReservations(input.allTags, attemptId, fault, traceState?.scope);
|
|
538
|
+
const cleanup = await this.cancelReservations(input.allTags, attemptId, fault, traceState?.scope);
|
|
539
|
+
if (cleanup.failedTags.length === 0)
|
|
540
|
+
await releasePermit();
|
|
489
541
|
return this.noApplicationOutcome(attemptId, true);
|
|
490
542
|
}
|
|
491
543
|
await this.retiredJournalMilestone("S05c", traceState?.scope, 2);
|
|
@@ -493,9 +545,14 @@ export class CommitWorker {
|
|
|
493
545
|
// This is immediately before the first final authoritative tag mutation.
|
|
494
546
|
// The same service epoch obtained at admission must still be current.
|
|
495
547
|
if ((await this.bootstrapCommand("finalize", attemptId, bootstrapEpoch, traceState?.scope, "S10")) === undefined) {
|
|
496
|
-
await this.cancelReservations(input.allTags, attemptId, fault, traceState?.scope);
|
|
548
|
+
const cleanup = await this.cancelReservations(input.allTags, attemptId, fault, traceState?.scope);
|
|
549
|
+
if (cleanup.failedTags.length === 0)
|
|
550
|
+
await releasePermit();
|
|
497
551
|
return responseWithAttempt(error(409, "bootstrap_command_rejected", "Bootstrap fencing epoch changed before commit write"), attemptId, fault !== undefined);
|
|
498
552
|
}
|
|
553
|
+
// SDT-G36 test-only hook: the write permit is held across this barrier,
|
|
554
|
+
// so `POST /plan` must be 409 while the in-flight commit may still write.
|
|
555
|
+
await this.hooks.beforeAuthoritativeAppend?.();
|
|
499
556
|
const writes = await this.appendAllTags(input, allocatedCandidates, attemptId, allocation.allocatorLineageId, reservations.successes, fault, traceState?.scope);
|
|
500
557
|
if (writes.pendingTags.length > 0) {
|
|
501
558
|
// Cancel is deliberately best effort: a cancellation error cannot
|
|
@@ -506,26 +563,36 @@ export class CommitWorker {
|
|
|
506
563
|
// /fence/install loop: G44 can discover the incomplete source universe
|
|
507
564
|
// without a delivery, and G45/G46 retain a readable fenced frontier.
|
|
508
565
|
if (!await this.installPartialWriteFences(writes.pendingTags, attemptId, traceState?.scope)) {
|
|
566
|
+
// Resolution is not proven: the permit stays held and correctly
|
|
567
|
+
// blocks `plan`. Do not add recovery.
|
|
509
568
|
return this.noApplicationOutcome(attemptId, true);
|
|
510
569
|
}
|
|
511
570
|
if (writes.committedTags.size === 0 &&
|
|
512
571
|
writes.registrationUnavailableTags.length === writes.pendingTags.length) {
|
|
572
|
+
await releasePermit();
|
|
513
573
|
return responseWithAttempt(error(503, "partition_registration_unavailable", "Source partition registration is unavailable; retry the commit.", true), attemptId, fault !== undefined);
|
|
514
574
|
}
|
|
575
|
+
await releasePermit();
|
|
515
576
|
return this.partialWriteOutcome(input, allocatedCandidates, writes, attemptId, fault !== undefined);
|
|
516
577
|
}
|
|
578
|
+
// SDT-G36 test seam: a concurrent append here must not change the
|
|
579
|
+
// already-captured `tagWriteResults`.
|
|
580
|
+
await this.hooks.afterAppendBeforeResponse?.();
|
|
517
581
|
await this.retiredJournalMilestone("S05d", traceState?.scope, 3);
|
|
518
582
|
if (fault === "sealing-after-cas") {
|
|
519
583
|
// The all-tags-written/response-lost boundary has no single terminal
|
|
520
584
|
// Journal response. Durable tag receipts and G44 reconciliation are
|
|
521
585
|
// now the detection authority.
|
|
586
|
+
await releasePermit();
|
|
522
587
|
return this.noApplicationOutcome(attemptId, true);
|
|
523
588
|
}
|
|
524
589
|
try {
|
|
525
|
-
const success = await this.successResponse(input, allocatedCandidates, attemptId, startedAt, fault, traceState?.scope);
|
|
590
|
+
const success = await this.successResponse(input, allocatedCandidates, attemptId, startedAt, fault, traceState?.scope, writes);
|
|
591
|
+
await releasePermit();
|
|
526
592
|
return responseWithAttempt(withGlobalAdmission(json(success, 200), writes.globalAdmission), attemptId, fault !== undefined);
|
|
527
593
|
}
|
|
528
594
|
catch {
|
|
595
|
+
await releasePermit();
|
|
529
596
|
return responseWithAttempt(error(500, "internal_error", "Committed records could not be read while preparing the response"), attemptId, fault !== undefined);
|
|
530
597
|
}
|
|
531
598
|
}
|
|
@@ -571,6 +638,55 @@ export class CommitWorker {
|
|
|
571
638
|
: await traceScope.span(rowId, {}, invoke);
|
|
572
639
|
return result.leaseEpoch;
|
|
573
640
|
}
|
|
641
|
+
/**
|
|
642
|
+
* SDT-G36 bootstrap write permit. Deliberately untraced: it must not add
|
|
643
|
+
* `sdt.commit/v2` rows or new trace identities. Only a bootstrap barrier,
|
|
644
|
+
* never commit-outcome authority. No JOURNAL, no alarm, no lease expiry.
|
|
645
|
+
*/
|
|
646
|
+
bootstrapStub() {
|
|
647
|
+
if (this.env.BOOTSTRAP === undefined)
|
|
648
|
+
return undefined;
|
|
649
|
+
return this.env.BOOTSTRAP.get(scopeIdFor(this.env.BOOTSTRAP, {
|
|
650
|
+
serviceId: this.serviceId,
|
|
651
|
+
doClass: "bootstrap",
|
|
652
|
+
identity: "coordinator",
|
|
653
|
+
}));
|
|
654
|
+
}
|
|
655
|
+
async bootstrapPermitAcquire(commandId, digest) {
|
|
656
|
+
const stub = this.bootstrapStub();
|
|
657
|
+
if (stub === undefined)
|
|
658
|
+
return { ok: true };
|
|
659
|
+
try {
|
|
660
|
+
const url = new URL(`https://commit-worker.internal/command/permit`);
|
|
661
|
+
url.searchParams.set("__serviceId", this.serviceId);
|
|
662
|
+
const response = await stub.fetch(new Request(url, {
|
|
663
|
+
method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ commandId, digest }),
|
|
664
|
+
}));
|
|
665
|
+
if (response.ok)
|
|
666
|
+
return { ok: true };
|
|
667
|
+
const body = await response.clone().json().catch(() => undefined);
|
|
668
|
+
return { ok: false, code: typeof body?.code === "string" ? body.code : undefined };
|
|
669
|
+
}
|
|
670
|
+
catch {
|
|
671
|
+
return { ok: false };
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
async bootstrapPermitRelease(commandId, digest) {
|
|
675
|
+
const stub = this.bootstrapStub();
|
|
676
|
+
if (stub === undefined)
|
|
677
|
+
return;
|
|
678
|
+
try {
|
|
679
|
+
const url = new URL(`https://commit-worker.internal/command/permit-release`);
|
|
680
|
+
url.searchParams.set("__serviceId", this.serviceId);
|
|
681
|
+
await stub.fetch(new Request(url, {
|
|
682
|
+
method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ commandId, digest }),
|
|
683
|
+
}));
|
|
684
|
+
}
|
|
685
|
+
catch {
|
|
686
|
+
// Best effort: a lost release cannot replace the primary commit outcome.
|
|
687
|
+
// A permit left behind by a crash correctly blocks `plan`.
|
|
688
|
+
}
|
|
689
|
+
}
|
|
574
690
|
async postJson(stub, path, body, traceScope, rowId, traceOptions = {}) {
|
|
575
691
|
const invoke = async () => {
|
|
576
692
|
const response = await stub.fetch(new Request(`https://commit-worker.internal${path}`, {
|
|
@@ -730,6 +846,7 @@ export class CommitWorker {
|
|
|
730
846
|
let pending = [...input.allTags];
|
|
731
847
|
const committedTags = new Set();
|
|
732
848
|
const lastFailureCodeByTag = new Map();
|
|
849
|
+
const tagWriteFacts = new Map();
|
|
733
850
|
let globalAdmission = "admitted";
|
|
734
851
|
for (let attempt = 0; attempt < MAX_WRITE_ATTEMPTS && pending.length > 0; attempt += 1) {
|
|
735
852
|
const append = (stageScope) => Promise.allSettled(pending.map(async (tag) => {
|
|
@@ -761,14 +878,31 @@ export class CommitWorker {
|
|
|
761
878
|
retryIndex: attempt,
|
|
762
879
|
attemptId,
|
|
763
880
|
});
|
|
764
|
-
const
|
|
765
|
-
|
|
766
|
-
|
|
881
|
+
const success = response.status >= 200 && response.status < 300;
|
|
882
|
+
let appendVersion;
|
|
883
|
+
let appendUpdatedAt;
|
|
884
|
+
let code;
|
|
885
|
+
try {
|
|
886
|
+
const parsed = (await response.clone().json());
|
|
887
|
+
if (isObject(parsed)) {
|
|
888
|
+
if (typeof parsed.version === "number" && Number.isSafeInteger(parsed.version))
|
|
889
|
+
appendVersion = parsed.version;
|
|
890
|
+
if (typeof parsed.updatedAt === "string" && parsed.updatedAt.length > 0)
|
|
891
|
+
appendUpdatedAt = parsed.updatedAt;
|
|
892
|
+
if (typeof parsed.code === "string")
|
|
893
|
+
code = parsed.code;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
catch {
|
|
897
|
+
// Callers use the status first; malformed internal bodies are failures.
|
|
898
|
+
}
|
|
767
899
|
return {
|
|
768
900
|
tag,
|
|
769
|
-
success
|
|
901
|
+
success,
|
|
770
902
|
globalAdmission: readGlobalAdmission(response),
|
|
771
|
-
code
|
|
903
|
+
code,
|
|
904
|
+
appendVersion,
|
|
905
|
+
appendUpdatedAt,
|
|
772
906
|
};
|
|
773
907
|
}));
|
|
774
908
|
const results = traceScope === undefined
|
|
@@ -781,6 +915,9 @@ export class CommitWorker {
|
|
|
781
915
|
committedTags.add(tag);
|
|
782
916
|
lastFailureCodeByTag.delete(tag);
|
|
783
917
|
globalAdmission = mergeGlobalAdmission(globalAdmission, result.value.globalAdmission);
|
|
918
|
+
if (result.value.appendVersion !== undefined && result.value.appendUpdatedAt !== undefined) {
|
|
919
|
+
tagWriteFacts.set(tag, { version: result.value.appendVersion, updatedAt: result.value.appendUpdatedAt });
|
|
920
|
+
}
|
|
784
921
|
}
|
|
785
922
|
else {
|
|
786
923
|
lastFailureCodeByTag.set(tag, result.status === "fulfilled" ? result.value.code : undefined);
|
|
@@ -794,6 +931,7 @@ export class CommitWorker {
|
|
|
794
931
|
pendingTags: pending,
|
|
795
932
|
registrationUnavailableTags: pending.filter((tag) => lastFailureCodeByTag.get(tag) === "partition_registration_unavailable"),
|
|
796
933
|
globalAdmission,
|
|
934
|
+
tagWriteFacts,
|
|
797
935
|
};
|
|
798
936
|
}
|
|
799
937
|
/**
|
|
@@ -827,14 +965,17 @@ export class CommitWorker {
|
|
|
827
965
|
if (traceScope !== undefined) {
|
|
828
966
|
await traceScope.span("S17", {}, async () => undefined);
|
|
829
967
|
}
|
|
830
|
-
await this.cancelReservations(tags, attemptId, fault, traceScope);
|
|
968
|
+
const cleanup = await this.cancelReservations(tags, attemptId, fault, traceScope);
|
|
831
969
|
await this.retiredJournalMilestone("S05e", traceScope, 4);
|
|
832
970
|
const response = failure.outcome === "REFUSED"
|
|
833
971
|
? error(400, "consistency_conflict", "serialized commit was refused by a consistency reservation")
|
|
834
972
|
: failure.failureCause === "reservation-timeout"
|
|
835
973
|
? error(504, "timeout", "serialized commit timed out")
|
|
836
974
|
: error(500, "internal_error", "serialized commit failed before writing requested records");
|
|
837
|
-
return
|
|
975
|
+
return {
|
|
976
|
+
response: responseWithAttempt(response, attemptId, fault !== undefined),
|
|
977
|
+
resolved: cleanup.failedTags.length === 0,
|
|
978
|
+
};
|
|
838
979
|
}
|
|
839
980
|
/**
|
|
840
981
|
* Persist the local partial-write fact for each tag whose append did not
|
|
@@ -877,24 +1018,32 @@ export class CommitWorker {
|
|
|
877
1018
|
},
|
|
878
1019
|
}, 500), attemptId, exposeAttempt);
|
|
879
1020
|
}
|
|
880
|
-
async successResponse(input, candidates, attemptId, startedAt, fault, traceScope) {
|
|
881
|
-
|
|
1021
|
+
async successResponse(input, candidates, attemptId, startedAt, fault, traceScope, writes) {
|
|
1022
|
+
// SDT-G36: `tagWriteResults` come from the `/append` bodies captured in
|
|
1023
|
+
// `writes.tagWriteFacts`. The success path never re-reads `/head-facts`
|
|
1024
|
+
// or `/state`. S13/S14 stay as compatibility observations only.
|
|
1025
|
+
const assemble = async (stageScope) => {
|
|
882
1026
|
if (fault === "tag-state-unavailable") {
|
|
883
1027
|
throw new Error("Test fault made the committed tag state unavailable");
|
|
884
1028
|
}
|
|
885
|
-
const
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
1029
|
+
const results = [];
|
|
1030
|
+
for (const [memberIndex, tag] of input.allTags.entries()) {
|
|
1031
|
+
const read = () => {
|
|
1032
|
+
const fact = writes?.tagWriteFacts.get(tag);
|
|
1033
|
+
if (fact === undefined) {
|
|
1034
|
+
throw new Error("Committed tag state was unavailable while preparing the response");
|
|
1035
|
+
}
|
|
1036
|
+
return { tag, version: fact.version, writtenAt: fact.updatedAt };
|
|
1037
|
+
};
|
|
1038
|
+
results.push(stageScope === undefined
|
|
1039
|
+
? read()
|
|
1040
|
+
: await stageScope.fork().span("S14", { tag, memberIndex, attemptId }, async () => read()));
|
|
892
1041
|
}
|
|
893
|
-
return
|
|
894
|
-
}
|
|
1042
|
+
return results;
|
|
1043
|
+
};
|
|
895
1044
|
const tagWriteResults = traceScope === undefined
|
|
896
|
-
? await
|
|
897
|
-
: await traceScope.span("S13", {}, async (stage) =>
|
|
1045
|
+
? await assemble()
|
|
1046
|
+
: await traceScope.span("S13", {}, async (stage) => assemble(stage));
|
|
898
1047
|
return {
|
|
899
1048
|
writtenEvents: candidates.map((candidate) => ({
|
|
900
1049
|
payload: encodeBase64Utf8(candidate.payload),
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { IncidentLifecycleProjection, IncidentLifecycleState, IncidentTransitionRecord, IncidentTransitionRequest } from "./types";
|
|
2
|
+
declare const LIFECYCLE_STATES: readonly IncidentLifecycleState[];
|
|
3
|
+
export interface IncidentListFilters {
|
|
4
|
+
readonly state?: IncidentLifecycleState;
|
|
5
|
+
readonly owner?: string;
|
|
6
|
+
readonly unowned?: boolean;
|
|
7
|
+
readonly overdue?: boolean;
|
|
8
|
+
readonly observedAfterClose?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export interface IncidentFinding {
|
|
11
|
+
readonly serviceId: string;
|
|
12
|
+
readonly incidentIdentity: string;
|
|
13
|
+
readonly incidentType: string;
|
|
14
|
+
readonly partitionTag: string | null;
|
|
15
|
+
readonly obligationSequence: number | null;
|
|
16
|
+
readonly eventId: string | null;
|
|
17
|
+
readonly eventDigest: string | null;
|
|
18
|
+
readonly state: "OPEN" | "UNRESOLVED";
|
|
19
|
+
readonly firstObservedAt: number;
|
|
20
|
+
readonly lastObservedAt: number;
|
|
21
|
+
}
|
|
22
|
+
export interface IncidentListItem {
|
|
23
|
+
readonly finding: IncidentFinding;
|
|
24
|
+
readonly lifecycle: IncidentLifecycleProjection;
|
|
25
|
+
readonly overdue: boolean;
|
|
26
|
+
readonly unowned: boolean;
|
|
27
|
+
readonly observedAfterClose: boolean;
|
|
28
|
+
}
|
|
29
|
+
export interface IncidentListResult {
|
|
30
|
+
readonly items: readonly IncidentListItem[];
|
|
31
|
+
readonly summary: Readonly<{
|
|
32
|
+
total: number;
|
|
33
|
+
byState: Readonly<Record<IncidentLifecycleState, number>>;
|
|
34
|
+
unowned: number;
|
|
35
|
+
overdue: number;
|
|
36
|
+
observedAfterClose: number;
|
|
37
|
+
}>;
|
|
38
|
+
}
|
|
39
|
+
export interface IncidentDetailResult {
|
|
40
|
+
readonly finding: IncidentFinding;
|
|
41
|
+
readonly lifecycle: IncidentLifecycleProjection;
|
|
42
|
+
readonly flags: Readonly<{
|
|
43
|
+
overdue: boolean;
|
|
44
|
+
unowned: boolean;
|
|
45
|
+
observedAfterClose: boolean;
|
|
46
|
+
}>;
|
|
47
|
+
readonly transitions: readonly IncidentTransitionRecord[];
|
|
48
|
+
}
|
|
49
|
+
export interface IncidentTransitionResult {
|
|
50
|
+
readonly projection: IncidentLifecycleProjection;
|
|
51
|
+
readonly transition: IncidentTransitionRecord;
|
|
52
|
+
readonly idempotent: boolean;
|
|
53
|
+
}
|
|
54
|
+
export declare class IncidentLifecycleError extends Error {
|
|
55
|
+
readonly code: string;
|
|
56
|
+
readonly status: number;
|
|
57
|
+
constructor(code: string, status: number, message: string);
|
|
58
|
+
}
|
|
59
|
+
export declare function normalizeIncidentIdentity(value: unknown): string;
|
|
60
|
+
export declare class IncidentLifecycle {
|
|
61
|
+
private readonly database;
|
|
62
|
+
private readonly now;
|
|
63
|
+
constructor(database: D1Database, now?: () => number);
|
|
64
|
+
static parseTransition(value: unknown): IncidentTransitionRequest;
|
|
65
|
+
private finding;
|
|
66
|
+
private lifecycle;
|
|
67
|
+
private transitions;
|
|
68
|
+
private closeTime;
|
|
69
|
+
private item;
|
|
70
|
+
list(serviceId: string, filters?: IncidentListFilters, at?: number): Promise<IncidentListResult>;
|
|
71
|
+
detail(serviceId: string, incidentIdentity: string, at?: number): Promise<IncidentDetailResult>;
|
|
72
|
+
transition(serviceId: string, value: unknown, actor: string): Promise<IncidentTransitionResult>;
|
|
73
|
+
private nextProjection;
|
|
74
|
+
}
|
|
75
|
+
export { LIFECYCLE_STATES };
|