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

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,60 @@ 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
+ // Each look is bounded by its index's columns alone and ordered by its keys, and the row it finds is checked
484
+ // for the cycle and the generation after the read. A condition no index holds, such as the cycle's, makes
485
+ // the planner expect to read many rows before one passes; with the statistics a page leaves (every row
486
+ // pending, read by autoanalyze before the reads settle them) a read of the whole connection and a sort then
487
+ // looked as cheap as the index, and each take read every row (the drain case under ANALYZE: 1,680 rows in
488
+ // forty takes). Bounded this way, the cheapest plan reads one row whatever the statistics say. The kind is
489
+ // a range, not an equality, so the order still names it and the primary key cannot answer it without a sort.
490
+ found = await tx.get(`SELECT work FROM ${this.t.work}
491
+ WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3
492
+ AND work->'disposition'->>'kind' BETWEEN 'pending' AND 'pending'
493
+ ORDER BY work->'disposition'->>'kind',source_id LIMIT 1`, params(h.lease.scope));
494
+ found ??= await tx.get(`SELECT work FROM ${this.t.work}
495
+ WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3 AND work->'disposition'->>'kind'='retry'
496
+ AND (work->'disposition'->>'retryAt')::bigint<=$4::bigint
497
+ ORDER BY (work->'disposition'->>'retryAt')::bigint,source_id LIMIT 1`, [...params(h.lease.scope), now]);
498
+ // A cycle opens on an empty work table and its rows carry its cycle and generation, so a due row of another
499
+ // is a stale state, which the cycle's counts refuse at its finish too.
500
+ if (found && (found.work.cycleId !== cycle[0] || found.work.generation !== cycle[1]))
501
+ throw new StaleSync();
502
+ }
503
+ if (!found)
504
+ return null;
505
+ const { work } = found;
414
506
  if (work.disposition.kind === 'pending' && work.attempts >= 5) {
507
+ // The wait is after `now`, so the next look does not find this row again.
415
508
  await this.putWork(tx, h.lease.scope, { ...work, disposition: {
416
509
  kind: 'retry', reason: 'storage_unavailable', retryAt: now + row.policy.pollMs,
417
510
  } });
@@ -424,7 +517,6 @@ export class SqlAccountSourceSyncStore {
424
517
  await this.putWork(tx, h.lease.scope, next);
425
518
  return next;
426
519
  }
427
- return null;
428
520
  });
429
521
  }
430
522
  /**
@@ -560,6 +652,15 @@ export class SqlAccountSourceSyncStore {
560
652
  WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3`, params(scope));
561
653
  return rows.map((r) => r.document);
562
654
  }
655
+ /** How many sources the connection retains, counted in the database: the progress after each read never reads their provenance. */
656
+ async documentCount(tx, scope) {
657
+ const found = await tx.get(`SELECT COUNT(*)::int AS n FROM ${this.t.documents}
658
+ WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3`, params(scope));
659
+ const n = Number(found?.n ?? 0);
660
+ if (!validNumber(n))
661
+ throw new StaleSync();
662
+ return n;
663
+ }
563
664
  /** Internal provenance enumeration. Never expose this as a browser retrieval response. */
564
665
  async documents(scope) {
565
666
  return this.documentRows(this.db, scope);
@@ -586,17 +687,26 @@ export class SqlAccountSourceSyncStore {
586
687
  }
587
688
  /**
588
689
  * 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
690
+ * keeps the work unresolved and counts in the row's `failures`, across the counts of attempts `takeWork` starts
691
+ * again after a `pollMs` wait. From its tenth failure on, the store records the read as skipped with `attempt_limit`
692
+ * in place of the wait once its failures are evidently its own: a failure at the provider once the provider has
693
+ * answered another read of the cycle, and a `storage_unavailable` failure once another document of the cycle has
694
+ * been prepared and written, in the run of the read's first failure of that kind (since its lease was claimed) or
695
+ * after it. Without that, as in an outage, the read keeps its wait until its cycle has been open for `reconcileMs`,
696
+ * and its first failure from then on gives it up. That skip keeps the previous copy within its own expiry and window:
697
+ * the read saw nothing of the source, so nothing is decided about the copy, and a later read replaces it. A result
698
+ * that carries `attempt_limit` itself is refused. Each result that settles a work item changes the cycle's running
699
+ * counts with it, in the same transaction. A document is weighed first: excluded, outside the retained limits or
700
+ * over the indexing allowance with the characters it adds to index, it is recorded as a skip with its reason, and
701
+ * so is one whose cache lifetime has ended by the store's clock when it is weighed (`expired`), as a document
702
+ * admitted with seconds of it left and applied again after a longer preparation: an expired read is never refused as
703
+ * malformed. A document that fits replaces the source's old copy in the same transaction (host write, indexing
704
+ * charge and provenance), after the old copy and every result derived from it are purged, evicting this connection's
705
+ * older sources the limits no longer admit. A read that repeats the text and message spans of a copy the store still
706
+ * holds, within its expiry and window, adds nothing to index and is never charged. Any other read of such a copy adds
707
+ * its new and changed messages and is weighed and charged by them alone: a message the copy holds with the same id,
708
+ * text hash, cut and length is free, as is the text before it (its separator and any header lines) when that text is
709
+ * the copy's and the message before it is the one the copy has there, itself unchanged. A reply adds its separator and
600
710
  * text, a message edited in place adds itself with the separators on both sides of it, a changed header line or
601
711
  * separator adds the text it is in, a message moved after another neighbour adds its separator, and a read that
602
712
  * only drops messages adds nothing, since the messages it keeps follow the same neighbours among those kept (a
@@ -621,14 +731,16 @@ export class SqlAccountSourceSyncStore {
621
731
  * store's clock, so from then on `readCurrent` answers it expired and the fence neither commits nor delivers a result
622
732
  * derived from it, even when the purge that follows fails, as when the host's `purge` binding throws. A failing
623
733
  * 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.
734
+ * `sweepExpired` removes it, as does a later application of the read. A read the store gives up withdraws nothing,
735
+ * since it keeps the copy.
625
736
  *
626
737
  * @param prepared - The host's prepared value for a document result; null for any other result, and for a document
627
738
  * weighed before its preparation.
628
739
  * @returns The disposition and the handle later calls use, whose ticket reflects any purge; `pending` with the same
629
740
  * handle, and nothing recorded, for a document without its prepared value that the store has to write; null for a
630
741
  * stale handle or work item.
631
- * @throws {SourceSyncFault} `invalid_response` for a result that does not match its work item or is malformed.
742
+ * @throws {SourceSyncFault} `invalid_response` for a result that does not match its work item or is malformed, a skip
743
+ * that carries `attempt_limit` among them.
632
744
  * @throws {Error} `invalid_source_allowance` when the host's allowance is not made of nonnegative safe integers, and
633
745
  * `invalid_source_sync_charge` should the characters a read adds come out below zero or fractional, which the
634
746
  * ordered spans of a valid read and an intact copy rule out; nothing is charged or written.
@@ -636,33 +748,64 @@ export class SqlAccountSourceSyncStore {
636
748
  async applyResult(h, work, result, prepared) {
637
749
  // The purge below runs in the transaction that records the result, so a binding that throws rolls the copy's
638
750
  // 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)
751
+ // connection's copies, so a copy not held at this read cannot appear before the purge. A result that carries
752
+ // `attempt_limit`, the store's own reason, withdraws nothing and is refused below.
753
+ // The read result's type leaves `attempt_limit` out; a provider that breaks the type at run time still withdraws
754
+ // nothing, so the comparison is made on the reason as the store's wider type.
755
+ if ((result.kind === 'gone' || (result.kind === 'skipped' && result.reason !== 'attempt_limit'))
756
+ && await this.holds(h.lease.scope, work.sourceId) && await this.withdraw(h, work, result) === null)
642
757
  return null;
643
758
  // `withdrawn` once the copy of a read the weighing below finds expired has been withdrawn (`WithdrawFirst`).
644
759
  const apply = (withdrawn) => this.withHandle(h, async (tx, row) => {
645
760
  const s = h.lease.scope;
646
- await this.currentWork(tx, s, row.checkpoint, work);
761
+ const current = await this.currentWork(tx, s, row.checkpoint, work);
647
762
  if (result.kind === "skipped" && !skipReasons.includes(result.reason))
648
763
  throw new SourceSyncFault("invalid_response");
649
764
  if (result.kind !== 'document' && result.id !== work.sourceId)
650
765
  throw new SourceSyncFault('invalid_response');
766
+ const cp = row.checkpoint;
767
+ if (!cp)
768
+ throw new StaleSync();
769
+ const tally = await this.tallyOf(tx, s, cp);
770
+ // The row as recorded: the stored one, whose failures and counts the caller's copy cannot change.
771
+ let recorded = current;
772
+ let given = false;
651
773
  if (result.kind === 'retry') {
652
774
  if (!validNumber(result.retryAt) || result.retryAt <= this.now()
653
775
  || !['network', 'rate_limited', 'provider_unavailable', 'storage_unavailable'].includes(result.reason)) {
654
776
  throw new SourceSyncFault('invalid_response');
655
777
  }
656
- const disposition = { kind: 'retry', reason: result.reason, retryAt: result.retryAt };
657
- await this.putWork(tx, s, { ...work, disposition });
658
- return { handle: h, disposition };
778
+ // The failures a read records are counted across its counts of attempts, from the stored row, so the count
779
+ // survives the restart of the attempts after a wait and a row an earlier release wrote starts at none.
780
+ const failures = (current.failures ?? 0) + 1;
781
+ if (!validNumber(failures))
782
+ throw new SourceSyncFault('invalid_response');
783
+ // A failure on this side, a preparation or the store, is weighed against the documents the cycle prepared; one
784
+ // at the provider against the reads it answered. Each count is taken where the run of the read's first failure
785
+ // of that kind began, so what that run did before the failure counts as well as what came after it.
786
+ const here = result.reason === 'storage_unavailable';
787
+ const answeredAtFailure = current.answeredAtFailure ?? (here ? undefined : tally.answeredAtClaim);
788
+ const preparedAtFailure = current.preparedAtFailure ?? (here ? tally.preparedAtClaim : undefined);
789
+ const own = here ? tally.prepared > (preparedAtFailure ?? tally.prepared)
790
+ : tally.answered > (answeredAtFailure ?? tally.answered);
791
+ recorded = { ...current, failures,
792
+ ...(answeredAtFailure !== undefined ? { answeredAtFailure } : {}),
793
+ ...(preparedAtFailure !== undefined ? { preparedAtFailure } : {}) };
794
+ given = failures >= readFailureLimit && (own || this.now() - tally.openedAt >= row.policy.reconcileMs);
795
+ if (!given) {
796
+ const disposition = { kind: 'retry', reason: result.reason,
797
+ retryAt: result.retryAt };
798
+ await this.putWork(tx, s, { ...recorded, disposition });
799
+ return { handle: h, disposition };
800
+ }
659
801
  }
660
802
  const all = await this.documentRows(tx, s);
661
803
  const old = all.find((d) => d.dependency.sourceId === work.sourceId);
662
804
  let normalized = null;
663
- let disposition = result.kind === 'gone' ? { kind: 'gone' }
664
- : result.kind === 'skipped' ? { kind: 'skipped', reason: result.reason }
665
- : { kind: 'pending' };
805
+ let disposition = given ? { kind: 'skipped', reason: 'attempt_limit' }
806
+ : result.kind === 'gone' ? { kind: 'gone' }
807
+ : result.kind === 'skipped' ? { kind: 'skipped', reason: result.reason }
808
+ : { kind: 'pending' };
666
809
  let victims = [];
667
810
  // The old copy stays when the read is that copy and the host refreshes it in place (`refreshed`), or when an
668
811
  // indexing skip keeps it (`kept`, with `shortened` its provenance); a written read is charged the characters it
@@ -746,6 +889,11 @@ export class SqlAccountSourceSyncStore {
746
889
  disposition = { kind: 'applied', version: read.dependency.version };
747
890
  }
748
891
  }
892
+ else if (given) {
893
+ // The read saw nothing of the source, so nothing is decided about the copy the store holds: it stays, within
894
+ // its own expiry and window, until a later read replaces it or it expires.
895
+ kept = old !== undefined && retainable(old, row.policy, this.now());
896
+ }
749
897
  let ticket = h.ticket;
750
898
  const localFence = this.fenceOn(tx);
751
899
  const purged = refreshed || kept ? victims : [...victims, work.sourceId];
@@ -778,7 +926,14 @@ export class SqlAccountSourceSyncStore {
778
926
  if (normalized && normalized.dependency.expiresAt <= this.now())
779
927
  throw new StaleSync();
780
928
  await this.checked(tx, h);
781
- await this.putWork(tx, s, { ...work, disposition });
929
+ await this.putWork(tx, s, { ...recorded, disposition });
930
+ // The item settles here, once: `currentWork` takes only a pending one. The cycle's counts change with it, in the
931
+ // same transaction; a document counts as prepared when the host wrote it from a preparation.
932
+ row.checkpoint = { ...cp, tally: { ...tally, settled: tally.settled + 1,
933
+ skipped: tally.skipped + (disposition.kind === 'skipped' ? 1 : 0),
934
+ answered: tally.answered + (answers(disposition) ? 1 : 0),
935
+ prepared: tally.prepared + (normalized !== null && !refreshed && prepared !== null ? 1 : 0) } };
936
+ await this.save(tx, s, row);
782
937
  return { handle: { ...h, ticket }, disposition };
783
938
  });
784
939
  try {
@@ -828,33 +983,80 @@ export class SqlAccountSourceSyncStore {
828
983
  return true;
829
984
  });
830
985
  }
986
+ /**
987
+ * The cycle's work rows counted in the database by disposition and skip reason, once. A row of another cycle or
988
+ * generation, which the drop of that cycle should have removed, makes the count stale, as reading it did.
989
+ */
990
+ async workCounts(tx, scope, cp) {
991
+ const rows = await tx.all(`SELECT
992
+ (work->>'cycleId'=$4 AND (work->>'generation')::bigint=$5::bigint) AS current,
993
+ work->'disposition'->>'kind' AS kind,work->'disposition'->>'reason' AS reason,COUNT(*)::int AS n
994
+ 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]);
995
+ return rows.map(({ current, kind, reason, n }) => {
996
+ const count = Number(n);
997
+ if (current !== true || !isDispositionKind(kind) || !validNumber(count))
998
+ throw new StaleSync();
999
+ return { kind, reason, count };
1000
+ });
1001
+ }
831
1002
  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
1003
  const out = { cycleId: cp.cycleId, generation: cp.generation, checkpointRevision: cp.revision,
835
1004
  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]++;
1005
+ for (const { kind, count } of await this.workCounts(tx, scope, cp)) {
1006
+ out.total += count;
1007
+ out[kind] += count;
841
1008
  }
842
1009
  return out;
843
1010
  }
844
1011
  /**
845
- * Records the cycle's progress, counted from its stored work and the retained documents.
1012
+ * Counts a cycle's tally from its work rows, for a checkpoint that holds none, as an earlier release wrote it, or
1013
+ * one whose counts the store could not have kept. `answered` and `prepared` come from the rows as they stand, every
1014
+ * applied document counting as prepared, and the counts at the claim are the counts themselves, so only what the
1015
+ * cycle does from here weighs a failure; the opening time is the one the checkpoint kept, or else its cutoff.
1016
+ */
1017
+ async countTally(tx, scope, cp) {
1018
+ let settled = 0;
1019
+ let skipped = 0;
1020
+ let answered = 0;
1021
+ let prepared = 0;
1022
+ for (const { kind, reason, count } of await this.workCounts(tx, scope, cp)) {
1023
+ if (kind === 'pending' || kind === 'retry')
1024
+ continue;
1025
+ settled += count;
1026
+ if (kind === 'skipped')
1027
+ skipped += count;
1028
+ if (kind !== 'skipped' || (reason !== 'excluded' && reason !== 'attempt_limit'))
1029
+ answered += count;
1030
+ if (kind === 'applied')
1031
+ prepared += count;
1032
+ }
1033
+ const kept = cp.tally?.openedAt;
1034
+ const openedAt = typeof kept === 'number' && validNumber(kept) ? kept : cp.cutoff;
1035
+ return { openedAt, settled, skipped, answered, prepared, answeredAtClaim: answered, preparedAtClaim: prepared };
1036
+ }
1037
+ /** The open cycle's tally: the one its checkpoint keeps, or one counted from its work rows when it keeps none. */
1038
+ async tallyOf(tx, scope, cp) {
1039
+ return validTally(cp.tally) ? cp.tally : this.countTally(tx, scope, cp);
1040
+ }
1041
+ /**
1042
+ * Records the cycle's progress: its examined and skipped reads from the counts its checkpoint keeps, which the
1043
+ * transactions that change the work keep, so it reads no work row, and the retained documents counted in the
1044
+ * database. A checkpoint that keeps no counts, as an earlier release wrote it, is counted from its work rows once,
1045
+ * and the counts are kept with it from then on.
846
1046
  *
847
1047
  * @returns The state, or null for a stale handle.
848
1048
  */
849
1049
  async progress(h) {
850
1050
  return this.withHandle(h, async (tx, row) => {
851
- if (!row.checkpoint)
1051
+ const cp = row.checkpoint;
1052
+ if (!cp)
852
1053
  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);
1054
+ const tally = await this.tallyOf(tx, h.lease.scope, cp);
1055
+ if (tally !== cp.tally)
1056
+ row.checkpoint = { ...cp, tally };
855
1057
  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,
1058
+ examined: tally.settled, committed: await this.documentCount(tx, h.lease.scope),
1059
+ skipped: tally.skipped, bounded: cp.bounded,
858
1060
  } }, this.now());
859
1061
  await this.save(tx, h.lease.scope, row);
860
1062
  return row.state;
@@ -877,7 +1079,7 @@ export class SqlAccountSourceSyncStore {
877
1079
  return null;
878
1080
  const now = this.now();
879
1081
  row.state = transitionSourceSync(row.state, { type: 'sync_progress', progress: {
880
- examined: summary.total, committed: (await this.documentRows(tx, h.lease.scope)).length,
1082
+ examined: summary.total, committed: await this.documentCount(tx, h.lease.scope),
881
1083
  skipped: summary.skipped, bounded: cp.bounded,
882
1084
  } }, now);
883
1085
  row.state = transitionSourceSync(row.state, { type: 'sync_succeeded', cursor, nextSyncAt: now + row.policy.pollMs }, now);