@plane31/blind-relay 0.3.0 → 0.4.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 +28 -0
- package/dist/client.d.ts +33 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +76 -13
- package/dist/client.js.map +1 -1
- package/dist/errors.d.ts +6 -2
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +5 -1
- package/dist/errors.js.map +1 -1
- package/dist/inbox.d.ts +4 -0
- package/dist/inbox.d.ts.map +1 -1
- package/dist/inbox.js +22 -14
- package/dist/inbox.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/outbox.d.ts +5 -0
- package/dist/outbox.d.ts.map +1 -1
- package/dist/outbox.js +6 -3
- package/dist/outbox.js.map +1 -1
- package/dist/testing/faults.d.ts +5 -0
- package/dist/testing/faults.d.ts.map +1 -1
- package/dist/testing/faults.js +13 -0
- package/dist/testing/faults.js.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +107 -13
- package/src/errors.ts +6 -1
- package/src/inbox.ts +24 -13
- package/src/index.ts +2 -1
- package/src/outbox.ts +9 -3
- package/src/testing/faults.ts +17 -0
package/src/client.ts
CHANGED
|
@@ -235,6 +235,7 @@ export type ReplaceDeviceResult =
|
|
|
235
235
|
|
|
236
236
|
export interface DrainOptions {
|
|
237
237
|
readonly catchUp?: SyncOptions["catchUp"];
|
|
238
|
+
readonly skipMaintenance?: boolean;
|
|
238
239
|
/** Rounds at most. Default 10. */
|
|
239
240
|
readonly maxRounds?: number;
|
|
240
241
|
/** A soft bound in milliseconds across every round: no round starts past it and each round's reading budget is the time left, but a transport call already in flight ends on its own timeout. Default 30,000. */
|
|
@@ -254,6 +255,8 @@ export type DrainStop =
|
|
|
254
255
|
| "authentication"
|
|
255
256
|
/** Nothing more is reported, but a round could not read its inbox fully (discovery or a stream failed), so emptiness is not established. */
|
|
256
257
|
| "incomplete"
|
|
258
|
+
/** The host asked for a pause; `remaining.retryAfterMs` says how long, and the product schedules the next attempt. */
|
|
259
|
+
| "rateLimited"
|
|
257
260
|
| "closed";
|
|
258
261
|
|
|
259
262
|
/** The merged report of every round, with why the loop stopped and what remains. */
|
|
@@ -269,6 +272,8 @@ export interface DrainReport extends SyncReport {
|
|
|
269
272
|
readonly chainsIncomplete: number;
|
|
270
273
|
/** Authors whose current epoch has not reached this device: nothing to do but wait for them. */
|
|
271
274
|
readonly waitingFor: number;
|
|
275
|
+
/** On `rateLimited`: the longest wait the host asked for in the last round, when it said one. */
|
|
276
|
+
readonly retryAfterMs?: number;
|
|
272
277
|
};
|
|
273
278
|
}
|
|
274
279
|
|
|
@@ -280,6 +285,12 @@ export interface SyncOptions {
|
|
|
280
285
|
* at least one page.
|
|
281
286
|
*/
|
|
282
287
|
readonly catchUp?: { readonly maxRecords?: number; readonly maxMs?: number };
|
|
288
|
+
/**
|
|
289
|
+
* Leave the housekeeping to a later sync: the retention purge, the periodic re-check of the
|
|
290
|
+
* reset-key list (a stale list is still fetched, since verification needs it) and the
|
|
291
|
+
* recovery-key re-save. For a short background window; the report says `maintenance: "skipped"`.
|
|
292
|
+
*/
|
|
293
|
+
readonly skipMaintenance?: boolean;
|
|
283
294
|
}
|
|
284
295
|
|
|
285
296
|
export const DEFAULT_CATCH_UP = { maxRecords: 500, maxMs: 5_000 } as const;
|
|
@@ -291,11 +302,39 @@ export interface SyncReport {
|
|
|
291
302
|
/** `hasMore`: a stream still has records on the relay that this sync's budget did not reach; sync again soon. */
|
|
292
303
|
inbox: { streams: number; stored: number; keyPending: number; invalid: number; acked: number; hasMore: boolean };
|
|
293
304
|
purged: number;
|
|
305
|
+
/** Whether this sync did its housekeeping (purge, list re-check, key re-save) or left it to a later one. */
|
|
306
|
+
maintenance: "done" | "skipped";
|
|
294
307
|
/** Present while account recovery discovers circles through the host: pinned, pending and failed on this sync. */
|
|
295
308
|
bootstrap?: { circles: number; pending: number; failed: number };
|
|
309
|
+
/** Wall-clock milliseconds per phase and record bytes moved, for diagnostics (section 14: counts and durations only). */
|
|
310
|
+
timings: SyncTimings;
|
|
296
311
|
errors: SyncError[];
|
|
297
312
|
}
|
|
298
313
|
|
|
314
|
+
export interface SyncTimings {
|
|
315
|
+
resume: number;
|
|
316
|
+
bootstrap: number;
|
|
317
|
+
metadata: number;
|
|
318
|
+
preferences: number;
|
|
319
|
+
epoch: number;
|
|
320
|
+
outbox: number;
|
|
321
|
+
inbox: number;
|
|
322
|
+
maintenance: number;
|
|
323
|
+
total: number;
|
|
324
|
+
/** Record bytes handed to the relay. */
|
|
325
|
+
bytesSent: number;
|
|
326
|
+
/** Record bytes read from the relay. */
|
|
327
|
+
bytesReceived: number;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const emptyTimings = (): SyncTimings => ({ resume: 0, bootstrap: 0, metadata: 0, preferences: 0, epoch: 0, outbox: 0, inbox: 0, maintenance: 0, total: 0, bytesSent: 0, bytesReceived: 0 });
|
|
331
|
+
|
|
332
|
+
/** A monotonic wall clock for durations; the protocol clock (`now`) is not used, since a test drives it by hand. */
|
|
333
|
+
const wall = (): number => {
|
|
334
|
+
const g = globalThis as { performance?: { now?: () => number } };
|
|
335
|
+
return typeof g.performance?.now === "function" ? g.performance.now() : Date.now();
|
|
336
|
+
};
|
|
337
|
+
|
|
299
338
|
/** What `prepareReplacement()` returns: everything the product hands to the host for authorization. All public. */
|
|
300
339
|
export interface ReplacementProposal {
|
|
301
340
|
readonly successionId: Uint8Array;
|
|
@@ -333,10 +372,14 @@ export interface SyncError {
|
|
|
333
372
|
readonly authorIdentityId?: Uint8Array;
|
|
334
373
|
/** On a `bootstrap` entry: the circle that could not be adopted on this sync; it is retried by the next. */
|
|
335
374
|
readonly circleId?: Uint8Array;
|
|
375
|
+
/** On a `RATE_LIMITED` entry: the wait the host asked for, when it said. */
|
|
376
|
+
readonly retryAfterMs?: number;
|
|
336
377
|
}
|
|
337
378
|
|
|
338
379
|
function syncError(operation: string, e: unknown, extra: Partial<Pick<SyncError, "circleId" | "streamId" | "authorIdentityId">> = {}): SyncError {
|
|
339
|
-
return e instanceof KavachError
|
|
380
|
+
return e instanceof KavachError
|
|
381
|
+
? { operation, code: e.code, stage: e.stage, retryable: e.retryable, ...(e.retryAfterMs === undefined ? {} : { retryAfterMs: e.retryAfterMs }), ...extra }
|
|
382
|
+
: { operation, code: "UNEXPECTED", stage: operation, retryable: false, ...extra };
|
|
340
383
|
}
|
|
341
384
|
|
|
342
385
|
const RESET_LIST_RECHECK_MS = 86_400_000n;
|
|
@@ -985,6 +1028,8 @@ class ClientImpl implements Client {
|
|
|
985
1028
|
outbox: { sent: 0, accepted: 0, cancelled: 0, unknown: 0, retry: 0 },
|
|
986
1029
|
inbox: { streams: 0, stored: 0, keyPending: 0, invalid: 0, acked: 0, hasMore: false },
|
|
987
1030
|
purged: 0,
|
|
1031
|
+
maintenance: options.skipMaintenance ? "skipped" : "done",
|
|
1032
|
+
timings: emptyTimings(),
|
|
988
1033
|
errors: []
|
|
989
1034
|
};
|
|
990
1035
|
let rounds = 0;
|
|
@@ -1010,7 +1055,7 @@ class ClientImpl implements Client {
|
|
|
1010
1055
|
break;
|
|
1011
1056
|
}
|
|
1012
1057
|
const roundMs = Math.max(0, Math.min(remainingMs, options.catchUp?.maxMs ?? DEFAULT_CATCH_UP.maxMs));
|
|
1013
|
-
const r = await this.sync({ catchUp: { maxMs: roundMs, ...(options.catchUp?.maxRecords !== undefined ? { maxRecords: options.catchUp.maxRecords } : {}) } });
|
|
1058
|
+
const r = await this.sync({ catchUp: { maxMs: roundMs, ...(options.catchUp?.maxRecords !== undefined ? { maxRecords: options.catchUp.maxRecords } : {}) }, ...(options.skipMaintenance ? { skipMaintenance: true } : {}) });
|
|
1014
1059
|
rounds++;
|
|
1015
1060
|
last = r;
|
|
1016
1061
|
// The round's work counts whether or not it was the last: what it accepted, stored and failed
|
|
@@ -1023,6 +1068,7 @@ class ClientImpl implements Client {
|
|
|
1023
1068
|
merged.inbox.invalid += r.inbox.invalid;
|
|
1024
1069
|
merged.inbox.acked += r.inbox.acked;
|
|
1025
1070
|
merged.purged += r.purged;
|
|
1071
|
+
for (const k of Object.keys(merged.timings) as (keyof SyncTimings)[]) merged.timings[k] += r.timings[k];
|
|
1026
1072
|
merged.epoch = r.epoch;
|
|
1027
1073
|
merged.errors.push(...r.errors);
|
|
1028
1074
|
if (r.bootstrap) merged.bootstrap = r.bootstrap;
|
|
@@ -1031,6 +1077,11 @@ class ClientImpl implements Client {
|
|
|
1031
1077
|
stop = "closed";
|
|
1032
1078
|
break;
|
|
1033
1079
|
}
|
|
1080
|
+
if (r.errors.some((e) => e.code === "RATE_LIMITED")) {
|
|
1081
|
+
// The host asked for a pause: no further round now; the wait goes back to the product.
|
|
1082
|
+
stop = "rateLimited";
|
|
1083
|
+
break;
|
|
1084
|
+
}
|
|
1034
1085
|
const bootstrapIncomplete = (await this.inspect()).bootstrap?.complete === false;
|
|
1035
1086
|
const currentChains = await chains();
|
|
1036
1087
|
const more = r.inbox.hasMore || bootstrapIncomplete || currentChains.size > 0;
|
|
@@ -1065,19 +1116,43 @@ class ClientImpl implements Client {
|
|
|
1065
1116
|
const bootstrapIncomplete = this.closed ? false : (await this.inspect()).bootstrap?.complete === false;
|
|
1066
1117
|
const chainsIncomplete = this.closed ? previousChains.size : (await chains()).size;
|
|
1067
1118
|
const waitingFor = this.closed ? 0 : (await this.getState()).waitingFor.length;
|
|
1068
|
-
const inboxHasMore = !last || inboxUncertain(last) || stop === "closed" ? "unknown" : merged.inbox.hasMore;
|
|
1069
|
-
|
|
1119
|
+
const inboxHasMore = !last || inboxUncertain(last) || stop === "closed" || stop === "rateLimited" ? "unknown" : merged.inbox.hasMore;
|
|
1120
|
+
const waits = (last?.errors ?? []).flatMap((e) => (e.code === "RATE_LIMITED" && e.retryAfterMs !== undefined ? [e.retryAfterMs] : []));
|
|
1121
|
+
const retryAfterMs = waits.length ? Math.max(...waits) : undefined;
|
|
1122
|
+
return { ...merged, rounds, stoppedBecause: stop, remaining: { inboxHasMore, bootstrapIncomplete, chainsIncomplete, waitingFor, ...(retryAfterMs === undefined ? {} : { retryAfterMs }) } };
|
|
1070
1123
|
}
|
|
1071
1124
|
|
|
1072
1125
|
private async runSync(options: SyncOptions): Promise<SyncReport> {
|
|
1126
|
+
const started = wall();
|
|
1073
1127
|
const report: SyncReport = {
|
|
1074
1128
|
metadata: { certificates: 0, snapshots: 0, rotations: 0, quarantined: 0, resets: 0, successions: 0 },
|
|
1075
1129
|
epoch: "unchanged",
|
|
1076
1130
|
outbox: { sent: 0, accepted: 0, cancelled: 0, unknown: 0, retry: 0 },
|
|
1077
1131
|
inbox: { streams: 0, stored: 0, keyPending: 0, invalid: 0, acked: 0, hasMore: false },
|
|
1078
1132
|
purged: 0,
|
|
1133
|
+
maintenance: options.skipMaintenance ? "skipped" : "done",
|
|
1134
|
+
timings: emptyTimings(),
|
|
1079
1135
|
errors: []
|
|
1080
1136
|
};
|
|
1137
|
+
const maintain = !options.skipMaintenance;
|
|
1138
|
+
let mark = started;
|
|
1139
|
+
const phase = (name: keyof Omit<SyncTimings, "total" | "bytesSent" | "bytesReceived">): void => {
|
|
1140
|
+
const now = wall();
|
|
1141
|
+
report.timings[name] += now - mark;
|
|
1142
|
+
mark = now;
|
|
1143
|
+
};
|
|
1144
|
+
const finish = (): SyncReport => {
|
|
1145
|
+
report.timings.total = wall() - started;
|
|
1146
|
+
return report;
|
|
1147
|
+
};
|
|
1148
|
+
try {
|
|
1149
|
+
return await this.runSyncPhases(options, report, maintain, phase);
|
|
1150
|
+
} finally {
|
|
1151
|
+
finish();
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
private async runSyncPhases(options: SyncOptions, report: SyncReport, maintain: boolean, phase: (name: keyof Omit<SyncTimings, "total" | "bytesSent" | "bytesReceived">) => void): Promise<SyncReport> {
|
|
1081
1156
|
this.syncBudget = {
|
|
1082
1157
|
remainingRecords: Math.max(1, Math.floor(options.catchUp?.maxRecords ?? DEFAULT_CATCH_UP.maxRecords)),
|
|
1083
1158
|
deadline: this.now() + BigInt(Math.max(0, Math.floor(options.catchUp?.maxMs ?? DEFAULT_CATCH_UP.maxMs)))
|
|
@@ -1095,6 +1170,7 @@ class ClientImpl implements Client {
|
|
|
1095
1170
|
}
|
|
1096
1171
|
}
|
|
1097
1172
|
}
|
|
1173
|
+
phase("resume");
|
|
1098
1174
|
if (!this.account || !this.selfPin) return report;
|
|
1099
1175
|
const meta = await this.store.read((tx) => tx.get("meta", META));
|
|
1100
1176
|
if (!meta || meta.state === "RETIRED") return report;
|
|
@@ -1119,7 +1195,9 @@ class ClientImpl implements Client {
|
|
|
1119
1195
|
return report;
|
|
1120
1196
|
}
|
|
1121
1197
|
} else if (meta.state !== "ACTIVE") return report;
|
|
1122
|
-
|
|
1198
|
+
phase("resume");
|
|
1199
|
+
if (maintain) await this.ensureBackup();
|
|
1200
|
+
phase("maintenance");
|
|
1123
1201
|
if (meta.bootstrap && !meta.bootstrap.complete) {
|
|
1124
1202
|
try {
|
|
1125
1203
|
report.bootstrap = await this.bootstrap(report, meta.bootstrap);
|
|
@@ -1127,16 +1205,19 @@ class ClientImpl implements Client {
|
|
|
1127
1205
|
report.errors.push(syncError("bootstrap", e));
|
|
1128
1206
|
}
|
|
1129
1207
|
}
|
|
1208
|
+
phase("bootstrap");
|
|
1130
1209
|
try {
|
|
1131
|
-
await this.syncMetadata(report);
|
|
1210
|
+
await this.syncMetadata(report, maintain);
|
|
1132
1211
|
} catch (e) {
|
|
1133
1212
|
report.errors.push(syncError("metadata", e));
|
|
1134
1213
|
}
|
|
1214
|
+
phase("metadata");
|
|
1135
1215
|
try {
|
|
1136
1216
|
await this.pushPreferences(report);
|
|
1137
1217
|
} catch (e) {
|
|
1138
1218
|
report.errors.push(syncError("preference", e));
|
|
1139
1219
|
}
|
|
1220
|
+
phase("preferences");
|
|
1140
1221
|
const after = await this.store.read((tx) => tx.get("meta", META));
|
|
1141
1222
|
if (after?.state === "RETIRED" || this.closed) return report;
|
|
1142
1223
|
try {
|
|
@@ -1144,12 +1225,17 @@ class ClientImpl implements Client {
|
|
|
1144
1225
|
} catch (e) {
|
|
1145
1226
|
report.errors.push(syncError("epoch", e));
|
|
1146
1227
|
}
|
|
1228
|
+
phase("epoch");
|
|
1147
1229
|
if (this.closed) return report;
|
|
1148
1230
|
try {
|
|
1149
|
-
|
|
1231
|
+
const drained = await drain(this.outboxDeps());
|
|
1232
|
+
report.outbox = { sent: drained.sent, accepted: drained.accepted, cancelled: drained.cancelled, unknown: drained.unknown, retry: drained.retry };
|
|
1233
|
+
report.timings.bytesSent += drained.bytes;
|
|
1234
|
+
if (drained.blocked) report.errors.push(syncError("outbox", drained.blocked)); // the records stay queued
|
|
1150
1235
|
} catch (e) {
|
|
1151
1236
|
report.errors.push(syncError("outbox", e));
|
|
1152
1237
|
}
|
|
1238
|
+
phase("outbox");
|
|
1153
1239
|
if (this.closed) return report;
|
|
1154
1240
|
let subs: Awaited<ReturnType<Relay["listSubscriptions"]>> = [];
|
|
1155
1241
|
try {
|
|
@@ -1168,22 +1254,30 @@ class ClientImpl implements Client {
|
|
|
1168
1254
|
const known = await this.store.read((tx) => tx.list("certificates").some(([, c]) => c.streamId === toHex(s.streamId)));
|
|
1169
1255
|
if (!known) continue;
|
|
1170
1256
|
const r = await syncStream(this.inboxDeps(), s.streamId, s.authorIdentityId, budget);
|
|
1171
|
-
|
|
1257
|
+
// What landed counts, whether or not the stream then failed; a failed stream is not synced.
|
|
1172
1258
|
report.inbox.stored += r.stored;
|
|
1173
1259
|
report.inbox.keyPending += r.keyPending;
|
|
1174
1260
|
report.inbox.invalid += r.invalid;
|
|
1261
|
+
report.timings.bytesReceived += r.bytes;
|
|
1262
|
+
if (r.failure !== undefined) {
|
|
1263
|
+
report.errors.push({ ...syncError("inbox", r.failure), streamId: s.streamId, authorIdentityId: s.authorIdentityId });
|
|
1264
|
+
continue;
|
|
1265
|
+
}
|
|
1266
|
+
report.inbox.streams++;
|
|
1175
1267
|
if (r.acked !== undefined) report.inbox.acked++;
|
|
1176
1268
|
if (r.hasMore) report.inbox.hasMore = true;
|
|
1177
1269
|
} catch (e) {
|
|
1178
1270
|
report.errors.push({ ...syncError("inbox", e), streamId: s.streamId, authorIdentityId: s.authorIdentityId });
|
|
1179
1271
|
}
|
|
1180
1272
|
}
|
|
1181
|
-
|
|
1273
|
+
phase("inbox");
|
|
1274
|
+
if (maintain) report.purged = (await purgeHistory(this.store, this.policy, this.now())) + (await purgeOutbox(this.store, this.policy, this.now()));
|
|
1275
|
+
phase("maintenance");
|
|
1182
1276
|
await this.emitState();
|
|
1183
1277
|
return report;
|
|
1184
1278
|
}
|
|
1185
1279
|
|
|
1186
|
-
private async syncMetadata(report: SyncReport): Promise<void> {
|
|
1280
|
+
private async syncMetadata(report: SyncReport, maintain = true): Promise<void> {
|
|
1187
1281
|
const { pin } = this.requireAccount("sync");
|
|
1188
1282
|
// Own account: has this device been superseded?
|
|
1189
1283
|
const [me] = await this.transport.directory.resolveAccounts([this.accountName]);
|
|
@@ -1205,9 +1299,9 @@ class ClientImpl implements Client {
|
|
|
1205
1299
|
return;
|
|
1206
1300
|
}
|
|
1207
1301
|
}
|
|
1208
|
-
// Reset-key list when due.
|
|
1302
|
+
// Reset-key list when due: always when stale (verification needs it), periodically unless maintenance is skipped.
|
|
1209
1303
|
const now = this.now();
|
|
1210
|
-
const due = !this.trust.fresh(now) || now - this.trust.state.timeHighWater >= RESET_LIST_RECHECK_MS;
|
|
1304
|
+
const due = !this.trust.fresh(now) || (maintain && now - this.trust.state.timeHighWater >= RESET_LIST_RECHECK_MS);
|
|
1211
1305
|
if (due) {
|
|
1212
1306
|
const wire = await this.transport.directory.getResetKeyList();
|
|
1213
1307
|
if (wire) {
|
|
@@ -1901,7 +1995,7 @@ class ClientImpl implements Client {
|
|
|
1901
1995
|
private async approve(circleId: Uint8Array, accountName: string): Promise<{ revision: bigint }> {
|
|
1902
1996
|
const [resolved] = await this.transport.directory.resolveAccounts([accountName]);
|
|
1903
1997
|
if (!resolved) throw new KavachError("METADATA_REQUIRED", "approve", "unknown account");
|
|
1904
|
-
const report: SyncReport = { metadata: { certificates: 0, snapshots: 0, rotations: 0, quarantined: 0, resets: 0, successions: 0 }, epoch: "unchanged", outbox: { sent: 0, accepted: 0, cancelled: 0, unknown: 0, retry: 0 }, inbox: { streams: 0, stored: 0, keyPending: 0, invalid: 0, acked: 0, hasMore: false }, purged: 0, errors: [] };
|
|
1998
|
+
const report: SyncReport = { metadata: { certificates: 0, snapshots: 0, rotations: 0, quarantined: 0, resets: 0, successions: 0 }, epoch: "unchanged", outbox: { sent: 0, accepted: 0, cancelled: 0, unknown: 0, retry: 0 }, inbox: { streams: 0, stored: 0, keyPending: 0, invalid: 0, acked: 0, hasMore: false }, purged: 0, maintenance: "done", timings: emptyTimings(), errors: [] };
|
|
1905
1999
|
await this.acceptMemberCertificate(resolved, report);
|
|
1906
2000
|
const requester = resolvedAccountId(resolved);
|
|
1907
2001
|
const pinned = await this.pinFor(requester);
|
package/src/errors.ts
CHANGED
|
@@ -52,6 +52,7 @@ export type ErrorCode =
|
|
|
52
52
|
| "CLOSED"
|
|
53
53
|
| "RECOVERY_REQUIRED"
|
|
54
54
|
| "STORAGE_FAILURE"
|
|
55
|
+
| "RATE_LIMITED"
|
|
55
56
|
/** The host could not be reached or did not answer in time; the same call may succeed later. A transport raises it, never a plain error. */
|
|
56
57
|
| "NETWORK";
|
|
57
58
|
|
|
@@ -63,6 +64,7 @@ const RETRYABLE: ReadonlySet<ErrorCode> = new Set<ErrorCode>([
|
|
|
63
64
|
"LOCKED",
|
|
64
65
|
"STORAGE_FAILURE",
|
|
65
66
|
"NETWORK",
|
|
67
|
+
"RATE_LIMITED",
|
|
66
68
|
"SUCCESSION_PENDING"
|
|
67
69
|
]);
|
|
68
70
|
|
|
@@ -75,13 +77,16 @@ export class KavachError extends Error {
|
|
|
75
77
|
readonly code: ErrorCode;
|
|
76
78
|
readonly stage: string;
|
|
77
79
|
readonly retryable: boolean;
|
|
80
|
+
/** On `RATE_LIMITED`: how long the host asked the client to wait, when it said. */
|
|
81
|
+
readonly retryAfterMs: number | undefined;
|
|
78
82
|
|
|
79
|
-
constructor(code: ErrorCode, stage = "unknown", message?: string) {
|
|
83
|
+
constructor(code: ErrorCode, stage = "unknown", message?: string, options: { retryAfterMs?: number } = {}) {
|
|
80
84
|
super(message ?? `${code} at ${stage}`);
|
|
81
85
|
this.name = "KavachError";
|
|
82
86
|
this.code = code;
|
|
83
87
|
this.stage = stage;
|
|
84
88
|
this.retryable = RETRYABLE.has(code);
|
|
89
|
+
this.retryAfterMs = options.retryAfterMs;
|
|
85
90
|
}
|
|
86
91
|
}
|
|
87
92
|
|
package/src/inbox.ts
CHANGED
|
@@ -191,6 +191,10 @@ export interface StreamSyncSummary {
|
|
|
191
191
|
acked: bigint | undefined;
|
|
192
192
|
/** The relay holds more records past the last page read; the budget stopped the catch-up. */
|
|
193
193
|
hasMore: boolean;
|
|
194
|
+
/** Record bytes read from the relay. */
|
|
195
|
+
bytes: number;
|
|
196
|
+
/** Set when a read failed after the pages counted above: the caller reports it, with what landed. */
|
|
197
|
+
failure?: unknown;
|
|
194
198
|
}
|
|
195
199
|
|
|
196
200
|
function subscription(tx: Pick<Tx, "get">, streamId: Uint8Array, authorIdentityId: Uint8Array): SubscriptionRow {
|
|
@@ -236,23 +240,29 @@ export interface ReadBudget {
|
|
|
236
240
|
* records past the last page when the budget stopped the loop.
|
|
237
241
|
*/
|
|
238
242
|
export async function syncStream(deps: InboxDeps, streamId: Uint8Array, authorIdentityId: Uint8Array, budget: ReadBudget = { remainingRecords: READ_BATCH, deadline: deps.now() }): Promise<StreamSyncSummary> {
|
|
239
|
-
const summary: StreamSyncSummary = { read: 0, stored: 0, duplicate: 0, keyPending: 0, invalid: 0, expired: 0, acked: undefined, hasMore: false };
|
|
243
|
+
const summary: StreamSyncSummary = { read: 0, stored: 0, duplicate: 0, keyPending: 0, invalid: 0, expired: 0, acked: undefined, hasMore: false, bytes: 0 };
|
|
240
244
|
const sub = await deps.store.read((tx) => subscription(tx, streamId, authorIdentityId));
|
|
241
245
|
const unresolved = await deps.store.read((tx) => tx.list("unresolved", toHex(streamId) + ":"));
|
|
242
246
|
let from = unresolved.length > 0 ? unbig(sub.ackPrefix) : unbig(sub.scanPos);
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
247
|
+
try {
|
|
248
|
+
for (;;) {
|
|
249
|
+
// A failed read is the caller's to report: the sync names the stream in its report and goes
|
|
250
|
+
// on to the next one, rather than counting a stream it never read as synced.
|
|
251
|
+
const page = await deps.relay.readRecords(streamId, from, READ_BATCH);
|
|
252
|
+
await processPage(deps, streamId, authorIdentityId, page, summary);
|
|
253
|
+
budget.remainingRecords -= page.records.length;
|
|
254
|
+
const caughtUp = page.nextSeq >= page.tail || page.nextSeq <= from;
|
|
255
|
+
if (caughtUp) break;
|
|
256
|
+
if (budget.remainingRecords <= 0 || deps.now() >= budget.deadline) {
|
|
257
|
+
summary.hasMore = true;
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
from = page.nextSeq;
|
|
254
261
|
}
|
|
255
|
-
|
|
262
|
+
} catch (e) {
|
|
263
|
+
// The pages that landed before the failure are real work: the summary keeps them and carries
|
|
264
|
+
// the failure beside them, untouched, for the caller to report.
|
|
265
|
+
summary.failure = e;
|
|
256
266
|
}
|
|
257
267
|
return summary;
|
|
258
268
|
}
|
|
@@ -269,6 +279,7 @@ async function processPage(deps: InboxDeps, streamId: Uint8Array, authorIdentity
|
|
|
269
279
|
}
|
|
270
280
|
for (const item of page.records) {
|
|
271
281
|
summary.read++;
|
|
282
|
+
summary.bytes += item.wire.length;
|
|
272
283
|
if (item.deliverySeq <= page.expiredThrough) {
|
|
273
284
|
summary.expired++;
|
|
274
285
|
continue;
|
package/src/index.ts
CHANGED
|
@@ -36,7 +36,8 @@ export {
|
|
|
36
36
|
type ReplacementProposal,
|
|
37
37
|
type SyncError,
|
|
38
38
|
type SyncOptions,
|
|
39
|
-
type SyncReport
|
|
39
|
+
type SyncReport,
|
|
40
|
+
type SyncTimings
|
|
40
41
|
} from "./client.js";
|
|
41
42
|
|
|
42
43
|
// Errors -------------------------------------------------------------------------------------------
|
package/src/outbox.ts
CHANGED
|
@@ -199,6 +199,10 @@ export interface DrainSummary {
|
|
|
199
199
|
cancelled: number;
|
|
200
200
|
unknown: number;
|
|
201
201
|
retry: number;
|
|
202
|
+
/** Record bytes handed to the relay by this drain. */
|
|
203
|
+
bytes: number;
|
|
204
|
+
/** The error that ended the drain early, when a send failed before or without an answer (NETWORK, RATE_LIMITED); its record stays queued. */
|
|
205
|
+
blocked?: KavachError;
|
|
202
206
|
}
|
|
203
207
|
|
|
204
208
|
/** "none": nothing to send, the drain ends. "skipped": a row was cancelled, the next one is tried. */
|
|
@@ -211,17 +215,19 @@ type HandoffStep = { kind: "none" } | { kind: "skipped" } | { kind: "sent"; key:
|
|
|
211
215
|
* revalidates first.
|
|
212
216
|
*/
|
|
213
217
|
export async function drain(deps: OutboxDeps, maxRecords = 1000): Promise<DrainSummary> {
|
|
214
|
-
const summary: DrainSummary = { sent: 0, accepted: 0, cancelled: 0, unknown: 0, retry: 0 };
|
|
218
|
+
const summary: DrainSummary = { sent: 0, accepted: 0, cancelled: 0, unknown: 0, retry: 0, bytes: 0 };
|
|
215
219
|
for (let i = 0; i < maxRecords; i++) {
|
|
216
220
|
const step = await handoffNext(deps, summary);
|
|
217
221
|
if (step.kind === "none") break;
|
|
218
222
|
if (step.kind === "skipped") continue; // a cancelled row counts against the limit, never ends the drain
|
|
219
223
|
summary.sent++;
|
|
224
|
+
summary.bytes += step.row.wire.length / 2;
|
|
220
225
|
let outcome: AppendOutcome | undefined;
|
|
221
226
|
try {
|
|
222
227
|
outcome = await step.outcome;
|
|
223
|
-
} catch {
|
|
224
|
-
outcome = undefined; //
|
|
228
|
+
} catch (e) {
|
|
229
|
+
outcome = undefined; // no answer: the identical bytes are retried, the relay answers from its checkpoint
|
|
230
|
+
if (e instanceof KavachError) summary.blocked ??= e; // and the reason reaches the report
|
|
225
231
|
}
|
|
226
232
|
const final = await recordOutcome(deps, step.key, outcome);
|
|
227
233
|
if (final === "accepted") summary.accepted++;
|
package/src/testing/faults.ts
CHANGED
|
@@ -113,6 +113,10 @@ export interface MaliciousKnobs {
|
|
|
113
113
|
readonly hold: Map<MutationName, Promise<void>>;
|
|
114
114
|
/** Called when a held mutation has reached its gate (the host has answered; the response is being held), so a test can act at exactly that point. */
|
|
115
115
|
onHeld?: (name: MutationName) => void;
|
|
116
|
+
/** Mutations whose next call fails with exactly this error before reaching the host (consumed once). */
|
|
117
|
+
readonly failWith: Map<MutationName, KavachError>;
|
|
118
|
+
/** Record reads fail once with NETWORK after this many pages have been served. */
|
|
119
|
+
failReadsAfter?: number | undefined;
|
|
116
120
|
/** Reads that answer "nothing" while present. */
|
|
117
121
|
readonly withhold: Set<"snapshots" | "epochs" | "records" | "requests" | "currentEpoch" | "keyList" | "certificates" | "successions" | "circleList" | "subscriptions">;
|
|
118
122
|
/** Older documents served instead of the current ones. */
|
|
@@ -142,6 +146,7 @@ export function emptyKnobs(): MaliciousKnobs {
|
|
|
142
146
|
loseResponseOf: new Set(),
|
|
143
147
|
refuse: new Set(),
|
|
144
148
|
hold: new Map(),
|
|
149
|
+
failWith: new Map(),
|
|
145
150
|
withhold: new Set(),
|
|
146
151
|
replay: { snapshots: new Map() },
|
|
147
152
|
fork: { snapshots: new Map(), manifests: new Map(), records: new Map(), certificates: new Map() },
|
|
@@ -298,6 +303,11 @@ export class MaliciousTransport implements Transport {
|
|
|
298
303
|
appendRecord: (recordId, wire) => {
|
|
299
304
|
// Must stay synchronous up to the inner call (T6h).
|
|
300
305
|
this.calls.push("appendRecord");
|
|
306
|
+
const fail = k.failWith.get("appendRecord");
|
|
307
|
+
if (fail) {
|
|
308
|
+
k.failWith.delete("appendRecord");
|
|
309
|
+
return Promise.reject(fail);
|
|
310
|
+
}
|
|
301
311
|
const p = r().appendRecord(recordId, wire);
|
|
302
312
|
if (!k.loseResponseOf.has("appendRecord")) return p;
|
|
303
313
|
k.loseResponseOf.delete("appendRecord");
|
|
@@ -308,6 +318,13 @@ export class MaliciousTransport implements Transport {
|
|
|
308
318
|
},
|
|
309
319
|
readRecords: async (streamId, afterSeq, limit): Promise<ReadRecordsResult> => {
|
|
310
320
|
this.calls.push("readRecords");
|
|
321
|
+
if (k.failReadsAfter !== undefined) {
|
|
322
|
+
if (k.failReadsAfter <= 0) {
|
|
323
|
+
k.failReadsAfter = undefined;
|
|
324
|
+
throw Object.freeze(new KavachError("NETWORK", "transport", "network unreachable")); // frozen, as a transport may hand it over
|
|
325
|
+
}
|
|
326
|
+
k.failReadsAfter--;
|
|
327
|
+
}
|
|
311
328
|
if (k.withhold.has("records")) {
|
|
312
329
|
const probe = await r().readRecords(streamId, afterSeq, limit);
|
|
313
330
|
return { ...probe, records: [], nextSeq: afterSeq };
|