@syncular/server 0.39.2 → 0.41.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/README.md +4 -1
- package/dist/admin.d.ts +6 -0
- package/dist/admin.js +9 -0
- package/dist/d1-storage.d.ts +3 -2
- package/dist/d1-storage.js +60 -15
- package/dist/handler.js +79 -8
- package/dist/postgres-storage.d.ts +4 -3
- package/dist/postgres-storage.js +96 -46
- package/dist/relational-rows.d.ts +9 -0
- package/dist/relational-rows.js +30 -0
- package/dist/sqlite-dialect.d.ts +7 -1
- package/dist/sqlite-dialect.js +8 -0
- package/dist/sqlite-storage.d.ts +3 -2
- package/dist/sqlite-storage.js +57 -8
- package/dist/storage.d.ts +31 -1
- package/dist/storage.js +8 -0
- package/package.json +2 -2
- package/src/admin.ts +15 -0
- package/src/d1-storage.ts +109 -14
- package/src/handler.ts +126 -19
- package/src/postgres-storage.ts +154 -58
- package/src/relational-rows.ts +34 -0
- package/src/sqlite-dialect.ts +9 -0
- package/src/sqlite-storage.ts +102 -4
- package/src/storage.ts +56 -1
package/src/d1-storage.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { validateCommitPruneQuery } from './prune';
|
|
2
2
|
import { StorageQueryError } from './storage-errors';
|
|
3
3
|
import type { CommitPruneQuery, CommitPruneResult } from './storage';
|
|
4
|
-
import { PARTITION_TOUCH_INTERVAL_MS } from './storage';
|
|
4
|
+
import { clientRoundState, PARTITION_TOUCH_INTERVAL_MS } from './storage';
|
|
5
5
|
/**
|
|
6
6
|
* Cloudflare D1 server storage for Workers deployments.
|
|
7
7
|
*
|
|
@@ -78,6 +78,7 @@ import {
|
|
|
78
78
|
type StoredColumnLayout,
|
|
79
79
|
scanRowPageSql,
|
|
80
80
|
schemaDdl,
|
|
81
|
+
serverIndexDdl,
|
|
81
82
|
SQLITE_TABLE_INDEXES_SQL,
|
|
82
83
|
type SqliteTableIndexRow,
|
|
83
84
|
sqliteIndexSignatures,
|
|
@@ -104,6 +105,7 @@ import {
|
|
|
104
105
|
type SqliteRowRecord,
|
|
105
106
|
serializePushResult,
|
|
106
107
|
sqliteDdlStatements,
|
|
108
|
+
SYNC_CLIENT_ROUND_COLUMNS,
|
|
107
109
|
toStoredChange,
|
|
108
110
|
toStoredRow,
|
|
109
111
|
} from './sqlite-dialect';
|
|
@@ -114,6 +116,7 @@ import type {
|
|
|
114
116
|
ClientCursorInfo,
|
|
115
117
|
ClientPushResult,
|
|
116
118
|
ClientRecord,
|
|
119
|
+
ClientRecordWriteOptions,
|
|
117
120
|
ClientSubscription,
|
|
118
121
|
CommitChangeSummary,
|
|
119
122
|
CommitDetail,
|
|
@@ -999,6 +1002,15 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
999
1002
|
'ALTER TABLE sync_clients ADD COLUMN wire_version INTEGER NOT NULL DEFAULT 1;',
|
|
1000
1003
|
);
|
|
1001
1004
|
}
|
|
1005
|
+
for (const [name, definition] of Object.entries(
|
|
1006
|
+
SYNC_CLIENT_ROUND_COLUMNS,
|
|
1007
|
+
)) {
|
|
1008
|
+
if (!results.some((column) => column.name === name)) {
|
|
1009
|
+
await this.#db.exec(
|
|
1010
|
+
`ALTER TABLE sync_clients ADD COLUMN ${name} ${definition};`,
|
|
1011
|
+
);
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1002
1014
|
}
|
|
1003
1015
|
|
|
1004
1016
|
/** Resolve a table's compiled schema; row operations require `ensureSchema`. */
|
|
@@ -1129,6 +1141,44 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
1129
1141
|
'sqlite',
|
|
1130
1142
|
);
|
|
1131
1143
|
}
|
|
1144
|
+
// A database from before the round-state columns gains them in place.
|
|
1145
|
+
statementsExecuted++;
|
|
1146
|
+
const clientColumns = await this.#db
|
|
1147
|
+
.prepare('PRAGMA table_info("sync_clients")')
|
|
1148
|
+
.all<{ name: string }>();
|
|
1149
|
+
const missingRoundColumns = Object.entries(
|
|
1150
|
+
SYNC_CLIENT_ROUND_COLUMNS,
|
|
1151
|
+
).filter(
|
|
1152
|
+
([name]) =>
|
|
1153
|
+
!clientColumns.results.some((column) => column.name === name),
|
|
1154
|
+
);
|
|
1155
|
+
if (missingRoundColumns.length > 0) {
|
|
1156
|
+
await batch(
|
|
1157
|
+
missingRoundColumns.map(([name, definition]) =>
|
|
1158
|
+
this.#db.prepare(
|
|
1159
|
+
`ALTER TABLE sync_clients ADD COLUMN ${name} ${definition}`,
|
|
1160
|
+
),
|
|
1161
|
+
),
|
|
1162
|
+
);
|
|
1163
|
+
}
|
|
1164
|
+
// Server-only indexes reconcile at every open of a database already at
|
|
1165
|
+
// this version: they never reach a client, so no version bump is needed.
|
|
1166
|
+
const indexes = new Map<string, ReadonlyMap<string, string>>();
|
|
1167
|
+
for (const table of tables) {
|
|
1168
|
+
statementsExecuted++;
|
|
1169
|
+
const { results } = await this.#db
|
|
1170
|
+
.prepare(SQLITE_TABLE_INDEXES_SQL)
|
|
1171
|
+
.bind(table.name)
|
|
1172
|
+
.all<SqliteTableIndexRow>();
|
|
1173
|
+
indexes.set(
|
|
1174
|
+
table.name,
|
|
1175
|
+
new Map(Object.entries(sqliteIndexSignatures(results))),
|
|
1176
|
+
);
|
|
1177
|
+
}
|
|
1178
|
+
const reconcile = serverIndexDdl(schema, indexes);
|
|
1179
|
+
if (reconcile.length > 0) {
|
|
1180
|
+
await batch(reconcile.map((sql) => this.#db.prepare(sql)));
|
|
1181
|
+
}
|
|
1132
1182
|
this.#tables = schema.tables;
|
|
1133
1183
|
this.#schemaVersion = schema.version;
|
|
1134
1184
|
return { complete: true, statementsExecuted };
|
|
@@ -1248,19 +1298,20 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
1248
1298
|
const columns = await this.#db
|
|
1249
1299
|
.prepare('PRAGMA table_info("sync_clients")')
|
|
1250
1300
|
.all<{ name: string }>();
|
|
1251
|
-
const
|
|
1252
|
-
|
|
1301
|
+
const missingColumns = Object.entries({
|
|
1302
|
+
wire_version: 'INTEGER NOT NULL DEFAULT 1',
|
|
1303
|
+
...SYNC_CLIENT_ROUND_COLUMNS,
|
|
1304
|
+
}).filter(
|
|
1305
|
+
([name]) => !columns.results.some((column) => column.name === name),
|
|
1253
1306
|
);
|
|
1254
1307
|
if (
|
|
1255
1308
|
!(await save(
|
|
1256
1309
|
{ ...state, phase: 'prepare', offset: 0 },
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
]
|
|
1263
|
-
: [],
|
|
1310
|
+
missingColumns.map(([name, definition]) =>
|
|
1311
|
+
this.#db.prepare(
|
|
1312
|
+
`ALTER TABLE sync_clients ADD COLUMN ${name} ${definition}`,
|
|
1313
|
+
),
|
|
1314
|
+
),
|
|
1264
1315
|
))
|
|
1265
1316
|
)
|
|
1266
1317
|
break;
|
|
@@ -2333,7 +2384,7 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
2333
2384
|
): Promise<ClientRecord | undefined> {
|
|
2334
2385
|
const record = await this.#db
|
|
2335
2386
|
.prepare(
|
|
2336
|
-
'SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? AND client_id=?',
|
|
2387
|
+
'SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms, last_round_at_ms, last_error_code, consecutive_failures FROM sync_clients WHERE partition=? AND client_id=?',
|
|
2337
2388
|
)
|
|
2338
2389
|
.bind(partition, clientId)
|
|
2339
2390
|
.first<{
|
|
@@ -2343,6 +2394,9 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
2343
2394
|
cursor: number;
|
|
2344
2395
|
subscriptions: string;
|
|
2345
2396
|
updated_at_ms: number;
|
|
2397
|
+
last_round_at_ms: number | null;
|
|
2398
|
+
last_error_code: string | null;
|
|
2399
|
+
consecutive_failures: number;
|
|
2346
2400
|
}>();
|
|
2347
2401
|
if (record === null) return undefined;
|
|
2348
2402
|
return {
|
|
@@ -2352,25 +2406,39 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
2352
2406
|
cursor: record.cursor,
|
|
2353
2407
|
updatedAtMs: record.updated_at_ms,
|
|
2354
2408
|
subscriptions: JSON.parse(record.subscriptions) as ClientSubscription[],
|
|
2409
|
+
...clientRoundState(
|
|
2410
|
+
record.last_round_at_ms,
|
|
2411
|
+
record.last_error_code,
|
|
2412
|
+
record.consecutive_failures,
|
|
2413
|
+
),
|
|
2355
2414
|
};
|
|
2356
2415
|
}
|
|
2357
2416
|
|
|
2358
2417
|
async putClientRecord(
|
|
2359
2418
|
partition: string,
|
|
2360
2419
|
record: ClientRecord,
|
|
2420
|
+
options?: ClientRecordWriteOptions,
|
|
2361
2421
|
): Promise<void> {
|
|
2362
2422
|
// Update the existing record in place, so the table keeps one row per
|
|
2363
2423
|
// (partition, client_id). Every non-key field is written from the new
|
|
2364
2424
|
// record; a delete-and-insert would drop and recreate the row instead.
|
|
2425
|
+
// `keepRoundOutcome` leaves the round-state columns alone (§1.5, §4.5).
|
|
2365
2426
|
await this.#db
|
|
2366
2427
|
.prepare(
|
|
2367
|
-
`INSERT INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms) VALUES (
|
|
2428
|
+
`INSERT INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms, last_round_at_ms) VALUES (?,?,?,?,?,?,?,?)
|
|
2368
2429
|
ON CONFLICT(partition, client_id) DO UPDATE SET
|
|
2369
2430
|
actor_id=excluded.actor_id,
|
|
2370
2431
|
wire_version=excluded.wire_version,
|
|
2371
2432
|
cursor=excluded.cursor,
|
|
2372
2433
|
subscriptions=excluded.subscriptions,
|
|
2373
|
-
updated_at_ms=excluded.updated_at_ms
|
|
2434
|
+
updated_at_ms=excluded.updated_at_ms,
|
|
2435
|
+
last_round_at_ms=excluded.last_round_at_ms${
|
|
2436
|
+
options?.keepRoundOutcome === true
|
|
2437
|
+
? ''
|
|
2438
|
+
: `,
|
|
2439
|
+
last_error_code=NULL,
|
|
2440
|
+
consecutive_failures=0`
|
|
2441
|
+
}`,
|
|
2374
2442
|
)
|
|
2375
2443
|
.bind(
|
|
2376
2444
|
partition,
|
|
@@ -2380,7 +2448,26 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
2380
2448
|
record.cursor,
|
|
2381
2449
|
JSON.stringify(record.subscriptions),
|
|
2382
2450
|
record.updatedAtMs,
|
|
2451
|
+
record.updatedAtMs,
|
|
2452
|
+
)
|
|
2453
|
+
.run();
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2456
|
+
async recordClientRoundFailure(
|
|
2457
|
+
partition: string,
|
|
2458
|
+
clientId: string,
|
|
2459
|
+
actorId: string,
|
|
2460
|
+
errorCode: string,
|
|
2461
|
+
atMs: number,
|
|
2462
|
+
): Promise<void> {
|
|
2463
|
+
await this.#db
|
|
2464
|
+
.prepare(
|
|
2465
|
+
`UPDATE sync_clients
|
|
2466
|
+
SET last_round_at_ms=?, last_error_code=?,
|
|
2467
|
+
consecutive_failures=consecutive_failures+1
|
|
2468
|
+
WHERE partition=? AND client_id=? AND actor_id=?`,
|
|
2383
2469
|
)
|
|
2470
|
+
.bind(atMs, errorCode, partition, clientId, actorId)
|
|
2384
2471
|
.run();
|
|
2385
2472
|
}
|
|
2386
2473
|
|
|
@@ -2537,7 +2624,7 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
2537
2624
|
async listClientRecords(partition: string): Promise<ClientRecord[]> {
|
|
2538
2625
|
const { results } = await this.#db
|
|
2539
2626
|
.prepare(
|
|
2540
|
-
'SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? ORDER BY updated_at_ms DESC',
|
|
2627
|
+
'SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms, last_round_at_ms, last_error_code, consecutive_failures FROM sync_clients WHERE partition=? ORDER BY updated_at_ms DESC',
|
|
2541
2628
|
)
|
|
2542
2629
|
.bind(partition)
|
|
2543
2630
|
.all<{
|
|
@@ -2547,6 +2634,9 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
2547
2634
|
cursor: number;
|
|
2548
2635
|
subscriptions: string;
|
|
2549
2636
|
updated_at_ms: number;
|
|
2637
|
+
last_round_at_ms: number | null;
|
|
2638
|
+
last_error_code: string | null;
|
|
2639
|
+
consecutive_failures: number;
|
|
2550
2640
|
}>();
|
|
2551
2641
|
return results.map((record) => ({
|
|
2552
2642
|
clientId: record.client_id,
|
|
@@ -2555,6 +2645,11 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
2555
2645
|
cursor: record.cursor,
|
|
2556
2646
|
updatedAtMs: record.updated_at_ms,
|
|
2557
2647
|
subscriptions: JSON.parse(record.subscriptions) as ClientSubscription[],
|
|
2648
|
+
...clientRoundState(
|
|
2649
|
+
record.last_round_at_ms,
|
|
2650
|
+
record.last_error_code,
|
|
2651
|
+
record.consecutive_failures,
|
|
2652
|
+
),
|
|
2558
2653
|
}));
|
|
2559
2654
|
}
|
|
2560
2655
|
|
package/src/handler.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
type ReqHeaderFrame,
|
|
20
20
|
type RequestMessage,
|
|
21
21
|
type ResponseFrame,
|
|
22
|
+
type RoundFailureFrame,
|
|
22
23
|
type ScopeMap,
|
|
23
24
|
type SubscriptionFrame,
|
|
24
25
|
} from '@syncular/core';
|
|
@@ -30,7 +31,7 @@ import {
|
|
|
30
31
|
RESOLVER_OUTAGE,
|
|
31
32
|
touchAuthenticatedPartition,
|
|
32
33
|
} from './context';
|
|
33
|
-
import { SyncError, syncError } from './errors';
|
|
34
|
+
import { reportError, SyncError, syncError } from './errors';
|
|
34
35
|
import {
|
|
35
36
|
emitEvent,
|
|
36
37
|
type PullSubscriptionSummary,
|
|
@@ -70,6 +71,8 @@ interface RequestPlan {
|
|
|
70
71
|
readonly logEpoch: string;
|
|
71
72
|
readonly epochReset: boolean;
|
|
72
73
|
readonly header: ReqHeaderFrame;
|
|
74
|
+
/** §1.5: the code this request reports for its last unseen round failure. */
|
|
75
|
+
readonly roundFailure: RoundFailureFrame | undefined;
|
|
73
76
|
readonly pushes: readonly PushCommitFrame[];
|
|
74
77
|
readonly pull: PullHeaderFrame | undefined;
|
|
75
78
|
readonly subscriptions: readonly SubscriptionPlan[];
|
|
@@ -212,10 +215,12 @@ async function planRequest(
|
|
|
212
215
|
throw syncError('sync.invalid_request', 'missing REQ_HEADER');
|
|
213
216
|
}
|
|
214
217
|
const pushes: PushCommitFrame[] = [];
|
|
218
|
+
let roundFailure: RoundFailureFrame | undefined;
|
|
215
219
|
let pull: PullHeaderFrame | undefined;
|
|
216
220
|
const subFrames: SubscriptionFrame[] = [];
|
|
217
221
|
for (const frame of request.frames) {
|
|
218
|
-
if (frame.type === '
|
|
222
|
+
if (frame.type === 'ROUND_FAILURE') roundFailure = frame;
|
|
223
|
+
else if (frame.type === 'PUSH_COMMIT') pushes.push(frame);
|
|
219
224
|
else if (frame.type === 'PULL_HEADER') pull = frame;
|
|
220
225
|
else if (frame.type === 'SUBSCRIPTION') subFrames.push(frame);
|
|
221
226
|
}
|
|
@@ -241,6 +246,7 @@ async function planRequest(
|
|
|
241
246
|
logEpoch: registry.logEpoch,
|
|
242
247
|
epochReset,
|
|
243
248
|
header,
|
|
249
|
+
roundFailure,
|
|
244
250
|
pushes,
|
|
245
251
|
pull,
|
|
246
252
|
subscriptions: [],
|
|
@@ -256,6 +262,7 @@ async function planRequest(
|
|
|
256
262
|
logEpoch: registry.logEpoch,
|
|
257
263
|
epochReset: true,
|
|
258
264
|
header,
|
|
265
|
+
roundFailure,
|
|
259
266
|
pushes: [],
|
|
260
267
|
pull: undefined,
|
|
261
268
|
subscriptions: [],
|
|
@@ -362,6 +369,7 @@ async function planRequest(
|
|
|
362
369
|
logEpoch: registry.logEpoch,
|
|
363
370
|
epochReset: false,
|
|
364
371
|
header,
|
|
372
|
+
roundFailure,
|
|
365
373
|
pushes,
|
|
366
374
|
pull,
|
|
367
375
|
subscriptions,
|
|
@@ -709,14 +717,21 @@ async function* streamResponse(
|
|
|
709
717
|
: [...(previous?.subscriptions ?? [])];
|
|
710
718
|
const cursor =
|
|
711
719
|
cursors.length > 0 ? Math.min(...cursors) : (previous?.cursor ?? -1);
|
|
712
|
-
await ctx.storage.putClientRecord(
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
+
await ctx.storage.putClientRecord(
|
|
721
|
+
ctx.partition,
|
|
722
|
+
{
|
|
723
|
+
clientId: plan.header.clientId,
|
|
724
|
+
actorId: ctx.actorId,
|
|
725
|
+
wireVersion: plan.wireVersion,
|
|
726
|
+
cursor,
|
|
727
|
+
updatedAtMs: clockOf(ctx)(),
|
|
728
|
+
subscriptions,
|
|
729
|
+
},
|
|
730
|
+
// §4.5: a round that carried a `ROUND_FAILURE` report completes without
|
|
731
|
+
// clearing the reported code and streak; the next request without the
|
|
732
|
+
// frame clears them as a plain completed round does.
|
|
733
|
+
plan.roundFailure !== undefined ? { keepRoundOutcome: true } : undefined,
|
|
734
|
+
);
|
|
720
735
|
if (
|
|
721
736
|
ctx.realtime?.notifySubscriptions !== undefined &&
|
|
722
737
|
JSON.stringify(
|
|
@@ -734,6 +749,7 @@ async function* streamResponse(
|
|
|
734
749
|
report.outcome = 'error';
|
|
735
750
|
report.errorCode = error.code;
|
|
736
751
|
}
|
|
752
|
+
await recordRoundFailure(ctx, plan.header.clientId, error);
|
|
737
753
|
// §1.6: in-band ERROR, then END, nothing else.
|
|
738
754
|
yield encodeResponseFrame(
|
|
739
755
|
{
|
|
@@ -765,7 +781,14 @@ export async function createSyncResponseStream(
|
|
|
765
781
|
ctx: SyncRequestContext,
|
|
766
782
|
): Promise<AsyncIterable<Uint8Array>> {
|
|
767
783
|
const sink = ctx.events;
|
|
768
|
-
if (sink === undefined)
|
|
784
|
+
if (sink === undefined) {
|
|
785
|
+
try {
|
|
786
|
+
return await createStreamCore(bytes, ctx, undefined);
|
|
787
|
+
} catch (error) {
|
|
788
|
+
await recordRoundFailure(ctx, requestClientId(bytes), error);
|
|
789
|
+
throw error;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
769
792
|
// Instrumented path: measure with the ctx clock, count bytes at the
|
|
770
793
|
// seam, emit exactly one `request.handled` per request. Every event of
|
|
771
794
|
// this request carries one `requestId`.
|
|
@@ -779,6 +802,7 @@ export async function createSyncResponseStream(
|
|
|
779
802
|
try {
|
|
780
803
|
return await createStreamCore(bytes, requestCtx, events, startedAtMs);
|
|
781
804
|
} catch (error) {
|
|
805
|
+
const clientId = requestClientId(bytes);
|
|
782
806
|
emitEvent(events, {
|
|
783
807
|
type: 'request.handled',
|
|
784
808
|
kind: 'sync',
|
|
@@ -789,11 +813,78 @@ export async function createSyncResponseStream(
|
|
|
789
813
|
bytesIn: bytes.length,
|
|
790
814
|
bytesOut: 0,
|
|
791
815
|
outcome: 'rejected',
|
|
816
|
+
...(clientId !== undefined ? { clientId } : {}),
|
|
792
817
|
errorCode: error instanceof SyncError ? error.code : 'internal',
|
|
793
818
|
pushCommits: 0,
|
|
794
819
|
pulled: false,
|
|
795
820
|
subscriptions: 0,
|
|
796
821
|
});
|
|
822
|
+
await recordRoundFailure(ctx, clientId, error);
|
|
823
|
+
throw error;
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/** The `REQ_HEADER` clientId of a request that failed before planning. */
|
|
828
|
+
function requestClientId(bytes: Uint8Array): string | undefined {
|
|
829
|
+
try {
|
|
830
|
+
const message = decodeMessage(bytes);
|
|
831
|
+
const header = message.frames[0];
|
|
832
|
+
return message.msgKind === 'request' && header?.type === 'REQ_HEADER'
|
|
833
|
+
? header.clientId
|
|
834
|
+
: undefined;
|
|
835
|
+
} catch {
|
|
836
|
+
return undefined;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* Record a failed round on the client's stored record so an operator can see
|
|
842
|
+
* a stuck client and why. A storage failure here is reported through
|
|
843
|
+
* `onError` and never replaces the round's own outcome.
|
|
844
|
+
*/
|
|
845
|
+
async function recordRoundFailure(
|
|
846
|
+
ctx: SyncRequestContext,
|
|
847
|
+
clientId: string | undefined,
|
|
848
|
+
error: unknown,
|
|
849
|
+
): Promise<void> {
|
|
850
|
+
await recordRoundFailureCode(
|
|
851
|
+
ctx,
|
|
852
|
+
clientId,
|
|
853
|
+
error instanceof SyncError ? error.code : 'sync.internal_error',
|
|
854
|
+
);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/** As `recordRoundFailure`, for a code that is not a thrown catalog error
|
|
858
|
+
* (a `ROUND_FAILURE` report, §1.5). */
|
|
859
|
+
async function recordRoundFailureCode(
|
|
860
|
+
ctx: SyncRequestContext,
|
|
861
|
+
clientId: string | undefined,
|
|
862
|
+
code: string,
|
|
863
|
+
): Promise<void> {
|
|
864
|
+
if (clientId === undefined) return;
|
|
865
|
+
try {
|
|
866
|
+
await ctx.storage.recordClientRoundFailure(
|
|
867
|
+
ctx.partition,
|
|
868
|
+
clientId,
|
|
869
|
+
ctx.actorId,
|
|
870
|
+
code,
|
|
871
|
+
clockOf(ctx)(),
|
|
872
|
+
);
|
|
873
|
+
} catch (failure) {
|
|
874
|
+
reportError(failure, ctx.onError, 'sync');
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/** Record a failure thrown while the response streams (after planning). */
|
|
879
|
+
async function* recordedStream(
|
|
880
|
+
stream: AsyncIterable<Uint8Array>,
|
|
881
|
+
ctx: SyncRequestContext,
|
|
882
|
+
clientId: string,
|
|
883
|
+
): AsyncGenerator<Uint8Array> {
|
|
884
|
+
try {
|
|
885
|
+
yield* stream;
|
|
886
|
+
} catch (error) {
|
|
887
|
+
await recordRoundFailure(ctx, clientId, error);
|
|
797
888
|
throw error;
|
|
798
889
|
}
|
|
799
890
|
}
|
|
@@ -855,6 +946,16 @@ async function createStreamCore(
|
|
|
855
946
|
);
|
|
856
947
|
const served = selected ?? schema;
|
|
857
948
|
const plan = await planRequest(request, ctx, served, registry);
|
|
949
|
+
// §1.5/§4.5: record the reported round failure on the existing record
|
|
950
|
+
// before the round runs (it never creates one, never touches another
|
|
951
|
+
// actor's record). The completion of this round leaves it in place.
|
|
952
|
+
if (plan.roundFailure !== undefined) {
|
|
953
|
+
await recordRoundFailureCode(
|
|
954
|
+
ctx,
|
|
955
|
+
plan.header.clientId,
|
|
956
|
+
plan.roundFailure.code,
|
|
957
|
+
);
|
|
958
|
+
}
|
|
858
959
|
const report: RequestReport = { outcome: 'ok' };
|
|
859
960
|
// RFC 0007 read-verify-refuse for the streamed read path: the generator
|
|
860
961
|
// builds the whole response (its data reads) before yielding the first
|
|
@@ -883,15 +984,21 @@ async function createStreamCore(
|
|
|
883
984
|
if (changed !== undefined) throw serveNotReadyError(changed);
|
|
884
985
|
for (const chunk of buffered) yield chunk;
|
|
885
986
|
};
|
|
886
|
-
if (events === undefined)
|
|
887
|
-
|
|
888
|
-
|
|
987
|
+
if (events === undefined) {
|
|
988
|
+
return recordedStream(verified(), ctx, plan.header.clientId);
|
|
989
|
+
}
|
|
990
|
+
return recordedStream(
|
|
991
|
+
instrumentedStream(
|
|
992
|
+
verified(),
|
|
993
|
+
ctx,
|
|
994
|
+
events,
|
|
995
|
+
plan,
|
|
996
|
+
report,
|
|
997
|
+
bytes.length,
|
|
998
|
+
startedAtMs,
|
|
999
|
+
),
|
|
889
1000
|
ctx,
|
|
890
|
-
|
|
891
|
-
plan,
|
|
892
|
-
report,
|
|
893
|
-
bytes.length,
|
|
894
|
-
startedAtMs,
|
|
1001
|
+
plan.header.clientId,
|
|
895
1002
|
);
|
|
896
1003
|
}
|
|
897
1004
|
|