@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/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 ? { operation, code: e.code, stage: e.stage, retryable: e.retryable, ...extra } : { operation, code: "UNEXPECTED", stage: operation, retryable: false, ...extra };
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
- return { ...merged, rounds, stoppedBecause: stop, remaining: { inboxHasMore, bootstrapIncomplete, chainsIncomplete, waitingFor } };
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
- await this.ensureBackup();
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
- report.outbox = await drain(this.outboxDeps());
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
- report.inbox.streams++;
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
- report.purged = (await purgeHistory(this.store, this.policy, this.now())) + (await purgeOutbox(this.store, this.policy, this.now()));
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
- for (;;) {
244
- // A failed read is the caller's to report: the sync names the stream in its report and goes
245
- // on to the next one, rather than counting a stream it never read as synced.
246
- const page = await deps.relay.readRecords(streamId, from, READ_BATCH);
247
- await processPage(deps, streamId, authorIdentityId, page, summary);
248
- budget.remainingRecords -= page.records.length;
249
- const caughtUp = page.nextSeq >= page.tail || page.nextSeq <= from;
250
- if (caughtUp) break;
251
- if (budget.remainingRecords <= 0 || deps.now() >= budget.deadline) {
252
- summary.hasMore = true;
253
- break;
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
- from = page.nextSeq;
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; // lost response
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++;
@@ -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 };