@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
|
@@ -8,6 +8,9 @@ interface ReadWorkerEnv {
|
|
|
8
8
|
TAG_STATE?: DurableObjectNamespace;
|
|
9
9
|
POSTGRES_URL?: string;
|
|
10
10
|
HYPERDRIVE?: Hyperdrive;
|
|
11
|
+
COSMOS_ENDPOINT?: string;
|
|
12
|
+
COSMOS_DATABASE?: string;
|
|
13
|
+
COSMOS_KEY?: string;
|
|
11
14
|
/** Optional explicit D1 provider binding; Postgres remains the default. */
|
|
12
15
|
D1?: D1Database;
|
|
13
16
|
/** Set only by an authenticated deployment-verification lane. */
|
|
@@ -174,10 +174,10 @@ export class SerializedReadWorker {
|
|
|
174
174
|
// Direct unit tests can exercise Tag DO determinacy with only TAG. The
|
|
175
175
|
// default provider has no backing store in that fixture. An explicit
|
|
176
176
|
// provider (including Cosmos) is always checked when it is configured.
|
|
177
|
-
if (this.storeProvider === undefined || this.storeProvider.isConfigured?.(this.env) === false) {
|
|
178
|
-
return;
|
|
179
|
-
}
|
|
180
177
|
try {
|
|
178
|
+
if (this.storeProvider === undefined || this.storeProvider.isConfigured?.(this.env) === false) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
181
|
const store = this.storeProvider.create(this.env);
|
|
182
182
|
await store.initialize();
|
|
183
183
|
const nowMs = Date.now();
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export type SealedShard = Readonly<{
|
|
2
|
+
id: string;
|
|
3
|
+
lastSuid: string;
|
|
4
|
+
}>;
|
|
5
|
+
export type LateArrival = Readonly<{
|
|
6
|
+
shardId: string;
|
|
7
|
+
suid: string;
|
|
8
|
+
reason: "sealed-range";
|
|
9
|
+
}>;
|
|
10
|
+
export type ShardRotationState = Readonly<{
|
|
11
|
+
sealed: readonly SealedShard[];
|
|
12
|
+
active: string;
|
|
13
|
+
ledger: readonly LateArrival[];
|
|
14
|
+
}>;
|
|
15
|
+
export type ShardRotationDiagnostic = Readonly<{
|
|
16
|
+
code: "ORDER_INVARIANT" | "SEALED_WRITE" | "IDENTITY_SWAP" | "ACTIVE_COUNT" | "SEAL_MAX_MISSING" | "UNKNOWN_SHARD" | "SILENT_MERGE" | "SAFEWINDOW_SEAL";
|
|
17
|
+
path: string;
|
|
18
|
+
reason: string;
|
|
19
|
+
}>;
|
|
20
|
+
export declare class ShardRotationError extends Error {
|
|
21
|
+
readonly diagnostic: ShardRotationDiagnostic;
|
|
22
|
+
constructor(diagnostic: ShardRotationDiagnostic);
|
|
23
|
+
}
|
|
24
|
+
export declare function createRotationState(active: string): ShardRotationState;
|
|
25
|
+
/** Fail closed unless last(D1-1) < first(D1-2). */
|
|
26
|
+
export declare function assertStrictlyBefore(lastD1_1: string, firstD1_2: string): void;
|
|
27
|
+
export declare function maxSortableUniqueId(values: readonly string[]): string;
|
|
28
|
+
/**
|
|
29
|
+
* Normal rotation: derive last(D1-1) as the maximum SortableUniqueId present in
|
|
30
|
+
* the shard being sealed, append that sealed entry in order, then point active
|
|
31
|
+
* at exactly one new shard id that is not already sealed.
|
|
32
|
+
*/
|
|
33
|
+
export declare function rotateAppend(state: ShardRotationState, sealedId: string, shardSuids: readonly string[], nextActiveId: string): ShardRotationState;
|
|
34
|
+
export declare function replaceSealedIdentity(state: ShardRotationState, index: number, nextId: string): never;
|
|
35
|
+
export declare function refuseSealedWrite(state: ShardRotationState, shardId: string): void;
|
|
36
|
+
/**
|
|
37
|
+
* First or later write into the active shard after a seal. Requires
|
|
38
|
+
* last(D1-1) < candidate. Does not write when the gate fails.
|
|
39
|
+
*/
|
|
40
|
+
export declare function admitActiveWrite(state: ShardRotationState, shardId: string, firstOrNextSuid: string): void;
|
|
41
|
+
/**
|
|
42
|
+
* Late arrival under a sealed range. Appended only to the side ledger.
|
|
43
|
+
* Never writes back into the sealed shard.
|
|
44
|
+
*/
|
|
45
|
+
export declare function recordLateArrival(state: ShardRotationState, sealedShardId: string, suid: string): ShardRotationState;
|
|
46
|
+
export declare function assertNoSilentLedgerMerge(source: string): void;
|
|
47
|
+
export declare function refuseSilentLedgerMerge(): never;
|
|
48
|
+
export declare function refuseSafeWindowSeal(): never;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { assertSortableUniqueId, compareSortableUniqueId } from "../allocator/SortableUniqueId";
|
|
2
|
+
export class ShardRotationError extends Error {
|
|
3
|
+
diagnostic;
|
|
4
|
+
constructor(diagnostic) {
|
|
5
|
+
super(diagnostic.code);
|
|
6
|
+
this.name = "ShardRotationError";
|
|
7
|
+
this.diagnostic = diagnostic;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
function fail(code, path, reason) {
|
|
11
|
+
throw new ShardRotationError({ code, path, reason });
|
|
12
|
+
}
|
|
13
|
+
export function createRotationState(active) {
|
|
14
|
+
if (typeof active !== "string" || active.length === 0)
|
|
15
|
+
fail("ACTIVE_COUNT", "active", "active-missing");
|
|
16
|
+
return { sealed: [], active, ledger: [] };
|
|
17
|
+
}
|
|
18
|
+
/** Fail closed unless last(D1-1) < first(D1-2). */
|
|
19
|
+
export function assertStrictlyBefore(lastD1_1, firstD1_2) {
|
|
20
|
+
assertSortableUniqueId(lastD1_1);
|
|
21
|
+
assertSortableUniqueId(firstD1_2);
|
|
22
|
+
if (compareSortableUniqueId(lastD1_1, firstD1_2) >= 0) {
|
|
23
|
+
fail("ORDER_INVARIANT", "last(D1-1)<first(D1-2)", "order-invariant");
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function maxSortableUniqueId(values) {
|
|
27
|
+
if (values.length === 0)
|
|
28
|
+
fail("SEAL_MAX_MISSING", "d1-1", "seal-max-missing");
|
|
29
|
+
let max = assertSortableUniqueId(values[0]).value;
|
|
30
|
+
for (let i = 1; i < values.length; i += 1) {
|
|
31
|
+
const next = assertSortableUniqueId(values[i]).value;
|
|
32
|
+
if (next > max)
|
|
33
|
+
max = next;
|
|
34
|
+
}
|
|
35
|
+
return max;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Normal rotation: derive last(D1-1) as the maximum SortableUniqueId present in
|
|
39
|
+
* the shard being sealed, append that sealed entry in order, then point active
|
|
40
|
+
* at exactly one new shard id that is not already sealed.
|
|
41
|
+
*/
|
|
42
|
+
export function rotateAppend(state, sealedId, shardSuids, nextActiveId) {
|
|
43
|
+
if (sealedId !== state.active)
|
|
44
|
+
fail("UNKNOWN_SHARD", sealedId, "unknown-shard");
|
|
45
|
+
if (typeof nextActiveId !== "string" || nextActiveId.length === 0 || nextActiveId === sealedId) {
|
|
46
|
+
fail("ACTIVE_COUNT", "active", "active-count");
|
|
47
|
+
}
|
|
48
|
+
if (state.sealed.some((entry) => entry.id === nextActiveId)) {
|
|
49
|
+
fail("ACTIVE_COUNT", nextActiveId, "active-already-sealed");
|
|
50
|
+
}
|
|
51
|
+
if (state.sealed.some((entry) => entry.id === sealedId))
|
|
52
|
+
fail("IDENTITY_SWAP", sealedId, "identity-swap");
|
|
53
|
+
const lastSuid = maxSortableUniqueId(shardSuids);
|
|
54
|
+
return {
|
|
55
|
+
sealed: [...state.sealed, { id: sealedId, lastSuid }],
|
|
56
|
+
active: nextActiveId,
|
|
57
|
+
ledger: state.ledger,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export function replaceSealedIdentity(state, index, nextId) {
|
|
61
|
+
if (index < 0 || index >= state.sealed.length)
|
|
62
|
+
fail("UNKNOWN_SHARD", "sealed", "unknown-shard");
|
|
63
|
+
fail("IDENTITY_SWAP", state.sealed[index].id, `identity-swap:${nextId}`);
|
|
64
|
+
}
|
|
65
|
+
export function refuseSealedWrite(state, shardId) {
|
|
66
|
+
if (state.sealed.some((entry) => entry.id === shardId))
|
|
67
|
+
fail("SEALED_WRITE", shardId, "sealed-write");
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* First or later write into the active shard after a seal. Requires
|
|
71
|
+
* last(D1-1) < candidate. Does not write when the gate fails.
|
|
72
|
+
*/
|
|
73
|
+
export function admitActiveWrite(state, shardId, firstOrNextSuid) {
|
|
74
|
+
assertSortableUniqueId(firstOrNextSuid);
|
|
75
|
+
refuseSealedWrite(state, shardId);
|
|
76
|
+
if (shardId !== state.active)
|
|
77
|
+
fail("UNKNOWN_SHARD", shardId, "unknown-shard");
|
|
78
|
+
if (state.sealed.length === 0)
|
|
79
|
+
return;
|
|
80
|
+
const previous = state.sealed[state.sealed.length - 1];
|
|
81
|
+
assertStrictlyBefore(previous.lastSuid, firstOrNextSuid);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Late arrival under a sealed range. Appended only to the side ledger.
|
|
85
|
+
* Never writes back into the sealed shard.
|
|
86
|
+
*/
|
|
87
|
+
export function recordLateArrival(state, sealedShardId, suid) {
|
|
88
|
+
assertSortableUniqueId(suid);
|
|
89
|
+
const sealed = state.sealed.find((entry) => entry.id === sealedShardId);
|
|
90
|
+
if (sealed === undefined)
|
|
91
|
+
fail("UNKNOWN_SHARD", sealedShardId, "unknown-shard");
|
|
92
|
+
if (compareSortableUniqueId(suid, sealed.lastSuid) > 0) {
|
|
93
|
+
fail("ORDER_INVARIANT", sealedShardId, "not-late-arrival");
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
...state,
|
|
97
|
+
ledger: [...state.ledger, { shardId: sealed.id, suid, reason: "sealed-range" }],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
const SILENT_MERGE_TOKENS = [
|
|
101
|
+
["merge", "Ledger", "Into", "Sealed"],
|
|
102
|
+
["merge", "OnRead", "Sealed"],
|
|
103
|
+
["silent", "Merge", "Ledger"],
|
|
104
|
+
["ledger", ".concat(", "sealed"],
|
|
105
|
+
["sealed", ".concat(", "ledger"],
|
|
106
|
+
];
|
|
107
|
+
export function assertNoSilentLedgerMerge(source) {
|
|
108
|
+
const lowered = source.toLowerCase();
|
|
109
|
+
for (const token of SILENT_MERGE_TOKENS) {
|
|
110
|
+
const needle = token.join("").toLowerCase();
|
|
111
|
+
if (lowered.includes(needle))
|
|
112
|
+
fail("SILENT_MERGE", "ledger", "silent-merge");
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
export function refuseSilentLedgerMerge() {
|
|
116
|
+
fail("SILENT_MERGE", "ledger", "silent-merge");
|
|
117
|
+
}
|
|
118
|
+
export function refuseSafeWindowSeal() {
|
|
119
|
+
fail("SAFEWINDOW_SEAL", "safe-window", "safewindow-seal");
|
|
120
|
+
}
|
|
@@ -2,44 +2,43 @@ import { resolveDeliveryIdentity } from "../eventIdentity";
|
|
|
2
2
|
import { assertSortableUniqueId } from "../allocator/SortableUniqueId";
|
|
3
3
|
import { CANONICAL_UTC_TIMESTAMP_PATTERN, isRfc4122Uuid, isUuidV7, serializedEventMetadata } from "../eventRecord";
|
|
4
4
|
import { decayedLagEstimateMs } from "../safeWindow";
|
|
5
|
+
import { COSMOS_CONTAINER_LAYOUT, COSMOS_CONTAINER_NAMES, COSMOS_DOCUMENT_ID_RULES, } from "../generated/cosmos-layout";
|
|
5
6
|
import { CanonicalEventIdentityConflictError, } from "./types";
|
|
6
7
|
/** Sekiban.Dcb CosmosEvent's immutable logical-record partition. */
|
|
7
|
-
export const COSMOS_EVENT_PARTITION_KEY_PATH =
|
|
8
|
+
export const COSMOS_EVENT_PARTITION_KEY_PATH = COSMOS_CONTAINER_LAYOUT.events.partitionKeyPath;
|
|
8
9
|
/** TS-only auxiliary documents retain a service-local partition. */
|
|
9
|
-
export const COSMOS_AUXILIARY_PARTITION_KEY_PATH =
|
|
10
|
+
export const COSMOS_AUXILIARY_PARTITION_KEY_PATH = COSMOS_CONTAINER_LAYOUT.lagEstimates.partitionKeyPath;
|
|
10
11
|
/** @deprecated Kept as an alias for auxiliary-container callers. */
|
|
11
12
|
export const COSMOS_PARTITION_KEY_PATH = COSMOS_AUXILIARY_PARTITION_KEY_PATH;
|
|
12
|
-
export const DEFAULT_COSMOS_CONTAINERS =
|
|
13
|
-
events: "dcb-events",
|
|
14
|
-
lagEstimates: "dcb-lag-estimates",
|
|
15
|
-
pendingArrivals: "dcb-pending-arrivals",
|
|
16
|
-
findings: "dcb-findings",
|
|
17
|
-
checkpoints: "dcb-projection-checkpoints",
|
|
18
|
-
});
|
|
13
|
+
export const DEFAULT_COSMOS_CONTAINERS = COSMOS_CONTAINER_NAMES;
|
|
19
14
|
/**
|
|
20
15
|
* The layout is data rather than a comment so every adapter initialization and
|
|
21
16
|
* contract fixture can inspect the same five service-partitioned containers.
|
|
22
17
|
*/
|
|
23
18
|
export function cosmosContainerDefinitions(containers = DEFAULT_COSMOS_CONTAINERS) {
|
|
24
|
-
return
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
19
|
+
return Object.keys(COSMOS_CONTAINER_LAYOUT).map((key) => {
|
|
20
|
+
const layout = COSMOS_CONTAINER_LAYOUT[key];
|
|
21
|
+
const name = containers[key];
|
|
22
|
+
if (key === "events") {
|
|
23
|
+
return {
|
|
24
|
+
name,
|
|
25
|
+
partitionKeyPath: layout.partitionKeyPath,
|
|
26
|
+
// The C# CosmosEvent is partitioned by the exact serviceId|id pair.
|
|
27
|
+
// Refuse a missing id here so an introspection fixture cannot mistake
|
|
28
|
+
// an auxiliary service partition for the logical event partition.
|
|
29
|
+
partitionKeyValue: (serviceId, id) => {
|
|
30
|
+
if (typeof id !== "string" || id.length === 0)
|
|
31
|
+
throw new Error("Cosmos event partition key requires an event id");
|
|
32
|
+
return eventPk(serviceId, id);
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
38
37
|
name,
|
|
39
|
-
partitionKeyPath:
|
|
38
|
+
partitionKeyPath: layout.partitionKeyPath,
|
|
40
39
|
partitionKeyValue: (serviceId) => serviceId,
|
|
41
|
-
}
|
|
42
|
-
|
|
40
|
+
};
|
|
41
|
+
});
|
|
43
42
|
}
|
|
44
43
|
export class CosmosClientError extends Error {
|
|
45
44
|
status;
|
|
@@ -103,6 +102,13 @@ export function compareCosmosSuid(left, right) {
|
|
|
103
102
|
function safeId(...parts) {
|
|
104
103
|
return parts.map((part) => encodeURIComponent(part)).join("~");
|
|
105
104
|
}
|
|
105
|
+
function rulePrefix(rule, fallback) {
|
|
106
|
+
return rule.match(/^safeId\("([^"]+)"/)?.[1] ?? fallback;
|
|
107
|
+
}
|
|
108
|
+
const EVENT_OPS_ID_PREFIX = rulePrefix(COSMOS_DOCUMENT_ID_RULES.events.eventSidecar, "event-ops");
|
|
109
|
+
const LINEAGE_ID_PREFIX = rulePrefix(COSMOS_DOCUMENT_ID_RULES.events.serviceGuards.allocatorLineageBinding, "allocator-lineage-binding");
|
|
110
|
+
const SUID_ID_PREFIX = rulePrefix(COSMOS_DOCUMENT_ID_RULES.events.serviceGuards.suidBinding, "suid-binding");
|
|
111
|
+
const INCIDENT_ID_PREFIX = rulePrefix(COSMOS_DOCUMENT_ID_RULES.events.serviceGuards.deliveryIncident, "incident");
|
|
106
112
|
/** The exact CosmosEvent partition-key derivation from Sekiban.Dcb. */
|
|
107
113
|
function eventPk(serviceId, id) {
|
|
108
114
|
return `${serviceId}|${id}`;
|
|
@@ -362,19 +368,23 @@ export class CosmosRestClient {
|
|
|
362
368
|
return buffer;
|
|
363
369
|
})(), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
364
370
|
const signature = encodeURIComponent(`type=master&ver=1.0&sig=${base64(await crypto.subtle.sign("HMAC", cryptoKey, new TextEncoder().encode(signaturePayload)))}`);
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
371
|
+
try {
|
|
372
|
+
return await this.fetcher(new URL(path, this.endpoint), {
|
|
373
|
+
method,
|
|
374
|
+
headers: {
|
|
375
|
+
accept: "application/json",
|
|
376
|
+
"content-type": "application/json",
|
|
377
|
+
"x-ms-date": date,
|
|
378
|
+
"x-ms-version": "2018-12-31",
|
|
379
|
+
authorization: signature,
|
|
380
|
+
...extraHeaders,
|
|
381
|
+
},
|
|
382
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
catch {
|
|
386
|
+
throw new CosmosClientError(0, "Cosmos request failed before a response was received");
|
|
387
|
+
}
|
|
378
388
|
}
|
|
379
389
|
async errorFrom(response, prefix) {
|
|
380
390
|
// Never include response bodies: Cosmos errors can echo account or query
|
|
@@ -655,7 +665,7 @@ export class CosmosEventStore {
|
|
|
655
665
|
}
|
|
656
666
|
async hasDeliveryIncident(serviceId, identityKey) {
|
|
657
667
|
this.ready();
|
|
658
|
-
return (await this.client.read(this.containers.events, safeId(
|
|
668
|
+
return (await this.client.read(this.containers.events, safeId(INCIDENT_ID_PREFIX, identityKey), auxiliaryPk(serviceId))) !== undefined;
|
|
659
669
|
}
|
|
660
670
|
async listDeliveryIncidents(serviceId) {
|
|
661
671
|
this.ready();
|
|
@@ -680,7 +690,7 @@ export class CosmosEventStore {
|
|
|
680
690
|
}
|
|
681
691
|
async bindLineage(serviceId, allocatorLineageId, boundAt) {
|
|
682
692
|
const document = {
|
|
683
|
-
id: safeId(
|
|
693
|
+
id: safeId(LINEAGE_ID_PREFIX),
|
|
684
694
|
pk: auxiliaryPk(serviceId),
|
|
685
695
|
serviceId,
|
|
686
696
|
kind: "allocator-lineage-binding",
|
|
@@ -702,7 +712,7 @@ export class CosmosEventStore {
|
|
|
702
712
|
* mutation. Historical rows are still checked by eventDocuments above.
|
|
703
713
|
*/
|
|
704
714
|
async reserveSuid(serviceId, suid, eventId) {
|
|
705
|
-
const id = safeId(
|
|
715
|
+
const id = safeId(SUID_ID_PREFIX, suid);
|
|
706
716
|
const existing = await this.client.read(this.containers.events, id, auxiliaryPk(serviceId));
|
|
707
717
|
if (existing !== undefined)
|
|
708
718
|
return existing.document.eventId;
|
|
@@ -728,7 +738,7 @@ export class CosmosEventStore {
|
|
|
728
738
|
}
|
|
729
739
|
async persistIncident(incident) {
|
|
730
740
|
const document = {
|
|
731
|
-
id: safeId(
|
|
741
|
+
id: safeId(INCIDENT_ID_PREFIX, incident.identityKey),
|
|
732
742
|
pk: auxiliaryPk(incident.serviceId),
|
|
733
743
|
serviceId: incident.serviceId,
|
|
734
744
|
kind: "delivery-incident",
|
|
@@ -753,7 +763,7 @@ export class CosmosEventStore {
|
|
|
753
763
|
}
|
|
754
764
|
async projectIncident(incident) {
|
|
755
765
|
const document = {
|
|
756
|
-
id: safeId(
|
|
766
|
+
id: safeId(INCIDENT_ID_PREFIX, incident.identityKey),
|
|
757
767
|
serviceId: incident.serviceId,
|
|
758
768
|
eventId: incident.eventId ?? incident.incomingEventId ?? incident.identityKey,
|
|
759
769
|
path: incident.classification,
|
|
@@ -799,7 +809,7 @@ export class CosmosEventStore {
|
|
|
799
809
|
return rows;
|
|
800
810
|
}
|
|
801
811
|
async eventOps(serviceId, eventId) {
|
|
802
|
-
const row = await this.client.read(this.containers.events, safeId(
|
|
812
|
+
const row = await this.client.read(this.containers.events, safeId(EVENT_OPS_ID_PREFIX, eventId), eventPk(serviceId, eventId));
|
|
803
813
|
return row?.document;
|
|
804
814
|
}
|
|
805
815
|
async mutateEvent(message, eventTags, eventType, timestamp, metadata) {
|
|
@@ -847,7 +857,7 @@ export class CosmosEventStore {
|
|
|
847
857
|
throw new Error("Cosmos event CAS did not converge");
|
|
848
858
|
}
|
|
849
859
|
async mutateEventOps(message, arrivedAt, lagMs) {
|
|
850
|
-
const id = safeId(
|
|
860
|
+
const id = safeId(EVENT_OPS_ID_PREFIX, message.eventId);
|
|
851
861
|
const pk = eventPk(message.serviceId, message.eventId);
|
|
852
862
|
const nextArrival = {
|
|
853
863
|
serviceId: message.serviceId,
|
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
import type { DeliverySource, DownstreamOutboxMessage } from "../downstream/types";
|
|
2
2
|
import { type DeliveryIncident, type DeliveryOutcome, type DetectorStore, type EventStore, type InconsistencyClassification, type InconsistencyFinding, type PendingArrivalRecord, type ProjectionCheckpoint, type ProjectionCheckpointAdvance, type ProjectionLag, type ProjectionStore, type StoredEvent } from "./types";
|
|
3
|
+
export interface LogicalProviderEvent {
|
|
4
|
+
readonly serviceId: string;
|
|
5
|
+
readonly id: string;
|
|
6
|
+
readonly sortableUniqueId: string;
|
|
7
|
+
readonly eventType: string;
|
|
8
|
+
readonly payload: string;
|
|
9
|
+
readonly tags: readonly string[];
|
|
10
|
+
readonly timestamp: string;
|
|
11
|
+
readonly causationId: string | null;
|
|
12
|
+
readonly correlationId: string | null;
|
|
13
|
+
readonly executedUser: string | null;
|
|
14
|
+
}
|
|
3
15
|
/**
|
|
4
16
|
* `postgres` exposes an ESM/workerd build. The deployed Worker passes a
|
|
5
17
|
* Hyperdrive connection string; local Miniflare passes Docker PostgreSQL.
|
|
@@ -12,6 +24,14 @@ export declare class PostgresEventStore implements EventStore, DetectorStore, Pr
|
|
|
12
24
|
/** Close an owned request/test client without touching durable records. */
|
|
13
25
|
close(): Promise<void>;
|
|
14
26
|
recordDelivery(message: DownstreamOutboxMessage, arrivedAt: number, deliverySource?: DeliverySource): Promise<DeliveryOutcome>;
|
|
27
|
+
/**
|
|
28
|
+
* DDL columns only. C# rows have no provenance and no allocator lineage;
|
|
29
|
+
* this read must not invent either, and it must not treat Tags as
|
|
30
|
+
* committed membership.
|
|
31
|
+
*/
|
|
32
|
+
readLogicalEvents(serviceId: string): Promise<LogicalProviderEvent[]>;
|
|
33
|
+
/** Insert one DDL row. Does not write dcb_event_ops or a provenance stamp. */
|
|
34
|
+
appendLogicalEvent(event: LogicalProviderEvent): Promise<void>;
|
|
15
35
|
readAllEvents(serviceId: string, since: string): Promise<StoredEvent[]>;
|
|
16
36
|
currentLagBound(serviceId: string, nowMs?: number): Promise<number>;
|
|
17
37
|
listProjectionTags(serviceId: string): Promise<string[]>;
|
|
@@ -480,6 +480,59 @@ export class PostgresEventStore {
|
|
|
480
480
|
? { outcome, kind: "stored", event: stored }
|
|
481
481
|
: { outcome, kind: outcome, incident: incident };
|
|
482
482
|
}
|
|
483
|
+
/**
|
|
484
|
+
* DDL columns only. C# rows have no provenance and no allocator lineage;
|
|
485
|
+
* this read must not invent either, and it must not treat Tags as
|
|
486
|
+
* committed membership.
|
|
487
|
+
*/
|
|
488
|
+
async readLogicalEvents(serviceId) {
|
|
489
|
+
const rows = await this.query(`SELECT "ServiceId" AS service_id, "Id"::text AS event_id,
|
|
490
|
+
"SortableUniqueId" AS suid, "Payload"::text AS payload,
|
|
491
|
+
"EventType" AS event_type, "Tags" AS event_tags,
|
|
492
|
+
"Timestamp"::text AS timestamp, "CausationId" AS causation_id,
|
|
493
|
+
"CorrelationId" AS correlation_id, "ExecutedUser" AS executed_user
|
|
494
|
+
FROM dcb_events
|
|
495
|
+
WHERE "ServiceId" = $1
|
|
496
|
+
ORDER BY "SortableUniqueId" ASC, "Id" ASC`, [serviceId]);
|
|
497
|
+
return rows.map((row) => ({
|
|
498
|
+
serviceId: asString(row.service_id, "service_id"),
|
|
499
|
+
id: asString(row.event_id, "event_id"),
|
|
500
|
+
sortableUniqueId: asString(row.suid, "suid"),
|
|
501
|
+
eventType: asString(row.event_type, "event_type"),
|
|
502
|
+
payload: asString(row.payload, "payload"),
|
|
503
|
+
tags: asStringArray(row.event_tags, "event_tags"),
|
|
504
|
+
timestamp: canonicalUtcTimestamp(row.timestamp, "timestamp"),
|
|
505
|
+
causationId: nullableString(row.causation_id, "causation_id"),
|
|
506
|
+
correlationId: nullableString(row.correlation_id, "correlation_id"),
|
|
507
|
+
executedUser: nullableString(row.executed_user, "executed_user"),
|
|
508
|
+
}));
|
|
509
|
+
}
|
|
510
|
+
/** Insert one DDL row. Does not write dcb_event_ops or a provenance stamp. */
|
|
511
|
+
async appendLogicalEvent(event) {
|
|
512
|
+
JSON.parse(event.payload);
|
|
513
|
+
assertSortableUniqueId(event.sortableUniqueId);
|
|
514
|
+
if (!isRfc4122Uuid(event.id)) {
|
|
515
|
+
throw new CanonicalEventIdentityConflictError("postgres", event.id, "logical event id must be an RFC 4122 UUID");
|
|
516
|
+
}
|
|
517
|
+
assertUtcTimestamp(event.timestamp, event.id);
|
|
518
|
+
const sql = this.requireSql();
|
|
519
|
+
await sql.begin(async (transaction) => {
|
|
520
|
+
await transaction.unsafe(`INSERT INTO dcb_events
|
|
521
|
+
("ServiceId", "Id", "SortableUniqueId", "EventType", "Payload", "Tags", "Timestamp", "CausationId", "CorrelationId", "ExecutedUser")
|
|
522
|
+
VALUES ($1, $2::uuid, $3, $4, $5::text::json, $6::text::jsonb, $7::timestamptz, $8, $9, $10)`, [
|
|
523
|
+
event.serviceId,
|
|
524
|
+
event.id,
|
|
525
|
+
event.sortableUniqueId,
|
|
526
|
+
event.eventType,
|
|
527
|
+
event.payload,
|
|
528
|
+
JSON.stringify(event.tags),
|
|
529
|
+
event.timestamp,
|
|
530
|
+
event.causationId,
|
|
531
|
+
event.correlationId,
|
|
532
|
+
event.executedUser,
|
|
533
|
+
]);
|
|
534
|
+
});
|
|
535
|
+
}
|
|
483
536
|
async readAllEvents(serviceId, since) {
|
|
484
537
|
if (since.length !== 0)
|
|
485
538
|
assertSortableUniqueId(since);
|
package/dist/store/provider.d.ts
CHANGED
|
@@ -6,6 +6,10 @@ export interface StoreProviderEnvironment {
|
|
|
6
6
|
readonly HYPERDRIVE?: {
|
|
7
7
|
readonly connectionString?: string;
|
|
8
8
|
};
|
|
9
|
+
/** Experimental Cosmos deployment values; the key is a secret binding. */
|
|
10
|
+
readonly COSMOS_ENDPOINT?: string;
|
|
11
|
+
readonly COSMOS_DATABASE?: string;
|
|
12
|
+
readonly COSMOS_KEY?: string;
|
|
9
13
|
/** Explicit opt-in D1 binding; the default provider never requires it. */
|
|
10
14
|
readonly D1?: D1Database;
|
|
11
15
|
/** Separate opt-in MV D1 binding; never used by the PipelineStore provider. */
|
|
@@ -7,7 +7,7 @@ import { CANONICAL_UTC_TIMESTAMP_PATTERN, isRfc4122Uuid, isUuidV7, serializedEve
|
|
|
7
7
|
import { DurableObjectActivation, enterNativeActorHandleSpan, noOpNativeTracing, } from "../trace/CommitTrace";
|
|
8
8
|
import { beginDurableObjectHandlerObservation, } from "../trace/ObservationStream";
|
|
9
9
|
import { canonicalDeclaredTagSet, eventDigestBytes, eventDigestHex } from "./EventDigest";
|
|
10
|
-
import { hasTagSqlStorage, initializeTagSqlSchema, TAG_READ_AFTER_SQL, TAG_READ_AFTER_THROUGH_SQL, } from "./TagSqlSchema";
|
|
10
|
+
import { ensureTagCommitReceiptWrittenVersion, hasTagSqlStorage, initializeTagSqlSchema, TAG_READ_AFTER_SQL, TAG_READ_AFTER_THROUGH_SQL, } from "./TagSqlSchema";
|
|
11
11
|
import { G43SqlMeasurement } from "./TagSqlMeasurement";
|
|
12
12
|
import { recordDurableHop } from "../diagnostics/G60DurableHop";
|
|
13
13
|
import { recordG65AdmissionAttempt } from "../diagnostics/G65Admission";
|
|
@@ -1723,6 +1723,7 @@ export class TagDurableObject {
|
|
|
1723
1723
|
const sql = this.sqlStorage();
|
|
1724
1724
|
if (sql === undefined)
|
|
1725
1725
|
throw new Error("Tag SQL storage is unavailable");
|
|
1726
|
+
ensureTagCommitReceiptWrittenVersion(sql);
|
|
1726
1727
|
const existing = sql.exec("SELECT tag FROM tag_identity WHERE singleton = 1").toArray()[0];
|
|
1727
1728
|
if (existing !== undefined) {
|
|
1728
1729
|
if (sqlString(existing.tag, "tag_identity.tag") !== tag)
|
|
@@ -1807,17 +1808,18 @@ export class TagDurableObject {
|
|
|
1807
1808
|
sql.exec("UPDATE tag_control SET head_suid = ?, version = ?, updated_at = ? WHERE singleton = 1", head, version, committedAt);
|
|
1808
1809
|
sql.exec("UPDATE tag_head SET service_id = ?, head_suid = ? WHERE singleton = 1", serviceId, head);
|
|
1809
1810
|
}
|
|
1810
|
-
writeCommittedSqlReceipt(sql, input, committedAt, eventCount, head, confirmsReservation) {
|
|
1811
|
+
writeCommittedSqlReceipt(sql, input, committedAt, eventCount, head, confirmsReservation, writtenVersion) {
|
|
1811
1812
|
sql.exec(`
|
|
1812
1813
|
INSERT INTO tag_commit_receipt
|
|
1813
|
-
(attempt_id, epoch, committed_at, committed_event_count, head_suid, reservation_confirmed)
|
|
1814
|
-
VALUES (?, ?, ?, ?, ?, ?)
|
|
1814
|
+
(attempt_id, epoch, committed_at, committed_event_count, head_suid, reservation_confirmed, written_version)
|
|
1815
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1815
1816
|
ON CONFLICT(attempt_id, epoch) DO UPDATE SET
|
|
1816
1817
|
committed_at = excluded.committed_at,
|
|
1817
1818
|
committed_event_count = excluded.committed_event_count,
|
|
1818
1819
|
head_suid = excluded.head_suid,
|
|
1819
|
-
reservation_confirmed = excluded.reservation_confirmed
|
|
1820
|
-
|
|
1820
|
+
reservation_confirmed = excluded.reservation_confirmed,
|
|
1821
|
+
written_version = excluded.written_version
|
|
1822
|
+
`, input.attemptId, input.epoch, committedAt, eventCount, head, confirmsReservation ? 1 : 0, writtenVersion);
|
|
1821
1823
|
}
|
|
1822
1824
|
/**
|
|
1823
1825
|
* The normal append path deliberately probes only the indexed rows it needs
|
|
@@ -1868,8 +1870,35 @@ export class TagDurableObject {
|
|
|
1868
1870
|
return { event, obligation, exact };
|
|
1869
1871
|
});
|
|
1870
1872
|
// Exact duplicate replay precedes all epoch/token checks by contract.
|
|
1873
|
+
// SDT-G36: replay the stored write transaction's own pair. Never mint
|
|
1874
|
+
// a timestamp and never substitute the current head version.
|
|
1871
1875
|
if (existingCandidates.every(({ exact }) => exact)) {
|
|
1872
|
-
|
|
1876
|
+
// Exact duplicate is before the epoch check, so a replay may name a
|
|
1877
|
+
// different epoch than the write that stored the receipt. Prefer the
|
|
1878
|
+
// caller's epoch, then the receipt whose head is this batch, then the
|
|
1879
|
+
// sole receipt for the attempt. A missing or NULL written_version
|
|
1880
|
+
// still fails closed.
|
|
1881
|
+
const receipts = sql.exec(`
|
|
1882
|
+
SELECT epoch, committed_at, written_version, head_suid FROM tag_commit_receipt
|
|
1883
|
+
WHERE attempt_id = ?
|
|
1884
|
+
`, input.attemptId).toArray();
|
|
1885
|
+
const sameEpoch = receipts.find((row) => sqlNumber(row.epoch, "tag_commit_receipt.epoch") === input.epoch);
|
|
1886
|
+
const replayHead = input.candidates.at(-1)?.suid;
|
|
1887
|
+
const byHead = replayHead === undefined
|
|
1888
|
+
? []
|
|
1889
|
+
: receipts.filter((row) => sqlString(row.head_suid, "tag_commit_receipt.head_suid") === replayHead);
|
|
1890
|
+
const receipt = sameEpoch ?? (byHead.length === 1 ? byHead[0] : undefined) ?? (receipts.length === 1 ? receipts[0] : undefined);
|
|
1891
|
+
if (receipt === undefined) {
|
|
1892
|
+
await this.rearmScheduler(txn);
|
|
1893
|
+
return { ...rejected("duplicate_receipt_missing"), body: { ...rejected("duplicate_receipt_missing").body, version } };
|
|
1894
|
+
}
|
|
1895
|
+
const storedAt = sqlNullableString(receipt.committed_at, "tag_commit_receipt.committed_at");
|
|
1896
|
+
const storedVersion = sqlNullableNumber(receipt.written_version, "tag_commit_receipt.written_version");
|
|
1897
|
+
if (storedAt === null || storedVersion === null) {
|
|
1898
|
+
await this.rearmScheduler(txn);
|
|
1899
|
+
return { ...rejected("duplicate_receipt_unknown"), body: { ...rejected("duplicate_receipt_unknown").body, version } };
|
|
1900
|
+
}
|
|
1901
|
+
return { status: 200, body: { status: "duplicate", version: storedVersion, updatedAt: storedAt } };
|
|
1873
1902
|
}
|
|
1874
1903
|
const active = sql.exec("SELECT * FROM tag_reservation WHERE singleton = 1").toArray()[0];
|
|
1875
1904
|
const now = sqlNullableNumber(control.clock_now_ms, "tag_control.clock_now_ms") ??
|
|
@@ -1976,7 +2005,7 @@ export class TagDurableObject {
|
|
|
1976
2005
|
if (input.faultInjection === "after-append-before-confirm") {
|
|
1977
2006
|
throw new AppendTransactionFault("Simulated interruption before atomic commit receipt");
|
|
1978
2007
|
}
|
|
1979
|
-
this.writeCommittedSqlReceipt(sql, input, committedAt, events.length, head, confirmsReservation);
|
|
2008
|
+
this.writeCommittedSqlReceipt(sql, input, committedAt, events.length, head, confirmsReservation, version + 1);
|
|
1980
2009
|
if (confirmsReservation)
|
|
1981
2010
|
sql.exec("DELETE FROM tag_reservation WHERE singleton = 1");
|
|
1982
2011
|
await this.rearmScheduler(txn);
|
|
@@ -1986,6 +2015,7 @@ export class TagDurableObject {
|
|
|
1986
2015
|
status: "appended",
|
|
1987
2016
|
fenceGate: { checked: true, activeFenceCount: 0 },
|
|
1988
2017
|
version: version + 1,
|
|
2018
|
+
updatedAt: committedAt,
|
|
1989
2019
|
},
|
|
1990
2020
|
hopFacts,
|
|
1991
2021
|
};
|
|
@@ -2360,12 +2390,13 @@ export class TagDurableObject {
|
|
|
2360
2390
|
`, eventServiceId, outbox.eventId, outbox.attemptId, outbox.suid, outbox.payload, outbox.allocatorLineageId, outbox.eventType, outbox.provenance, outbox.timestamp, artifact.canonicalBytes, artifact.eventDigest, artifact.declaredTagSet, artifact.localMembership, Date.now());
|
|
2361
2391
|
this.upsertSourcePartitionRegistration(sql, eventServiceId, record.tag, outbox.eventId);
|
|
2362
2392
|
}
|
|
2393
|
+
ensureTagCommitReceiptWrittenVersion(sql);
|
|
2363
2394
|
for (const confirmation of record.confirmations) {
|
|
2364
2395
|
sql.exec(`
|
|
2365
2396
|
INSERT OR IGNORE INTO tag_commit_receipt
|
|
2366
|
-
(attempt_id, epoch, committed_at, committed_event_count, head_suid, reservation_confirmed)
|
|
2367
|
-
VALUES (?, ?, ?, ?, ?, 1)
|
|
2368
|
-
`, confirmation.attemptId, confirmation.epoch, record.updatedAt, record.events.filter((event) => event.attemptId === confirmation.attemptId).length, record.head);
|
|
2397
|
+
(attempt_id, epoch, committed_at, committed_event_count, head_suid, reservation_confirmed, written_version)
|
|
2398
|
+
VALUES (?, ?, ?, ?, ?, 1, ?)
|
|
2399
|
+
`, confirmation.attemptId, confirmation.epoch, record.updatedAt, record.events.filter((event) => event.attemptId === confirmation.attemptId).length, record.head, record.version);
|
|
2369
2400
|
}
|
|
2370
2401
|
}
|
|
2371
2402
|
nextSqlAlarmDue() {
|
|
@@ -2764,7 +2795,21 @@ export class TagDurableObject {
|
|
|
2764
2795
|
let record = loaded.record;
|
|
2765
2796
|
// Required order begins here: exact duplicate is before any epoch or token check.
|
|
2766
2797
|
if (batchIsExactDuplicate(record, input)) {
|
|
2767
|
-
|
|
2798
|
+
const receipts = record.writeReceipts ?? {};
|
|
2799
|
+
const sameEpoch = receipts[`${input.attemptId}:${input.epoch}`];
|
|
2800
|
+
const forAttempt = Object.entries(receipts).filter(([key]) => key.startsWith(`${input.attemptId}:`));
|
|
2801
|
+
const replayHead = input.candidates.at(-1)?.suid;
|
|
2802
|
+
const byHead = replayHead === undefined
|
|
2803
|
+
? []
|
|
2804
|
+
: forAttempt.filter(([, value]) => value.headSuid === replayHead);
|
|
2805
|
+
const receipt = sameEpoch ?? (byHead.length === 1 ? byHead[0]?.[1] : undefined) ?? (forAttempt.length === 1 ? forAttempt[0]?.[1] : undefined);
|
|
2806
|
+
if (receipt === undefined) {
|
|
2807
|
+
return {
|
|
2808
|
+
...rejected("duplicate_receipt_unknown"),
|
|
2809
|
+
body: { ...rejected("duplicate_receipt_unknown").body, version: record.version },
|
|
2810
|
+
};
|
|
2811
|
+
}
|
|
2812
|
+
return { status: 200, body: { status: "duplicate", version: receipt.version, updatedAt: receipt.updatedAt } };
|
|
2768
2813
|
}
|
|
2769
2814
|
const expiry = expireReservation(record);
|
|
2770
2815
|
record = expiry.record;
|
|
@@ -2852,10 +2897,17 @@ export class TagDurableObject {
|
|
|
2852
2897
|
? withMaxEpoch(record.confirmations, input.attemptId, input.epoch)
|
|
2853
2898
|
: record.confirmations,
|
|
2854
2899
|
});
|
|
2855
|
-
|
|
2900
|
+
const recorded = {
|
|
2901
|
+
...updated,
|
|
2902
|
+
writeReceipts: {
|
|
2903
|
+
...(record.writeReceipts ?? {}),
|
|
2904
|
+
[`${input.attemptId}:${input.epoch}`]: { version: updated.version, updatedAt: updated.updatedAt, headSuid: updated.head },
|
|
2905
|
+
},
|
|
2906
|
+
};
|
|
2907
|
+
await this.write(txn, recorded, serviceId ?? "");
|
|
2856
2908
|
return {
|
|
2857
2909
|
status: 201,
|
|
2858
|
-
body: { status: "appended", fenceGate, version:
|
|
2910
|
+
body: { status: "appended", fenceGate, version: recorded.version, updatedAt: recorded.updatedAt },
|
|
2859
2911
|
};
|
|
2860
2912
|
});
|
|
2861
2913
|
const response = json(result.body, result.status);
|