@framers/agentos-ext-account-sources 0.9.4 → 0.9.5

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.
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import { createHash, randomUUID } from 'node:crypto';
8
8
  import { SqlSourceDerivationStore, createSourceDerivation, } from '@framers/agentos/cognition/library';
9
- import { createSourceSyncState, transitionSourceSync, sourceSyncDue, openSourceSyncCycle, advanceSourceSyncPage, committableSourceCursor, assertSourceSyncPolicy, SourceSyncFault, SOURCE_SYNC_OPERATING_BOUNDS, sourceSyncContentChanges, SourceSyncPolicyTuneError, } from './state.js';
9
+ import { createSourceSyncState, transitionSourceSync, sourceSyncDue, openSourceSyncCycle, advanceSourceSyncPage, committableSourceCursor, assertSourceSyncPolicy, SourceSyncFault, SOURCE_SYNC_OPERATING_BOUNDS, sourceSyncContentChanges, SourceSyncPolicyTuneError, isTerminalSourceWork, } from './state.js';
10
10
  import { sourceSyncTables } from './schema.js';
11
11
  /**
12
12
  * Thrown by `expire` when it fails after removing at least one source. Each removal committed in a transaction of its
@@ -41,7 +41,26 @@ class WithdrawFirst extends Error {
41
41
  const params = (s) => [s.tenantId, s.ownerId, s.connectionId];
42
42
  const validNumber = (n) => Number.isSafeInteger(n) && n >= 0;
43
43
  const validId = (s) => typeof s === 'string' && s.length > 0 && s.length <= 512;
44
+ // The reasons a read result may carry. `attempt_limit` is the store's own, recorded when it gives a read up, so a
45
+ // result that carries it is refused.
44
46
  const skipReasons = ["not_permitted", "no_store", "revalidation_required", "invalid_cache_header", "invalid_bounds", "expired", "excluded", "out_of_window", "excluded_label", "no_eligible_content", "response_too_large", "structure_limit", "unsupported_charset", "invalid_encoding", "document_limit", "held_limit", "indexing_limit"];
47
+ const dispositionKinds = ['pending', 'retry', 'applied', 'gone', 'skipped'];
48
+ /**
49
+ * The failure from which the store gives a read up as an `attempt_limit` skip: the fifth of the count of five attempts
50
+ * after the first count's `pollMs` wait, and any after it, once the failures are evidently the read's own or its cycle
51
+ * has been open for `reconcileMs`.
52
+ */
53
+ const readFailureLimit = 10;
54
+ /** Whether a tally holds counts the store could have kept: nonnegative safe integers, no more skipped than settled. */
55
+ const validTally = (tally) => !!tally
56
+ && typeof tally === 'object' && [tally.openedAt, tally.settled, tally.skipped, tally.answered, tally.prepared,
57
+ tally.answeredAtClaim, tally.preparedAtClaim].every((n) => typeof n === 'number' && validNumber(n))
58
+ && tally.skipped <= tally.settled && tally.answeredAtClaim <= tally.answered
59
+ && tally.preparedAtClaim <= tally.prepared;
60
+ /** Whether a recorded disposition is a provider's answer: settled, and not a skip for an exclusion or a give-up. */
61
+ const answers = (disposition) => isTerminalSourceWork(disposition)
62
+ && !(disposition.kind === 'skipped' && (disposition.reason === 'excluded' || disposition.reason === 'attempt_limit'));
63
+ const isDispositionKind = (kind) => typeof kind === 'string' && dispositionKinds.includes(kind);
45
64
  const newest = (a, b) => b.activityAt - a.activityAt || (a.dependency.sourceId < b.dependency.sourceId ? -1
46
65
  : a.dependency.sourceId > b.dependency.sourceId ? 1 : 0);
47
66
  /** Whether a retained copy may still be held: before its expiry, with every message inside the policy's window. */
@@ -255,7 +274,8 @@ export class SqlAccountSourceSyncStore {
255
274
  /**
256
275
  * Claims the connection's lease for one sync. The owner must be active, the host's `active` binding must allow it,
257
276
  * no barrier or live lease may be set and the connection must be due for `trigger`; a manual claim records the
258
- * request.
277
+ * request. With a cycle open, the claim marks where the run starts in the cycle's counts (`answeredAtClaim` and
278
+ * `preparedAtClaim`), so a read that first fails in this run is weighed against what the run did before it too.
259
279
  *
260
280
  * @returns The handle, or null.
261
281
  */
@@ -276,6 +296,12 @@ export class SqlAccountSourceSyncStore {
276
296
  row.state = transitionSourceSync(row.state, { type: 'sync_started' }, now);
277
297
  row.lease_token = randomUUID();
278
298
  row.lease_until = now + row.policy.leaseMs;
299
+ // A checkpoint without valid counts keeps none here; the first call that needs them counts them.
300
+ const tally = row.checkpoint?.tally;
301
+ if (row.checkpoint && validTally(tally)) {
302
+ row.checkpoint = { ...row.checkpoint, tally: { ...tally,
303
+ answeredAtClaim: tally.answered, preparedAtClaim: tally.prepared } };
304
+ }
279
305
  await this.save(tx, scope, row);
280
306
  return { ticket, lease: { scope, generation: row.state.generation,
281
307
  token: row.lease_token, expiresAt: Number(row.lease_until) } };
@@ -326,7 +352,8 @@ export class SqlAccountSourceSyncStore {
326
352
  /**
327
353
  * Opens a cycle's durable checkpoint and drops the previous cycle's work, or answers the checkpoint already open.
328
354
  * An incremental cycle starts from the committed cursor. With `replaceExpiredHistory`, a `recovery` cycle replaces
329
- * the open one. The cutoff defaults to the connection's last change.
355
+ * the open one. The cutoff defaults to the connection's last change. A new checkpoint holds the cycle's running
356
+ * counts at zero and its opening time by the store's clock (`tally`).
330
357
  *
331
358
  * @returns The checkpoint, or null for a stale handle or an incremental cycle from another cursor.
332
359
  * @throws {SourceSyncFault} `invalid_response` for a cutoff in the future or a replacement that is not `recovery`.
@@ -342,8 +369,10 @@ export class SqlAccountSourceSyncStore {
342
369
  if (input.cutoff !== undefined && (!validNumber(input.cutoff) || input.cutoff > this.now()))
343
370
  throw new SourceSyncFault("invalid_response");
344
371
  await tx.run(`DELETE FROM ${this.t.work} WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3`, params(h.lease.scope));
345
- row.checkpoint = openSourceSyncCycle({ ...input, cycleId: input.cycleId ?? randomUUID(),
346
- generation: row.state.generation, cutoff: input.cutoff ?? row.state.updatedAt });
372
+ const tally = { openedAt: this.now(), settled: 0, skipped: 0, answered: 0, prepared: 0,
373
+ answeredAtClaim: 0, preparedAtClaim: 0 };
374
+ row.checkpoint = { ...openSourceSyncCycle({ ...input, cycleId: input.cycleId ?? randomUUID(),
375
+ generation: row.state.generation, cutoff: input.cutoff ?? row.state.updatedAt }), tally };
347
376
  row.last_page = null;
348
377
  await this.save(tx, h.lease.scope, row);
349
378
  return row.checkpoint;
@@ -351,7 +380,11 @@ export class SqlAccountSourceSyncStore {
351
380
  }
352
381
  /**
353
382
  * Records one page: the next checkpoint and its queued work, in one transaction. The same page sent again right
354
- * after it was recorded answers the same checkpoint; any other stale revision answers null.
383
+ * after it was recorded answers the same checkpoint; any other stale revision answers null. A page that lists a
384
+ * source already queued in the cycle queues it again as pending, and a settled read comes off the cycle's counts. A
385
+ * read the provider has not answered since it failed, one waiting, pending or given up, keeps its failures and the
386
+ * counts they are weighed against, so a read given up before is given up again at its next failure; a read the
387
+ * provider answered starts a count of its own.
355
388
  *
356
389
  * @throws {SourceSyncFault} `invalid_response` for an id longer than 512 characters, and the page rules'
357
390
  * `invalid_source_sync_page` for a page the checkpoint does not admit.
@@ -359,6 +392,7 @@ export class SqlAccountSourceSyncStore {
359
392
  async page(h, expected, page) {
360
393
  return this.withHandle(h, async (tx, row) => {
361
394
  const cp = row.checkpoint;
395
+ const s = h.lease.scope;
362
396
  if (!cp || cp.cycleId !== expected.cycleId || cp.generation !== expected.generation)
363
397
  throw new StaleSync();
364
398
  const digest = createHash('sha256').update(JSON.stringify({
@@ -370,16 +404,42 @@ export class SqlAccountSourceSyncStore {
370
404
  if (cp.revision !== expected.revision)
371
405
  throw new StaleSync();
372
406
  const next = advanceSourceSyncPage(cp, page, row.policy);
373
- for (const id of next.enqueueIds) {
407
+ const ids = [...next.enqueueIds];
408
+ for (const id of ids)
374
409
  if (!validId(id))
375
410
  throw new SourceSyncFault('invalid_response');
411
+ // A checkpoint without valid counts, as an earlier release wrote it, is counted once the rows are written.
412
+ let tally = validTally(cp.tally) ? cp.tally : null;
413
+ // The rows this page lists again, read through the primary key, so a page costs the rows it lists.
414
+ const earlier = new Map();
415
+ if (ids.length > 0) {
416
+ const rows = await tx.all(`SELECT work FROM ${this.t.work}
417
+ WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3 AND source_id=ANY($4::text[])`, [...params(s), ids]);
418
+ for (const { work } of rows) {
419
+ if (work.cycleId === cp.cycleId && work.generation === cp.generation)
420
+ earlier.set(work.sourceId, work);
421
+ }
422
+ }
423
+ for (const id of ids) {
424
+ const before = earlier.get(id);
425
+ if (tally && before && isTerminalSourceWork(before.disposition)) {
426
+ tally = { ...tally, settled: tally.settled - 1,
427
+ skipped: tally.skipped - (before.disposition.kind === 'skipped' ? 1 : 0) };
428
+ }
429
+ const failing = before !== undefined && !answers(before.disposition) ? before : undefined;
376
430
  const work = { cycleId: cp.cycleId, generation: cp.generation, sourceId: id,
377
- discoveredRevision: next.checkpoint.revision, attempts: 0, disposition: { kind: 'pending' } };
378
- await this.putWork(tx, h.lease.scope, work);
431
+ discoveredRevision: next.checkpoint.revision, attempts: 0, disposition: { kind: 'pending' },
432
+ ...(failing?.failures !== undefined ? { failures: failing.failures } : {}),
433
+ ...(failing?.answeredAtFailure !== undefined ? { answeredAtFailure: failing.answeredAtFailure } : {}),
434
+ ...(failing?.preparedAtFailure !== undefined ? { preparedAtFailure: failing.preparedAtFailure } : {}) };
435
+ await this.putWork(tx, s, work);
379
436
  }
380
- row.checkpoint = next.checkpoint;
437
+ // Counts that would fall below zero were not the store's: count them again from the rows just written.
438
+ if (!validTally(tally))
439
+ tally = await this.countTally(tx, s, cp);
440
+ row.checkpoint = { ...next.checkpoint, tally };
381
441
  row.last_page = digest;
382
- await this.save(tx, h.lease.scope, row);
442
+ await this.save(tx, s, row);
383
443
  return row.checkpoint;
384
444
  });
385
445
  }
@@ -391,27 +451,56 @@ export class SqlAccountSourceSyncStore {
391
451
  /**
392
452
  * Null id takes the first due item. Increment before external work; a crash leaves it pending.
393
453
  *
394
- * An item is due when it is pending or its retry time has come; without an id the first due item by source id is
395
- * taken. A pending item that already has five attempts and no recorded result, as when a process died during each
396
- * read, is not taken: it waits `pollMs` as a `storage_unavailable` retry, and the attempt after a wait at five
454
+ * An item is due when it is pending or its retry time has come. Without an id, a pending item is taken before one
455
+ * whose retry time has come, so a read that keeps failing never holds back one that has not failed: the first
456
+ * pending item by source id, then the retry whose wait passed first, ties by source id. Each look reads one row
457
+ * through one of the schema's work indexes, by disposition and source id or by retry time, however many items the
458
+ * cycle queued: the pending look orders by the disposition index's own keys with the kind bounded as a range, so no
459
+ * other index can answer it without a sort and the settled rows before the first pending one are never read. A pending item that already has five attempts and no recorded result, as when a process died during
460
+ * each read, is not taken: it waits `pollMs` as a `storage_unavailable` retry, and the attempt after a wait at five
397
461
  * attempts starts a new count.
398
462
  *
399
463
  * @returns The item with its attempt counted, or null when none is due or the handle is stale.
400
464
  */
401
465
  async takeWork(h, id = null) {
402
466
  return this.withHandle(h, async (tx, row) => {
403
- const rows = await tx.all(`SELECT work FROM ${this.t.work}
404
- WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3
405
- AND ($4::text IS NULL OR source_id=$4) ORDER BY source_id`, [...params(h.lease.scope), id]);
406
467
  const now = this.now();
407
- for (const { work } of rows) {
408
- if (work.cycleId !== row.checkpoint?.cycleId || work.generation !== h.lease.generation)
409
- continue;
410
- if (work.disposition.kind !== 'pending' && work.disposition.kind !== 'retry')
411
- continue;
412
- if (work.disposition.kind === 'retry' && work.disposition.retryAt > now)
413
- continue;
468
+ const cycle = [row.checkpoint?.cycleId ?? '', h.lease.generation];
469
+ for (;;) {
470
+ // The due rows alone, one at a time, each look served by an index whose rows are the ones it wants: the
471
+ // first pending row by source id, then the retry whose wait passed first. A terminal row and one still
472
+ // waiting are never read, so a cycle of many reads costs each take a row, and a read that keeps failing is
473
+ // taken only once nothing that has not failed is due.
474
+ let found;
475
+ if (id !== null) {
476
+ found = await tx.get(`SELECT work FROM ${this.t.work}
477
+ WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3 AND source_id=$4
478
+ AND work->>'cycleId'=$5 AND (work->>'generation')::bigint=$6::bigint
479
+ AND (work->'disposition'->>'kind'='pending' OR (work->'disposition'->>'kind'='retry'
480
+ AND (work->'disposition'->>'retryAt')::bigint<=$7::bigint))`, [...params(h.lease.scope), id, ...cycle, now]);
481
+ }
482
+ else {
483
+ // The kind is bounded as a range, not an equality, and the order names the kind before the source id: the
484
+ // planner then has one index that answers the order without a sort, the disposition index, whose first row
485
+ // is the take. With an equality the primary key answers the same order by source id alone, and on a fresh
486
+ // table its estimate ties the disposition index's, so a take through it read and dropped every settled row
487
+ // before the first pending one (about half the connection a take across a drain).
488
+ found = await tx.get(`SELECT work FROM ${this.t.work}
489
+ WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3
490
+ AND work->'disposition'->>'kind' BETWEEN 'pending' AND 'pending'
491
+ AND work->>'cycleId'=$4 AND (work->>'generation')::bigint=$5::bigint
492
+ ORDER BY work->'disposition'->>'kind',source_id LIMIT 1`, [...params(h.lease.scope), ...cycle]);
493
+ found ??= await tx.get(`SELECT work FROM ${this.t.work}
494
+ WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3 AND work->'disposition'->>'kind'='retry'
495
+ AND (work->'disposition'->>'retryAt')::bigint<=$6::bigint
496
+ AND work->>'cycleId'=$4 AND (work->>'generation')::bigint=$5::bigint
497
+ ORDER BY (work->'disposition'->>'retryAt')::bigint,source_id LIMIT 1`, [...params(h.lease.scope), ...cycle, now]);
498
+ }
499
+ if (!found)
500
+ return null;
501
+ const { work } = found;
414
502
  if (work.disposition.kind === 'pending' && work.attempts >= 5) {
503
+ // The wait is after `now`, so the next look does not find this row again.
415
504
  await this.putWork(tx, h.lease.scope, { ...work, disposition: {
416
505
  kind: 'retry', reason: 'storage_unavailable', retryAt: now + row.policy.pollMs,
417
506
  } });
@@ -424,7 +513,6 @@ export class SqlAccountSourceSyncStore {
424
513
  await this.putWork(tx, h.lease.scope, next);
425
514
  return next;
426
515
  }
427
- return null;
428
516
  });
429
517
  }
430
518
  /**
@@ -560,6 +648,15 @@ export class SqlAccountSourceSyncStore {
560
648
  WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3`, params(scope));
561
649
  return rows.map((r) => r.document);
562
650
  }
651
+ /** How many sources the connection retains, counted in the database: the progress after each read never reads their provenance. */
652
+ async documentCount(tx, scope) {
653
+ const found = await tx.get(`SELECT COUNT(*)::int AS n FROM ${this.t.documents}
654
+ WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3`, params(scope));
655
+ const n = Number(found?.n ?? 0);
656
+ if (!validNumber(n))
657
+ throw new StaleSync();
658
+ return n;
659
+ }
563
660
  /** Internal provenance enumeration. Never expose this as a browser retrieval response. */
564
661
  async documents(scope) {
565
662
  return this.documentRows(this.db, scope);
@@ -586,17 +683,26 @@ export class SqlAccountSourceSyncStore {
586
683
  }
587
684
  /**
588
685
  * Applies one read result for a work item taken in this cycle, under a live lease and the owner fence. A retry
589
- * keeps the work unresolved. A document is weighed first: excluded, outside the retained limits or over the
590
- * indexing allowance with the characters it adds to index, it is recorded as a skip with its reason, and so is one
591
- * whose cache lifetime has ended by the store's clock when it is weighed (`expired`), as a document admitted with
592
- * seconds of it left and applied again after a longer preparation: an expired read is never refused as malformed. A
593
- * document that fits replaces the source's old copy in the same transaction (host write, indexing charge and provenance),
594
- * after the old copy and every result derived from it are purged, evicting this connection's older sources the
595
- * limits no longer admit. A read that repeats the text and message spans of a copy the store still holds, within
596
- * its expiry and window, adds nothing to index and is never charged. Any other read of such a copy adds its new and
597
- * changed messages and is weighed and charged by them alone: a message the copy holds with the same id, text hash,
598
- * cut and length is free, as is the text before it (its separator and any header lines) when that text is the
599
- * copy's and the message before it is the one the copy has there, itself unchanged. A reply adds its separator and
686
+ * keeps the work unresolved and counts in the row's `failures`, across the counts of attempts `takeWork` starts
687
+ * again after a `pollMs` wait. From its tenth failure on, the store records the read as skipped with `attempt_limit`
688
+ * in place of the wait once its failures are evidently its own: a failure at the provider once the provider has
689
+ * answered another read of the cycle, and a `storage_unavailable` failure once another document of the cycle has
690
+ * been prepared and written, in the run of the read's first failure of that kind (since its lease was claimed) or
691
+ * after it. Without that, as in an outage, the read keeps its wait until its cycle has been open for `reconcileMs`,
692
+ * and its first failure from then on gives it up. That skip keeps the previous copy within its own expiry and window:
693
+ * the read saw nothing of the source, so nothing is decided about the copy, and a later read replaces it. A result
694
+ * that carries `attempt_limit` itself is refused. Each result that settles a work item changes the cycle's running
695
+ * counts with it, in the same transaction. A document is weighed first: excluded, outside the retained limits or
696
+ * over the indexing allowance with the characters it adds to index, it is recorded as a skip with its reason, and
697
+ * so is one whose cache lifetime has ended by the store's clock when it is weighed (`expired`), as a document
698
+ * admitted with seconds of it left and applied again after a longer preparation: an expired read is never refused as
699
+ * malformed. A document that fits replaces the source's old copy in the same transaction (host write, indexing
700
+ * charge and provenance), after the old copy and every result derived from it are purged, evicting this connection's
701
+ * older sources the limits no longer admit. A read that repeats the text and message spans of a copy the store still
702
+ * holds, within its expiry and window, adds nothing to index and is never charged. Any other read of such a copy adds
703
+ * its new and changed messages and is weighed and charged by them alone: a message the copy holds with the same id,
704
+ * text hash, cut and length is free, as is the text before it (its separator and any header lines) when that text is
705
+ * the copy's and the message before it is the one the copy has there, itself unchanged. A reply adds its separator and
600
706
  * text, a message edited in place adds itself with the separators on both sides of it, a changed header line or
601
707
  * separator adds the text it is in, a message moved after another neighbour adds its separator, and a read that
602
708
  * only drops messages adds nothing, since the messages it keeps follow the same neighbours among those kept (a
@@ -621,14 +727,16 @@ export class SqlAccountSourceSyncStore {
621
727
  * store's clock, so from then on `readCurrent` answers it expired and the fence neither commits nor delivers a result
622
728
  * derived from it, even when the purge that follows fails, as when the host's `purge` binding throws. A failing
623
729
  * binding rolls the rest of the application back; the withdrawn copy is past its expiry, so the next `expire` or
624
- * `sweepExpired` removes it, as does a later application of the read.
730
+ * `sweepExpired` removes it, as does a later application of the read. A read the store gives up withdraws nothing,
731
+ * since it keeps the copy.
625
732
  *
626
733
  * @param prepared - The host's prepared value for a document result; null for any other result, and for a document
627
734
  * weighed before its preparation.
628
735
  * @returns The disposition and the handle later calls use, whose ticket reflects any purge; `pending` with the same
629
736
  * handle, and nothing recorded, for a document without its prepared value that the store has to write; null for a
630
737
  * stale handle or work item.
631
- * @throws {SourceSyncFault} `invalid_response` for a result that does not match its work item or is malformed.
738
+ * @throws {SourceSyncFault} `invalid_response` for a result that does not match its work item or is malformed, a skip
739
+ * that carries `attempt_limit` among them.
632
740
  * @throws {Error} `invalid_source_allowance` when the host's allowance is not made of nonnegative safe integers, and
633
741
  * `invalid_source_sync_charge` should the characters a read adds come out below zero or fractional, which the
634
742
  * ordered spans of a valid read and an intact copy rule out; nothing is charged or written.
@@ -636,33 +744,64 @@ export class SqlAccountSourceSyncStore {
636
744
  async applyResult(h, work, result, prepared) {
637
745
  // The purge below runs in the transaction that records the result, so a binding that throws rolls the copy's
638
746
  // invalidation back with it. The withdrawal commits first and survives that rollback. Only this lease writes the
639
- // connection's copies, so a copy not held at this read cannot appear before the purge.
640
- if ((result.kind === 'gone' || result.kind === 'skipped') && await this.holds(h.lease.scope, work.sourceId)
641
- && await this.withdraw(h, work, result) === null)
747
+ // connection's copies, so a copy not held at this read cannot appear before the purge. A result that carries
748
+ // `attempt_limit`, the store's own reason, withdraws nothing and is refused below.
749
+ // The read result's type leaves `attempt_limit` out; a provider that breaks the type at run time still withdraws
750
+ // nothing, so the comparison is made on the reason as the store's wider type.
751
+ if ((result.kind === 'gone' || (result.kind === 'skipped' && result.reason !== 'attempt_limit'))
752
+ && await this.holds(h.lease.scope, work.sourceId) && await this.withdraw(h, work, result) === null)
642
753
  return null;
643
754
  // `withdrawn` once the copy of a read the weighing below finds expired has been withdrawn (`WithdrawFirst`).
644
755
  const apply = (withdrawn) => this.withHandle(h, async (tx, row) => {
645
756
  const s = h.lease.scope;
646
- await this.currentWork(tx, s, row.checkpoint, work);
757
+ const current = await this.currentWork(tx, s, row.checkpoint, work);
647
758
  if (result.kind === "skipped" && !skipReasons.includes(result.reason))
648
759
  throw new SourceSyncFault("invalid_response");
649
760
  if (result.kind !== 'document' && result.id !== work.sourceId)
650
761
  throw new SourceSyncFault('invalid_response');
762
+ const cp = row.checkpoint;
763
+ if (!cp)
764
+ throw new StaleSync();
765
+ const tally = await this.tallyOf(tx, s, cp);
766
+ // The row as recorded: the stored one, whose failures and counts the caller's copy cannot change.
767
+ let recorded = current;
768
+ let given = false;
651
769
  if (result.kind === 'retry') {
652
770
  if (!validNumber(result.retryAt) || result.retryAt <= this.now()
653
771
  || !['network', 'rate_limited', 'provider_unavailable', 'storage_unavailable'].includes(result.reason)) {
654
772
  throw new SourceSyncFault('invalid_response');
655
773
  }
656
- const disposition = { kind: 'retry', reason: result.reason, retryAt: result.retryAt };
657
- await this.putWork(tx, s, { ...work, disposition });
658
- return { handle: h, disposition };
774
+ // The failures a read records are counted across its counts of attempts, from the stored row, so the count
775
+ // survives the restart of the attempts after a wait and a row an earlier release wrote starts at none.
776
+ const failures = (current.failures ?? 0) + 1;
777
+ if (!validNumber(failures))
778
+ throw new SourceSyncFault('invalid_response');
779
+ // A failure on this side, a preparation or the store, is weighed against the documents the cycle prepared; one
780
+ // at the provider against the reads it answered. Each count is taken where the run of the read's first failure
781
+ // of that kind began, so what that run did before the failure counts as well as what came after it.
782
+ const here = result.reason === 'storage_unavailable';
783
+ const answeredAtFailure = current.answeredAtFailure ?? (here ? undefined : tally.answeredAtClaim);
784
+ const preparedAtFailure = current.preparedAtFailure ?? (here ? tally.preparedAtClaim : undefined);
785
+ const own = here ? tally.prepared > (preparedAtFailure ?? tally.prepared)
786
+ : tally.answered > (answeredAtFailure ?? tally.answered);
787
+ recorded = { ...current, failures,
788
+ ...(answeredAtFailure !== undefined ? { answeredAtFailure } : {}),
789
+ ...(preparedAtFailure !== undefined ? { preparedAtFailure } : {}) };
790
+ given = failures >= readFailureLimit && (own || this.now() - tally.openedAt >= row.policy.reconcileMs);
791
+ if (!given) {
792
+ const disposition = { kind: 'retry', reason: result.reason,
793
+ retryAt: result.retryAt };
794
+ await this.putWork(tx, s, { ...recorded, disposition });
795
+ return { handle: h, disposition };
796
+ }
659
797
  }
660
798
  const all = await this.documentRows(tx, s);
661
799
  const old = all.find((d) => d.dependency.sourceId === work.sourceId);
662
800
  let normalized = null;
663
- let disposition = result.kind === 'gone' ? { kind: 'gone' }
664
- : result.kind === 'skipped' ? { kind: 'skipped', reason: result.reason }
665
- : { kind: 'pending' };
801
+ let disposition = given ? { kind: 'skipped', reason: 'attempt_limit' }
802
+ : result.kind === 'gone' ? { kind: 'gone' }
803
+ : result.kind === 'skipped' ? { kind: 'skipped', reason: result.reason }
804
+ : { kind: 'pending' };
666
805
  let victims = [];
667
806
  // The old copy stays when the read is that copy and the host refreshes it in place (`refreshed`), or when an
668
807
  // indexing skip keeps it (`kept`, with `shortened` its provenance); a written read is charged the characters it
@@ -746,6 +885,11 @@ export class SqlAccountSourceSyncStore {
746
885
  disposition = { kind: 'applied', version: read.dependency.version };
747
886
  }
748
887
  }
888
+ else if (given) {
889
+ // The read saw nothing of the source, so nothing is decided about the copy the store holds: it stays, within
890
+ // its own expiry and window, until a later read replaces it or it expires.
891
+ kept = old !== undefined && retainable(old, row.policy, this.now());
892
+ }
749
893
  let ticket = h.ticket;
750
894
  const localFence = this.fenceOn(tx);
751
895
  const purged = refreshed || kept ? victims : [...victims, work.sourceId];
@@ -778,7 +922,14 @@ export class SqlAccountSourceSyncStore {
778
922
  if (normalized && normalized.dependency.expiresAt <= this.now())
779
923
  throw new StaleSync();
780
924
  await this.checked(tx, h);
781
- await this.putWork(tx, s, { ...work, disposition });
925
+ await this.putWork(tx, s, { ...recorded, disposition });
926
+ // The item settles here, once: `currentWork` takes only a pending one. The cycle's counts change with it, in the
927
+ // same transaction; a document counts as prepared when the host wrote it from a preparation.
928
+ row.checkpoint = { ...cp, tally: { ...tally, settled: tally.settled + 1,
929
+ skipped: tally.skipped + (disposition.kind === 'skipped' ? 1 : 0),
930
+ answered: tally.answered + (answers(disposition) ? 1 : 0),
931
+ prepared: tally.prepared + (normalized !== null && !refreshed && prepared !== null ? 1 : 0) } };
932
+ await this.save(tx, s, row);
782
933
  return { handle: { ...h, ticket }, disposition };
783
934
  });
784
935
  try {
@@ -828,33 +979,80 @@ export class SqlAccountSourceSyncStore {
828
979
  return true;
829
980
  });
830
981
  }
982
+ /**
983
+ * The cycle's work rows counted in the database by disposition and skip reason, once. A row of another cycle or
984
+ * generation, which the drop of that cycle should have removed, makes the count stale, as reading it did.
985
+ */
986
+ async workCounts(tx, scope, cp) {
987
+ const rows = await tx.all(`SELECT
988
+ (work->>'cycleId'=$4 AND (work->>'generation')::bigint=$5::bigint) AS current,
989
+ work->'disposition'->>'kind' AS kind,work->'disposition'->>'reason' AS reason,COUNT(*)::int AS n
990
+ FROM ${this.t.work} WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3 GROUP BY 1,2,3`, [...params(scope), cp.cycleId, cp.generation]);
991
+ return rows.map(({ current, kind, reason, n }) => {
992
+ const count = Number(n);
993
+ if (current !== true || !isDispositionKind(kind) || !validNumber(count))
994
+ throw new StaleSync();
995
+ return { kind, reason, count };
996
+ });
997
+ }
831
998
  async summary(tx, scope, cp) {
832
- const rows = await tx.all(`SELECT work FROM ${this.t.work}
833
- WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3`, params(scope));
834
999
  const out = { cycleId: cp.cycleId, generation: cp.generation, checkpointRevision: cp.revision,
835
1000
  total: 0, pending: 0, retry: 0, applied: 0, gone: 0, skipped: 0 };
836
- for (const { work } of rows) {
837
- if (work.cycleId !== cp.cycleId || work.generation !== cp.generation)
838
- throw new StaleSync();
839
- out.total++;
840
- out[work.disposition.kind]++;
1001
+ for (const { kind, count } of await this.workCounts(tx, scope, cp)) {
1002
+ out.total += count;
1003
+ out[kind] += count;
841
1004
  }
842
1005
  return out;
843
1006
  }
844
1007
  /**
845
- * Records the cycle's progress, counted from its stored work and the retained documents.
1008
+ * Counts a cycle's tally from its work rows, for a checkpoint that holds none, as an earlier release wrote it, or
1009
+ * one whose counts the store could not have kept. `answered` and `prepared` come from the rows as they stand, every
1010
+ * applied document counting as prepared, and the counts at the claim are the counts themselves, so only what the
1011
+ * cycle does from here weighs a failure; the opening time is the one the checkpoint kept, or else its cutoff.
1012
+ */
1013
+ async countTally(tx, scope, cp) {
1014
+ let settled = 0;
1015
+ let skipped = 0;
1016
+ let answered = 0;
1017
+ let prepared = 0;
1018
+ for (const { kind, reason, count } of await this.workCounts(tx, scope, cp)) {
1019
+ if (kind === 'pending' || kind === 'retry')
1020
+ continue;
1021
+ settled += count;
1022
+ if (kind === 'skipped')
1023
+ skipped += count;
1024
+ if (kind !== 'skipped' || (reason !== 'excluded' && reason !== 'attempt_limit'))
1025
+ answered += count;
1026
+ if (kind === 'applied')
1027
+ prepared += count;
1028
+ }
1029
+ const kept = cp.tally?.openedAt;
1030
+ const openedAt = typeof kept === 'number' && validNumber(kept) ? kept : cp.cutoff;
1031
+ return { openedAt, settled, skipped, answered, prepared, answeredAtClaim: answered, preparedAtClaim: prepared };
1032
+ }
1033
+ /** The open cycle's tally: the one its checkpoint keeps, or one counted from its work rows when it keeps none. */
1034
+ async tallyOf(tx, scope, cp) {
1035
+ return validTally(cp.tally) ? cp.tally : this.countTally(tx, scope, cp);
1036
+ }
1037
+ /**
1038
+ * Records the cycle's progress: its examined and skipped reads from the counts its checkpoint keeps, which the
1039
+ * transactions that change the work keep, so it reads no work row, and the retained documents counted in the
1040
+ * database. A checkpoint that keeps no counts, as an earlier release wrote it, is counted from its work rows once,
1041
+ * and the counts are kept with it from then on.
846
1042
  *
847
1043
  * @returns The state, or null for a stale handle.
848
1044
  */
849
1045
  async progress(h) {
850
1046
  return this.withHandle(h, async (tx, row) => {
851
- if (!row.checkpoint)
1047
+ const cp = row.checkpoint;
1048
+ if (!cp)
852
1049
  return row.state;
853
- const summary = await this.summary(tx, h.lease.scope, row.checkpoint);
854
- const documents = await this.documentRows(tx, h.lease.scope);
1050
+ const tally = await this.tallyOf(tx, h.lease.scope, cp);
1051
+ if (tally !== cp.tally)
1052
+ row.checkpoint = { ...cp, tally };
855
1053
  row.state = transitionSourceSync(row.state, { type: 'sync_progress', progress: {
856
- examined: summary.total - summary.pending - summary.retry, committed: documents.length,
857
- skipped: summary.skipped, bounded: row.checkpoint.bounded,
1054
+ examined: tally.settled, committed: await this.documentCount(tx, h.lease.scope),
1055
+ skipped: tally.skipped, bounded: cp.bounded,
858
1056
  } }, this.now());
859
1057
  await this.save(tx, h.lease.scope, row);
860
1058
  return row.state;
@@ -877,7 +1075,7 @@ export class SqlAccountSourceSyncStore {
877
1075
  return null;
878
1076
  const now = this.now();
879
1077
  row.state = transitionSourceSync(row.state, { type: 'sync_progress', progress: {
880
- examined: summary.total, committed: (await this.documentRows(tx, h.lease.scope)).length,
1078
+ examined: summary.total, committed: await this.documentCount(tx, h.lease.scope),
881
1079
  skipped: summary.skipped, bounded: cp.bounded,
882
1080
  } }, now);
883
1081
  row.state = transitionSourceSync(row.state, { type: 'sync_succeeded', cursor, nextSyncAt: now + row.policy.pollMs }, now);