@sekiban/dcb-runtime 0.2.0
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/LICENSE +86 -0
- package/README.md +13 -0
- package/dist/allocator/AllocatorDurableObject.d.ts +36 -0
- package/dist/allocator/AllocatorDurableObject.js +658 -0
- package/dist/allocator/IssuanceLedger.d.ts +188 -0
- package/dist/allocator/IssuanceLedger.js +608 -0
- package/dist/allocator/IssuanceReconciler.d.ts +41 -0
- package/dist/allocator/IssuanceReconciler.js +191 -0
- package/dist/allocator/OrderClock.d.ts +41 -0
- package/dist/allocator/OrderClock.js +92 -0
- package/dist/allocator/SortableUniqueId.d.ts +46 -0
- package/dist/allocator/SortableUniqueId.js +156 -0
- package/dist/allocator/types.d.ts +52 -0
- package/dist/allocator/types.js +1 -0
- package/dist/bootstrap/BootstrapCoordinatorDurableObject.d.ts +28 -0
- package/dist/bootstrap/BootstrapCoordinatorDurableObject.js +357 -0
- package/dist/bootstrap/BootstrapStoreAdapter.d.ts +51 -0
- package/dist/bootstrap/BootstrapStoreAdapter.js +162 -0
- package/dist/bootstrap/OperatorBootstrap.d.ts +20 -0
- package/dist/bootstrap/OperatorBootstrap.js +104 -0
- package/dist/bootstrap/manifest.d.ts +9 -0
- package/dist/bootstrap/manifest.js +115 -0
- package/dist/bootstrap/types.d.ts +75 -0
- package/dist/bootstrap/types.js +1 -0
- package/dist/cli/OperatorRepairCli.d.ts +11 -0
- package/dist/cli/OperatorRepairCli.js +108 -0
- package/dist/cloudflare.d.ts +164 -0
- package/dist/cloudflare.js +19487 -0
- package/dist/commit/CommitWorker.d.ts +101 -0
- package/dist/commit/CommitWorker.js +921 -0
- package/dist/commit/types.d.ts +25 -0
- package/dist/commit/types.js +13 -0
- package/dist/completeness/GlobalCompletenessReconciler.d.ts +77 -0
- package/dist/completeness/GlobalCompletenessReconciler.js +572 -0
- package/dist/completeness/types.d.ts +85 -0
- package/dist/completeness/types.js +6 -0
- package/dist/composition.d.ts +123 -0
- package/dist/composition.js +237 -0
- package/dist/cosmos.d.ts +11 -0
- package/dist/cosmos.js +1411 -0
- package/dist/d1-mv.d.ts +8 -0
- package/dist/d1-mv.js +2001 -0
- package/dist/d1.d.ts +16 -0
- package/dist/d1.js +3418 -0
- package/dist/diagnostics/G60DurableHop.d.ts +99 -0
- package/dist/diagnostics/G60DurableHop.js +248 -0
- package/dist/diagnostics/G65Admission.d.ts +23 -0
- package/dist/diagnostics/G65Admission.js +43 -0
- package/dist/diagnostics/G65DirectRing.d.ts +33 -0
- package/dist/diagnostics/G65DirectRing.js +85 -0
- package/dist/diagnostics/G69AdmissionAttempt.d.ts +43 -0
- package/dist/diagnostics/G69AdmissionAttempt.js +22 -0
- package/dist/downstream/DeliveryCore.d.ts +127 -0
- package/dist/downstream/DeliveryCore.js +304 -0
- package/dist/downstream/Doorbell.d.ts +43 -0
- package/dist/downstream/Doorbell.js +137 -0
- package/dist/downstream/DownstreamAdapter.d.ts +52 -0
- package/dist/downstream/DownstreamAdapter.js +299 -0
- package/dist/downstream/ExclusionLookup.d.ts +36 -0
- package/dist/downstream/ExclusionLookup.js +50 -0
- package/dist/downstream/InconsistencyDetector.d.ts +27 -0
- package/dist/downstream/InconsistencyDetector.js +82 -0
- package/dist/downstream/OutboxDrain.d.ts +35 -0
- package/dist/downstream/OutboxDrain.js +126 -0
- package/dist/downstream/types.d.ts +77 -0
- package/dist/downstream/types.js +64 -0
- package/dist/eventIdentity.d.ts +30 -0
- package/dist/eventIdentity.js +49 -0
- package/dist/eventRecord.d.ts +26 -0
- package/dist/eventRecord.js +53 -0
- package/dist/g42-probe.d.ts +9 -0
- package/dist/g42-probe.js +9 -0
- package/dist/http/SerializedQueryWorker.d.ts +58 -0
- package/dist/http/SerializedQueryWorker.js +497 -0
- package/dist/http/commitResponse.d.ts +46 -0
- package/dist/http/commitResponse.js +80 -0
- package/dist/http/testServiceId.d.ts +17 -0
- package/dist/http/testServiceId.js +14 -0
- package/dist/index.d.ts +112 -0
- package/dist/index.js +19926 -0
- package/dist/journal/JournalDurableObject.d.ts +30 -0
- package/dist/journal/JournalDurableObject.js +481 -0
- package/dist/journal/JournalFirstTouchProbe.d.ts +190 -0
- package/dist/journal/JournalFirstTouchProbe.js +375 -0
- package/dist/journal/types.d.ts +116 -0
- package/dist/journal/types.js +52 -0
- package/dist/mv/MaterializedViewCatchUp.d.ts +60 -0
- package/dist/mv/MaterializedViewCatchUp.js +353 -0
- package/dist/mv/MaterializedViewRuntime.d.ts +80 -0
- package/dist/mv/MaterializedViewRuntime.js +328 -0
- package/dist/mv/MaterializedViewStore.d.ts +257 -0
- package/dist/mv/MaterializedViewStore.js +875 -0
- package/dist/mv/UnsafeWindowMaterializedView.d.ts +112 -0
- package/dist/mv/UnsafeWindowMaterializedView.js +495 -0
- package/dist/mv/index.d.ts +8 -0
- package/dist/mv/index.js +2357 -0
- package/dist/projection/LiveProjectionWorker.d.ts +86 -0
- package/dist/projection/LiveProjectionWorker.js +240 -0
- package/dist/projection/ProjectionRuntime.d.ts +113 -0
- package/dist/projection/ProjectionRuntime.js +346 -0
- package/dist/projection/ProjectorRegistry.d.ts +51 -0
- package/dist/projection/ProjectorRegistry.js +116 -0
- package/dist/query/ProjectionQueryStore.d.ts +102 -0
- package/dist/query/ProjectionQueryStore.js +185 -0
- package/dist/query/QueryRegistry.d.ts +22 -0
- package/dist/query/QueryRegistry.js +36 -0
- package/dist/read/SerializedReadWorker.d.ts +36 -0
- package/dist/read/SerializedReadWorker.js +225 -0
- package/dist/repair/RepairWorker.d.ts +72 -0
- package/dist/repair/RepairWorker.js +461 -0
- package/dist/safeWindow.d.ts +20 -0
- package/dist/safeWindow.js +36 -0
- package/dist/scope/ControlRouteScope.d.ts +22 -0
- package/dist/scope/ControlRouteScope.js +36 -0
- package/dist/scope/ScopeName.d.ts +34 -0
- package/dist/scope/ScopeName.js +67 -0
- package/dist/service/ServiceIdentityProvider.d.ts +35 -0
- package/dist/service/ServiceIdentityProvider.js +56 -0
- package/dist/store/CosmosEventStore.d.ts +126 -0
- package/dist/store/CosmosEventStore.js +950 -0
- package/dist/store/D1EventStore.d.ts +111 -0
- package/dist/store/D1EventStore.js +1111 -0
- package/dist/store/PostgresEventStore.d.ts +35 -0
- package/dist/store/PostgresEventStore.js +811 -0
- package/dist/store/provider.d.ts +25 -0
- package/dist/store/provider.js +32 -0
- package/dist/store/types.d.ts +205 -0
- package/dist/store/types.js +18 -0
- package/dist/tag/EventDigest.d.ts +40 -0
- package/dist/tag/EventDigest.js +158 -0
- package/dist/tag/TagDurableObject.d.ts +286 -0
- package/dist/tag/TagDurableObject.js +4071 -0
- package/dist/tag/TagSqlMeasurement.d.ts +26 -0
- package/dist/tag/TagSqlMeasurement.js +44 -0
- package/dist/tag/TagSqlSchema.d.ts +29 -0
- package/dist/tag/TagSqlSchema.js +211 -0
- package/dist/tag/types.d.ts +173 -0
- package/dist/tag/types.js +3 -0
- package/dist/tagstate/TagStateDurableObject.d.ts +57 -0
- package/dist/tagstate/TagStateDurableObject.js +499 -0
- package/dist/trace/AttributionRatio.d.ts +12 -0
- package/dist/trace/AttributionRatio.js +68 -0
- package/dist/trace/CloudflareTracing.d.ts +7 -0
- package/dist/trace/CloudflareTracing.js +9 -0
- package/dist/trace/CommitTrace.d.ts +405 -0
- package/dist/trace/CommitTrace.js +1008 -0
- package/dist/trace/CommitTraceConsoleSink.d.ts +68 -0
- package/dist/trace/CommitTraceConsoleSink.js +114 -0
- package/dist/trace/CommitTraceVerifier.d.ts +22 -0
- package/dist/trace/CommitTraceVerifier.js +295 -0
- package/dist/trace/ObservationStream.d.ts +118 -0
- package/dist/trace/ObservationStream.js +148 -0
- package/package.json +71 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { BindingExclusionLedgerClient } from "./ExclusionLookup";
|
|
2
|
+
import { InconsistencyDetector } from "./InconsistencyDetector";
|
|
3
|
+
import { processDeliveryCore, } from "./DeliveryCore";
|
|
4
|
+
import { isDownstreamOutboxMessage, systemPipelineClock, } from "./types";
|
|
5
|
+
import { GlobalCompletenessReconciler, globalReceiptAcknowledgement } from "../completeness/GlobalCompletenessReconciler";
|
|
6
|
+
import { observeG60PostAdmission } from "../diagnostics/G60DurableHop";
|
|
7
|
+
import { scopeIdFor } from "../scope/ScopeName";
|
|
8
|
+
import { envServiceIdentity, requireServiceIdentity } from "../service/ServiceIdentityProvider";
|
|
9
|
+
function observeSourceSubstep(observer, input) {
|
|
10
|
+
observeG60PostAdmission(observer, {
|
|
11
|
+
stage: "source-tag-acknowledgement",
|
|
12
|
+
boundary: input.boundary,
|
|
13
|
+
outcome: input.outcome,
|
|
14
|
+
serviceId: input.message.serviceId,
|
|
15
|
+
eventId: input.message.eventId,
|
|
16
|
+
suid: input.message.suid,
|
|
17
|
+
attemptId: input.message.attemptId,
|
|
18
|
+
partitionTag: input.message.tag,
|
|
19
|
+
transport: input.source,
|
|
20
|
+
observedAt: Date.now(),
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
function sourceAcknowledgementOptions(env, options) {
|
|
24
|
+
// D1 itself is the G44 global-array authority. Do not add a rollout flag:
|
|
25
|
+
// this is an in-place development schema, not a mixed-version protocol.
|
|
26
|
+
const hasG44Authority = env.D1 !== undefined && env.TAG !== undefined;
|
|
27
|
+
const sourceAcknowledgement = options.afterGlobalReceipt !== undefined || !hasG44Authority
|
|
28
|
+
? options.afterGlobalReceipt
|
|
29
|
+
: async ({ message, receipt, arrivedAt }) => {
|
|
30
|
+
const tag = env.TAG.get(scopeIdFor(env.TAG, {
|
|
31
|
+
serviceId: message.serviceId,
|
|
32
|
+
doClass: "tag",
|
|
33
|
+
identity: message.tag,
|
|
34
|
+
}));
|
|
35
|
+
const url = new URL("https://downstream.internal/outbox/mark-delivered");
|
|
36
|
+
url.searchParams.set("__tag", message.tag);
|
|
37
|
+
url.searchParams.set("__serviceId", message.serviceId);
|
|
38
|
+
const response = await tag.fetch(new Request(url, {
|
|
39
|
+
method: "POST",
|
|
40
|
+
headers: { "content-type": "application/json" },
|
|
41
|
+
body: JSON.stringify(globalReceiptAcknowledgement(message, receipt.receivedAt || arrivedAt)),
|
|
42
|
+
}));
|
|
43
|
+
if (!response.ok)
|
|
44
|
+
throw new Error(`source_outbox_receipt_ack_failed:${response.status}`);
|
|
45
|
+
};
|
|
46
|
+
const coverage = hasG44Authority
|
|
47
|
+
? async ({ message, event, arrivedAt, source }) => {
|
|
48
|
+
let completed = false;
|
|
49
|
+
await options.beforeViews?.({ message, event, arrivedAt, source });
|
|
50
|
+
observeG60PostAdmission(options.durableHopObserver, {
|
|
51
|
+
stage: "completeness-coverage",
|
|
52
|
+
boundary: "start",
|
|
53
|
+
outcome: "started",
|
|
54
|
+
serviceId: message.serviceId,
|
|
55
|
+
eventId: message.eventId,
|
|
56
|
+
suid: message.suid,
|
|
57
|
+
attemptId: message.attemptId,
|
|
58
|
+
partitionTag: message.tag,
|
|
59
|
+
transport: source,
|
|
60
|
+
observedAt: Date.now(),
|
|
61
|
+
});
|
|
62
|
+
try {
|
|
63
|
+
const decision = await new GlobalCompletenessReconciler(env.D1, env.TAG).coverageForObligation(message.serviceId, message.tag, message.completeness.obligationSequence, arrivedAt);
|
|
64
|
+
observeG60PostAdmission(options.durableHopObserver, {
|
|
65
|
+
stage: "completeness-coverage",
|
|
66
|
+
boundary: "end",
|
|
67
|
+
outcome: decision.kind,
|
|
68
|
+
serviceId: message.serviceId,
|
|
69
|
+
eventId: message.eventId,
|
|
70
|
+
suid: message.suid,
|
|
71
|
+
attemptId: message.attemptId,
|
|
72
|
+
partitionTag: message.tag,
|
|
73
|
+
transport: source,
|
|
74
|
+
observedAt: Date.now(),
|
|
75
|
+
});
|
|
76
|
+
completed = true;
|
|
77
|
+
if (decision.kind !== "SETTLED") {
|
|
78
|
+
throw new Error(`global_completeness_${decision.kind}:${decision.health.status}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
if (!completed) {
|
|
83
|
+
observeG60PostAdmission(options.durableHopObserver, {
|
|
84
|
+
stage: "completeness-coverage",
|
|
85
|
+
boundary: "end",
|
|
86
|
+
outcome: "error",
|
|
87
|
+
serviceId: message.serviceId,
|
|
88
|
+
eventId: message.eventId,
|
|
89
|
+
suid: message.suid,
|
|
90
|
+
attemptId: message.attemptId,
|
|
91
|
+
partitionTag: message.tag,
|
|
92
|
+
transport: source,
|
|
93
|
+
observedAt: Date.now(),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
: options.beforeViews;
|
|
100
|
+
const observedSourceAcknowledgement = sourceAcknowledgement === undefined
|
|
101
|
+
? undefined
|
|
102
|
+
: async (input) => {
|
|
103
|
+
observeSourceSubstep(options.durableHopObserver, { ...input, boundary: "start", outcome: "started" });
|
|
104
|
+
try {
|
|
105
|
+
await sourceAcknowledgement(input);
|
|
106
|
+
observeSourceSubstep(options.durableHopObserver, { ...input, boundary: "end", outcome: "acknowledged" });
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
observeSourceSubstep(options.durableHopObserver, { ...input, boundary: "end", outcome: "error" });
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
const detectorFailure = hasG44Authority
|
|
114
|
+
? async ({ message, arrivedAt, error, source }) => {
|
|
115
|
+
await options.onDetectorFailure?.({ message, arrivedAt, error, source });
|
|
116
|
+
await new GlobalCompletenessReconciler(env.D1, env.TAG).recordDetectorFailure(message.serviceId, error, arrivedAt);
|
|
117
|
+
}
|
|
118
|
+
: options.onDetectorFailure;
|
|
119
|
+
return {
|
|
120
|
+
...options,
|
|
121
|
+
...(observedSourceAcknowledgement === undefined ? {} : { afterGlobalReceipt: observedSourceAcknowledgement }),
|
|
122
|
+
...(coverage === undefined ? {} : { beforeViews: coverage }),
|
|
123
|
+
...(detectorFailure === undefined ? {} : { onDetectorFailure: detectorFailure }),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
function sharedStore(env, provider) {
|
|
127
|
+
// The provider creates a request-scoped client. Never retain a client across
|
|
128
|
+
// Queue or scheduled invocations, or a later handler can attempt I/O on a
|
|
129
|
+
// stream owned by an earlier request.
|
|
130
|
+
return provider.create(env);
|
|
131
|
+
}
|
|
132
|
+
async function admitBootstrapRoute(env, serviceId, route) {
|
|
133
|
+
if (env.BOOTSTRAP === undefined) {
|
|
134
|
+
if (env.G38_DOORBELL_DELIVERY_ROLE === "receiver") {
|
|
135
|
+
const error = new Error(`bootstrap_route_binding_missing:${route}`);
|
|
136
|
+
error.name = "BootstrapRouteBindingMissingError";
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const url = new URL("https://downstream.internal/route/check");
|
|
142
|
+
url.searchParams.set("__serviceId", serviceId);
|
|
143
|
+
const admitted = await env.BOOTSTRAP.get(scopeIdFor(env.BOOTSTRAP, {
|
|
144
|
+
serviceId,
|
|
145
|
+
doClass: "bootstrap",
|
|
146
|
+
identity: "coordinator",
|
|
147
|
+
})).fetch(new Request(url, {
|
|
148
|
+
method: "POST",
|
|
149
|
+
headers: { "content-type": "application/json" },
|
|
150
|
+
body: JSON.stringify({ route }),
|
|
151
|
+
}));
|
|
152
|
+
if (!admitted.ok)
|
|
153
|
+
throw new Error(`bootstrap_route_rejected:${route}`);
|
|
154
|
+
}
|
|
155
|
+
async function withStore(env, options, operation) {
|
|
156
|
+
if (options.store === undefined && options.storeProvider === undefined) {
|
|
157
|
+
throw new Error("A downstream store provider is not configured");
|
|
158
|
+
}
|
|
159
|
+
const store = options.store ?? sharedStore(env, options.storeProvider);
|
|
160
|
+
await store.initialize();
|
|
161
|
+
return operation(store, options.clock ?? systemPipelineClock);
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Processes one direct delivery through the shared core. The caller receives a
|
|
165
|
+
* typed result; this function never acknowledges, retries, or marks an
|
|
166
|
+
* outbox row.
|
|
167
|
+
*/
|
|
168
|
+
export async function processDownstreamDoorbell(message, env, options = {}) {
|
|
169
|
+
if (!isDownstreamOutboxMessage(message)) {
|
|
170
|
+
throw new Error("Doorbell contained an invalid outbox message");
|
|
171
|
+
}
|
|
172
|
+
options.durableHopObserver?.observe({
|
|
173
|
+
stage: "consumer-invocation-started",
|
|
174
|
+
serviceId: message.serviceId,
|
|
175
|
+
eventId: message.eventId,
|
|
176
|
+
suid: message.suid,
|
|
177
|
+
attemptId: message.attemptId,
|
|
178
|
+
partitionTag: message.tag,
|
|
179
|
+
transport: "fast",
|
|
180
|
+
observedAt: Date.now(),
|
|
181
|
+
});
|
|
182
|
+
await admitBootstrapRoute(env, message.serviceId, "fast");
|
|
183
|
+
return processDeliveryCore(message, "fast", env, sourceAcknowledgementOptions(env, options));
|
|
184
|
+
}
|
|
185
|
+
/** Processes one Queue delivery without exposing Queue state to the core. */
|
|
186
|
+
export async function processDownstreamDelivery(message, env, options = {}) {
|
|
187
|
+
if (!isDownstreamOutboxMessage(message)) {
|
|
188
|
+
throw new Error("Downstream Queue contained an invalid outbox message");
|
|
189
|
+
}
|
|
190
|
+
options.durableHopObserver?.observe({
|
|
191
|
+
stage: "consumer-invocation-started",
|
|
192
|
+
serviceId: message.serviceId,
|
|
193
|
+
eventId: message.eventId,
|
|
194
|
+
suid: message.suid,
|
|
195
|
+
attemptId: message.attemptId,
|
|
196
|
+
partitionTag: message.tag,
|
|
197
|
+
transport: "queue",
|
|
198
|
+
observedAt: Date.now(),
|
|
199
|
+
});
|
|
200
|
+
await admitBootstrapRoute(env, message.serviceId, "queue");
|
|
201
|
+
const outcome = await processDeliveryCore(message, "queue", env, sourceAcknowledgementOptions(env, options));
|
|
202
|
+
if (outcome.queueDisposition !== "ack") {
|
|
203
|
+
throw new Error(`downstream_delivery_retry:${outcome.correlationId}`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Queue policy is deliberately limited to the wrapper: the shared core
|
|
208
|
+
* returns a disposition, and this is the only place that calls ack/retry.
|
|
209
|
+
*/
|
|
210
|
+
export async function handleDownstreamQueue(batch, env, options = {}) {
|
|
211
|
+
// Validate each Queue ingress before acquiring a provider. In particular a
|
|
212
|
+
// non-G32 SUID must not even initialize the downstream store; this keeps
|
|
213
|
+
// the cutover's abort-before-write guarantee observable for Queue traffic.
|
|
214
|
+
const valid = [];
|
|
215
|
+
for (const queued of batch.messages) {
|
|
216
|
+
if (!isDownstreamOutboxMessage(queued.body)) {
|
|
217
|
+
console.warn("downstream_queue_delivery", { disposition: "retry-to-dlq" });
|
|
218
|
+
queued.retry();
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
valid.push(queued);
|
|
222
|
+
}
|
|
223
|
+
if (valid.length === 0)
|
|
224
|
+
return;
|
|
225
|
+
await withStore(env, options, async (store, clock) => {
|
|
226
|
+
for (const queued of valid) {
|
|
227
|
+
try {
|
|
228
|
+
options.durableHopObserver?.observe({
|
|
229
|
+
stage: "consumer-invocation-started",
|
|
230
|
+
serviceId: queued.body.serviceId,
|
|
231
|
+
eventId: queued.body.eventId,
|
|
232
|
+
suid: queued.body.suid,
|
|
233
|
+
attemptId: queued.body.attemptId,
|
|
234
|
+
partitionTag: queued.body.tag,
|
|
235
|
+
transport: "queue",
|
|
236
|
+
observedAt: Date.now(),
|
|
237
|
+
});
|
|
238
|
+
await admitBootstrapRoute(env, queued.body.serviceId, "queue");
|
|
239
|
+
const outcome = await processDeliveryCore(queued.body, "queue", env, {
|
|
240
|
+
...sourceAcknowledgementOptions(env, options),
|
|
241
|
+
store,
|
|
242
|
+
clock,
|
|
243
|
+
g69AdmissionAttempt: {
|
|
244
|
+
queueMessageId: queued.id,
|
|
245
|
+
waitUntil: options.g69AdmissionAttemptWaitUntil,
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
console.log("downstream_queue_delivery", {
|
|
249
|
+
correlationId: outcome.correlationId,
|
|
250
|
+
coreDurationMs: outcome.coreDurationMs,
|
|
251
|
+
viewDurationsMs: outcome.views.map((view) => ({ id: view.id, durationMs: view.durationMs, status: view.status })),
|
|
252
|
+
disposition: outcome.queueDisposition,
|
|
253
|
+
failures: outcome.failures.map((failure) => ({ phase: failure.phase, class: failure.class, viewId: failure.viewId, error: failure.error })),
|
|
254
|
+
});
|
|
255
|
+
if (outcome.queueDisposition === "ack")
|
|
256
|
+
queued.ack();
|
|
257
|
+
else if (outcome.queueDisposition === "retry-once")
|
|
258
|
+
queued.retry();
|
|
259
|
+
else
|
|
260
|
+
queued.retry();
|
|
261
|
+
// A committed event still needs an event-driven safe-lane kick when
|
|
262
|
+
// G44 deliberately holds the ordinary views. A stored recordDelivery
|
|
263
|
+
// result includes idempotent Queue replays; each successful batch is
|
|
264
|
+
// eligible, while a recordDelivery failure is not. The hook is
|
|
265
|
+
// notification-only: it is never awaited, so its caller must
|
|
266
|
+
// register work through waitUntil and return.
|
|
267
|
+
if (outcome.outcome === "stored" && !outcome.failures.some((failure) => failure.phase === "recordDelivery")) {
|
|
268
|
+
try {
|
|
269
|
+
options.afterStoredQueueDelivery?.({ message: queued.body, result: outcome });
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
// A lost kick is recoverable by cron and must never change the
|
|
273
|
+
// durable Queue ack/retry decision already made above.
|
|
274
|
+
console.warn("safe_lane_kick", {
|
|
275
|
+
status: "failed-to-schedule",
|
|
276
|
+
error: error instanceof Error ? error.message : String(error),
|
|
277
|
+
serviceId: queued.body.serviceId,
|
|
278
|
+
eventId: queued.body.eventId,
|
|
279
|
+
attemptId: queued.body.attemptId,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
console.warn("downstream_queue_delivery", { disposition: "retry-to-dlq" });
|
|
286
|
+
queued.retry();
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
/** A scheduled/operator invocation can evaluate a stable pending record without new traffic. */
|
|
292
|
+
export async function stabilizeDownstream(env, options = {}, serviceId, serviceIdentityProvider) {
|
|
293
|
+
await admitBootstrapRoute(env, serviceId ?? requireServiceIdentity(serviceIdentityProvider ?? envServiceIdentity(env)), "scheduled");
|
|
294
|
+
await withStore(env, options, async (store, clock) => {
|
|
295
|
+
const detector = new InconsistencyDetector(store, new BindingExclusionLedgerClient(env.REPAIR_EXCLUSION_LOOKUP));
|
|
296
|
+
await detector.stabilize(clock, serviceId);
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
export { processDeliveryCore } from "./DeliveryCore";
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider-internal port used by SDT-G6 Branch B. It is intentionally a
|
|
3
|
+
* narrow binding client, not a Queue/Cosmos/Postgres adapter.
|
|
4
|
+
*/
|
|
5
|
+
export interface ExclusionLookupInput {
|
|
6
|
+
attemptId: string;
|
|
7
|
+
tag: string;
|
|
8
|
+
eventId: string;
|
|
9
|
+
suid: string;
|
|
10
|
+
actor: string;
|
|
11
|
+
repairEpoch: number;
|
|
12
|
+
}
|
|
13
|
+
export interface ExclusionLookupPort {
|
|
14
|
+
recordExclusion(input: ExclusionLookupInput): Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
export interface ExclusionLedgerLookupInput {
|
|
17
|
+
serviceId: string;
|
|
18
|
+
attemptId: string;
|
|
19
|
+
tag: string;
|
|
20
|
+
eventId: string;
|
|
21
|
+
suid: string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Reads the same provider-internal exclusion ledger that SDT-G6 writes. A
|
|
25
|
+
* missing binding is an unknown path, never an invented exclusion.
|
|
26
|
+
*/
|
|
27
|
+
export declare class BindingExclusionLedgerClient {
|
|
28
|
+
private readonly binding;
|
|
29
|
+
constructor(binding: Fetcher | undefined);
|
|
30
|
+
isExcludedAudited(input: ExclusionLedgerLookupInput): Promise<boolean>;
|
|
31
|
+
}
|
|
32
|
+
export declare class BindingExclusionLookupClient implements ExclusionLookupPort {
|
|
33
|
+
private readonly binding;
|
|
34
|
+
constructor(binding: Fetcher | undefined);
|
|
35
|
+
recordExclusion(input: ExclusionLookupInput): Promise<void>;
|
|
36
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads the same provider-internal exclusion ledger that SDT-G6 writes. A
|
|
3
|
+
* missing binding is an unknown path, never an invented exclusion.
|
|
4
|
+
*/
|
|
5
|
+
export class BindingExclusionLedgerClient {
|
|
6
|
+
binding;
|
|
7
|
+
constructor(binding) {
|
|
8
|
+
this.binding = binding;
|
|
9
|
+
}
|
|
10
|
+
async isExcludedAudited(input) {
|
|
11
|
+
if (this.binding === undefined) {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
const response = await this.binding.fetch("https://repair-exclusion.internal/exclusions/lookup", {
|
|
15
|
+
method: "POST",
|
|
16
|
+
headers: { "content-type": "application/json" },
|
|
17
|
+
body: JSON.stringify(input),
|
|
18
|
+
});
|
|
19
|
+
if (response.status === 404) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
if (!response.ok) {
|
|
23
|
+
throw new Error(`Repair exclusion lookup rejected detector with ${response.status}`);
|
|
24
|
+
}
|
|
25
|
+
const body = await response.json();
|
|
26
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
|
27
|
+
throw new Error("Repair exclusion lookup returned an invalid response");
|
|
28
|
+
}
|
|
29
|
+
return body.classification === "EXCLUDED_AUDITED";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export class BindingExclusionLookupClient {
|
|
33
|
+
binding;
|
|
34
|
+
constructor(binding) {
|
|
35
|
+
this.binding = binding;
|
|
36
|
+
}
|
|
37
|
+
async recordExclusion(input) {
|
|
38
|
+
if (this.binding === undefined) {
|
|
39
|
+
throw new Error("Repair exclusion lookup binding is not configured");
|
|
40
|
+
}
|
|
41
|
+
const response = await this.binding.fetch("https://repair-exclusion.internal/exclusions", {
|
|
42
|
+
method: "POST",
|
|
43
|
+
headers: { "content-type": "application/json" },
|
|
44
|
+
body: JSON.stringify(input),
|
|
45
|
+
});
|
|
46
|
+
if (!response.ok) {
|
|
47
|
+
throw new Error(`Repair exclusion lookup rejected Branch B with ${response.status}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { DownstreamOutboxMessage, PipelineClock } from "./types";
|
|
2
|
+
import type { DetectorStore } from "../store/types";
|
|
3
|
+
export declare const MIN_STABILITY_HORIZON_MS = 20000;
|
|
4
|
+
export interface ExclusionLedgerInput {
|
|
5
|
+
serviceId: string;
|
|
6
|
+
attemptId: string;
|
|
7
|
+
eventId: string;
|
|
8
|
+
suid: string;
|
|
9
|
+
tag: string;
|
|
10
|
+
}
|
|
11
|
+
export interface ExclusionLedgerPort {
|
|
12
|
+
isExcludedAudited(input: ExclusionLedgerInput): Promise<boolean>;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* This component owns only pending/finding facts. Event rows are written by
|
|
16
|
+
* the adapter before this detector is invoked and are intentionally absent
|
|
17
|
+
* from DetectorStore.
|
|
18
|
+
*/
|
|
19
|
+
export declare class InconsistencyDetector {
|
|
20
|
+
private readonly store;
|
|
21
|
+
private readonly exclusions;
|
|
22
|
+
constructor(store: DetectorStore, exclusions: ExclusionLedgerPort);
|
|
23
|
+
observe(message: DownstreamOutboxMessage, arrivedAt: number, dynamicLagBoundMs: number): Promise<void>;
|
|
24
|
+
stabilize(clock: PipelineClock, serviceId?: string): Promise<void>;
|
|
25
|
+
private classifyKnownExclusions;
|
|
26
|
+
private resolveLateArrivals;
|
|
27
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
export const MIN_STABILITY_HORIZON_MS = 20_000;
|
|
2
|
+
function sortedUnique(values) {
|
|
3
|
+
return [...new Set(values)].sort((left, right) => left.localeCompare(right));
|
|
4
|
+
}
|
|
5
|
+
function missingPaths(pending) {
|
|
6
|
+
const observed = new Set(pending.observedPaths);
|
|
7
|
+
return pending.expectedPaths.filter((path) => !observed.has(path));
|
|
8
|
+
}
|
|
9
|
+
function finding(pending, path, classification, observedAt) {
|
|
10
|
+
return {
|
|
11
|
+
serviceId: pending.serviceId,
|
|
12
|
+
eventId: pending.eventId,
|
|
13
|
+
path,
|
|
14
|
+
classification,
|
|
15
|
+
firstObservedAt: pending.firstObservedAt,
|
|
16
|
+
lagBoundMs: pending.lagBoundMs,
|
|
17
|
+
observedAt,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* This component owns only pending/finding facts. Event rows are written by
|
|
22
|
+
* the adapter before this detector is invoked and are intentionally absent
|
|
23
|
+
* from DetectorStore.
|
|
24
|
+
*/
|
|
25
|
+
export class InconsistencyDetector {
|
|
26
|
+
store;
|
|
27
|
+
exclusions;
|
|
28
|
+
constructor(store, exclusions) {
|
|
29
|
+
this.store = store;
|
|
30
|
+
this.exclusions = exclusions;
|
|
31
|
+
}
|
|
32
|
+
async observe(message, arrivedAt, dynamicLagBoundMs) {
|
|
33
|
+
const pending = await this.store.upsertPending({ ...message, eventTags: sortedUnique(message.eventTags) }, arrivedAt, Math.max(MIN_STABILITY_HORIZON_MS, dynamicLagBoundMs));
|
|
34
|
+
const excluded = await this.classifyKnownExclusions(pending, arrivedAt);
|
|
35
|
+
await this.resolveLateArrivals(pending, arrivedAt, excluded);
|
|
36
|
+
}
|
|
37
|
+
async stabilize(clock, serviceId) {
|
|
38
|
+
const now = clock.now();
|
|
39
|
+
for (const pending of await this.store.listPending(serviceId)) {
|
|
40
|
+
const excluded = await this.classifyKnownExclusions(pending, now);
|
|
41
|
+
const missing = missingPaths(pending);
|
|
42
|
+
if (missing.length === 0) {
|
|
43
|
+
await this.resolveLateArrivals(pending, now, excluded);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (now < pending.firstObservedAt + Math.max(MIN_STABILITY_HORIZON_MS, pending.lagBoundMs)) {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
for (const path of missing) {
|
|
50
|
+
if (!excluded.has(path)) {
|
|
51
|
+
await this.store.appendFinding(finding(pending, path, "MISSING_STABLE", now));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
async classifyKnownExclusions(pending, observedAt) {
|
|
57
|
+
const excluded = new Set();
|
|
58
|
+
for (const path of missingPaths(pending)) {
|
|
59
|
+
if (await this.exclusions.isExcludedAudited({
|
|
60
|
+
serviceId: pending.serviceId,
|
|
61
|
+
attemptId: pending.attemptId,
|
|
62
|
+
eventId: pending.eventId,
|
|
63
|
+
suid: pending.suid,
|
|
64
|
+
tag: path,
|
|
65
|
+
})) {
|
|
66
|
+
excluded.add(path);
|
|
67
|
+
await this.store.appendFinding(finding(pending, path, "EXCLUDED_AUDITED", observedAt));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return excluded;
|
|
71
|
+
}
|
|
72
|
+
async resolveLateArrivals(pending, observedAt, excluded) {
|
|
73
|
+
if (missingPaths(pending).length !== 0) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
for (const path of pending.expectedPaths) {
|
|
77
|
+
if (!excluded.has(path) && await this.store.hasFinding(pending.serviceId, pending.eventId, path, "MISSING_STABLE")) {
|
|
78
|
+
await this.store.appendFinding(finding(pending, path, "RESOLVED_LATE", observedAt));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type DownstreamOutboxMessage, type PipelineClock } from "./types";
|
|
2
|
+
import type { G60DurableHopObserver } from "../diagnostics/G60DurableHop";
|
|
3
|
+
export interface OutboxDrainEnv {
|
|
4
|
+
TAG: DurableObjectNamespace;
|
|
5
|
+
DOWNSTREAM_QUEUE: Queue<DownstreamOutboxMessage>;
|
|
6
|
+
}
|
|
7
|
+
export interface DrainTagInput {
|
|
8
|
+
serviceId: string;
|
|
9
|
+
tag: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The ordinary runtime predates the G44 D1 global-array authority and keeps
|
|
13
|
+
* its historical transport acknowledgement for its private operational
|
|
14
|
+
* endpoint. The Cloudflare-only runtime opts into `global-receipt` below;
|
|
15
|
+
* that branch is deliberately not selected by a rollout flag.
|
|
16
|
+
*/
|
|
17
|
+
export interface OutboxDrainOptions {
|
|
18
|
+
readonly acknowledgement?: "transport" | "global-receipt";
|
|
19
|
+
/** G60 observation is scheduled by the active Worker context. */
|
|
20
|
+
readonly durableHopObserver?: G60DurableHopObserver;
|
|
21
|
+
}
|
|
22
|
+
export interface DrainResult {
|
|
23
|
+
serviceId: string;
|
|
24
|
+
tag: string;
|
|
25
|
+
delivered: number;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* A row gains its durable enqueuedAt fact before the first send. Queue send
|
|
29
|
+
* is only transport acceptance: G44 deliberately leaves the source pending
|
|
30
|
+
* until the receiver has atomically written and read back the global receipt
|
|
31
|
+
* plus committed-membership join. An interruption therefore replays safely.
|
|
32
|
+
*/
|
|
33
|
+
export declare function drainTagOutbox(input: DrainTagInput, env: OutboxDrainEnv, clock?: PipelineClock, options?: OutboxDrainOptions): Promise<DrainResult>;
|
|
34
|
+
/** Internal operational trigger; it does not alter any V1 public endpoint. */
|
|
35
|
+
export declare function handleOutboxDrainRequest(request: Request, env: OutboxDrainEnv, options?: OutboxDrainOptions): Promise<Response>;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { outboxIdentity, systemPipelineClock } from "./types";
|
|
2
|
+
import { scopeIdFor } from "../scope/ScopeName";
|
|
3
|
+
function json(body, status = 200) {
|
|
4
|
+
return new Response(JSON.stringify(body), {
|
|
5
|
+
status,
|
|
6
|
+
headers: { "content-type": "application/json; charset=utf-8" },
|
|
7
|
+
});
|
|
8
|
+
}
|
|
9
|
+
function isNonEmptyString(value) {
|
|
10
|
+
return typeof value === "string" && value.length > 0;
|
|
11
|
+
}
|
|
12
|
+
function isObject(value) {
|
|
13
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
14
|
+
}
|
|
15
|
+
function isDrainRequest(value) {
|
|
16
|
+
return isObject(value) && isNonEmptyString(value.serviceId) && Array.isArray(value.tags) &&
|
|
17
|
+
value.tags.length > 0 && value.tags.every(isNonEmptyString) && new Set(value.tags).size === value.tags.length;
|
|
18
|
+
}
|
|
19
|
+
function identity(message) {
|
|
20
|
+
return {
|
|
21
|
+
attemptId: message.attemptId,
|
|
22
|
+
eventId: message.eventId,
|
|
23
|
+
suid: message.suid,
|
|
24
|
+
allocatorLineageId: message.allocatorLineageId,
|
|
25
|
+
payload: message.payload,
|
|
26
|
+
eventType: message.eventType,
|
|
27
|
+
provenance: message.provenance,
|
|
28
|
+
timestamp: message.timestamp,
|
|
29
|
+
causationId: message.causationId,
|
|
30
|
+
correlationId: message.correlationId,
|
|
31
|
+
executedUser: message.executedUser,
|
|
32
|
+
// Legacy transport acknowledgement still has to present the full G43
|
|
33
|
+
// source fact because TagDurableObject rejects a lossy identity.
|
|
34
|
+
completeness: message.completeness,
|
|
35
|
+
enqueuedAt: message.enqueuedAt,
|
|
36
|
+
deliveredAt: null,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
async function tagPost(env, input, path, body) {
|
|
40
|
+
const stub = env.TAG.get(scopeIdFor(env.TAG, {
|
|
41
|
+
serviceId: input.serviceId,
|
|
42
|
+
doClass: "tag",
|
|
43
|
+
identity: input.tag,
|
|
44
|
+
}));
|
|
45
|
+
const url = new URL(`https://outbox-drain.internal${path}`);
|
|
46
|
+
url.searchParams.set("__tag", input.tag);
|
|
47
|
+
url.searchParams.set("__serviceId", input.serviceId);
|
|
48
|
+
return stub.fetch(new Request(url, {
|
|
49
|
+
method: "POST",
|
|
50
|
+
headers: { "content-type": "application/json" },
|
|
51
|
+
body: JSON.stringify(body),
|
|
52
|
+
}));
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* A row gains its durable enqueuedAt fact before the first send. Queue send
|
|
56
|
+
* is only transport acceptance: G44 deliberately leaves the source pending
|
|
57
|
+
* until the receiver has atomically written and read back the global receipt
|
|
58
|
+
* plus committed-membership join. An interruption therefore replays safely.
|
|
59
|
+
*/
|
|
60
|
+
export async function drainTagOutbox(input, env, clock = systemPipelineClock, options = {}) {
|
|
61
|
+
// Queue-triggered draining is an explicit retry attempt. The source's
|
|
62
|
+
// `next_attempt_at` controls automatic alarm wakes, but must not suppress a
|
|
63
|
+
// caller that is already actively draining the durable obligation.
|
|
64
|
+
const pending = await tagPost(env, input, "/outbox/pending", { nowMs: clock.now(), force: true });
|
|
65
|
+
if (!pending.ok) {
|
|
66
|
+
throw new Error(`Tag outbox pending read failed with ${pending.status}`);
|
|
67
|
+
}
|
|
68
|
+
const body = await pending.json();
|
|
69
|
+
let delivered = 0;
|
|
70
|
+
for (const row of body.rows) {
|
|
71
|
+
await env.DOWNSTREAM_QUEUE.send(row, { contentType: "json" });
|
|
72
|
+
options.durableHopObserver?.observe({
|
|
73
|
+
stage: "queue-send-returned",
|
|
74
|
+
serviceId: input.serviceId,
|
|
75
|
+
eventId: row.eventId,
|
|
76
|
+
suid: row.suid,
|
|
77
|
+
attemptId: row.attemptId,
|
|
78
|
+
partitionTag: input.tag,
|
|
79
|
+
transport: "queue",
|
|
80
|
+
observedAt: clock.now(),
|
|
81
|
+
});
|
|
82
|
+
if (options.acknowledgement === "global-receipt") {
|
|
83
|
+
// G44 Queue handoff is only transport acceptance. The D1 receiver
|
|
84
|
+
// performs the source acknowledgement after its atomic event,
|
|
85
|
+
// membership, and receipt join has been read back.
|
|
86
|
+
delivered += 1;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const mark = await tagPost(env, input, "/outbox/mark-delivered", {
|
|
90
|
+
deliveries: [identity(row)],
|
|
91
|
+
nowMs: clock.now(),
|
|
92
|
+
});
|
|
93
|
+
if (!mark.ok) {
|
|
94
|
+
throw new Error(`Tag outbox delivered mark failed with ${mark.status} for ${outboxIdentity(row)}`);
|
|
95
|
+
}
|
|
96
|
+
const markBody = await mark.json();
|
|
97
|
+
delivered += markBody.marked;
|
|
98
|
+
}
|
|
99
|
+
return { ...input, delivered };
|
|
100
|
+
}
|
|
101
|
+
/** Internal operational trigger; it does not alter any V1 public endpoint. */
|
|
102
|
+
export async function handleOutboxDrainRequest(request, env, options = {}) {
|
|
103
|
+
let body;
|
|
104
|
+
try {
|
|
105
|
+
body = await request.json();
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return json({ error: "Outbox drain request must be JSON", code: "invalid_outbox_drain_request" }, 400);
|
|
109
|
+
}
|
|
110
|
+
if (!isDrainRequest(body)) {
|
|
111
|
+
return json({ error: "serviceId and unique non-empty tags are required", code: "invalid_outbox_drain_request" }, 400);
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
const results = [];
|
|
115
|
+
for (const tag of body.tags) {
|
|
116
|
+
results.push(await drainTagOutbox({ serviceId: body.serviceId, tag }, env, systemPipelineClock, options));
|
|
117
|
+
}
|
|
118
|
+
return json({ results });
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
return json({
|
|
122
|
+
error: error instanceof Error ? error.message : "Outbox drain failed",
|
|
123
|
+
code: "outbox_drain_failed",
|
|
124
|
+
}, 503);
|
|
125
|
+
}
|
|
126
|
+
}
|