@framers/agentos-ext-account-sources 0.9.3 → 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
@@ -30,10 +30,37 @@ export class SourceSyncExpiryError extends Error {
30
30
  class StaleSync extends Error {
31
31
  constructor() { super('stale_source_sync'); }
32
32
  }
33
+ /**
34
+ * Thrown inside an application, before anything is written, by a read the store records as an `expired` skip while it
35
+ * still holds the source's copy within its expiry: the copy is withdrawn in a commit of its own and the read applied
36
+ * again, so a purge that fails cannot leave the copy deliverable.
37
+ */
38
+ class WithdrawFirst extends Error {
39
+ constructor() { super('source_sync_withdraw_first'); }
40
+ }
33
41
  const params = (s) => [s.tenantId, s.ownerId, s.connectionId];
34
42
  const validNumber = (n) => Number.isSafeInteger(n) && n >= 0;
35
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.
36
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);
37
64
  const newest = (a, b) => b.activityAt - a.activityAt || (a.dependency.sourceId < b.dependency.sourceId ? -1
38
65
  : a.dependency.sourceId > b.dependency.sourceId ? 1 : 0);
39
66
  /** Whether a retained copy may still be held: before its expiry, with every message inside the policy's window. */
@@ -247,7 +274,8 @@ export class SqlAccountSourceSyncStore {
247
274
  /**
248
275
  * Claims the connection's lease for one sync. The owner must be active, the host's `active` binding must allow it,
249
276
  * no barrier or live lease may be set and the connection must be due for `trigger`; a manual claim records the
250
- * 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.
251
279
  *
252
280
  * @returns The handle, or null.
253
281
  */
@@ -268,6 +296,12 @@ export class SqlAccountSourceSyncStore {
268
296
  row.state = transitionSourceSync(row.state, { type: 'sync_started' }, now);
269
297
  row.lease_token = randomUUID();
270
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
+ }
271
305
  await this.save(tx, scope, row);
272
306
  return { ticket, lease: { scope, generation: row.state.generation,
273
307
  token: row.lease_token, expiresAt: Number(row.lease_until) } };
@@ -318,7 +352,8 @@ export class SqlAccountSourceSyncStore {
318
352
  /**
319
353
  * Opens a cycle's durable checkpoint and drops the previous cycle's work, or answers the checkpoint already open.
320
354
  * An incremental cycle starts from the committed cursor. With `replaceExpiredHistory`, a `recovery` cycle replaces
321
- * 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`).
322
357
  *
323
358
  * @returns The checkpoint, or null for a stale handle or an incremental cycle from another cursor.
324
359
  * @throws {SourceSyncFault} `invalid_response` for a cutoff in the future or a replacement that is not `recovery`.
@@ -334,8 +369,10 @@ export class SqlAccountSourceSyncStore {
334
369
  if (input.cutoff !== undefined && (!validNumber(input.cutoff) || input.cutoff > this.now()))
335
370
  throw new SourceSyncFault("invalid_response");
336
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));
337
- row.checkpoint = openSourceSyncCycle({ ...input, cycleId: input.cycleId ?? randomUUID(),
338
- 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 };
339
376
  row.last_page = null;
340
377
  await this.save(tx, h.lease.scope, row);
341
378
  return row.checkpoint;
@@ -343,7 +380,11 @@ export class SqlAccountSourceSyncStore {
343
380
  }
344
381
  /**
345
382
  * Records one page: the next checkpoint and its queued work, in one transaction. The same page sent again right
346
- * 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.
347
388
  *
348
389
  * @throws {SourceSyncFault} `invalid_response` for an id longer than 512 characters, and the page rules'
349
390
  * `invalid_source_sync_page` for a page the checkpoint does not admit.
@@ -351,6 +392,7 @@ export class SqlAccountSourceSyncStore {
351
392
  async page(h, expected, page) {
352
393
  return this.withHandle(h, async (tx, row) => {
353
394
  const cp = row.checkpoint;
395
+ const s = h.lease.scope;
354
396
  if (!cp || cp.cycleId !== expected.cycleId || cp.generation !== expected.generation)
355
397
  throw new StaleSync();
356
398
  const digest = createHash('sha256').update(JSON.stringify({
@@ -362,16 +404,42 @@ export class SqlAccountSourceSyncStore {
362
404
  if (cp.revision !== expected.revision)
363
405
  throw new StaleSync();
364
406
  const next = advanceSourceSyncPage(cp, page, row.policy);
365
- for (const id of next.enqueueIds) {
407
+ const ids = [...next.enqueueIds];
408
+ for (const id of ids)
366
409
  if (!validId(id))
367
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;
368
430
  const work = { cycleId: cp.cycleId, generation: cp.generation, sourceId: id,
369
- discoveredRevision: next.checkpoint.revision, attempts: 0, disposition: { kind: 'pending' } };
370
- 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);
371
436
  }
372
- 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 };
373
441
  row.last_page = digest;
374
- await this.save(tx, h.lease.scope, row);
442
+ await this.save(tx, s, row);
375
443
  return row.checkpoint;
376
444
  });
377
445
  }
@@ -383,27 +451,56 @@ export class SqlAccountSourceSyncStore {
383
451
  /**
384
452
  * Null id takes the first due item. Increment before external work; a crash leaves it pending.
385
453
  *
386
- * 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
387
- * taken. A pending item that already has five attempts and no recorded result, as when a process died during each
388
- * 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
389
461
  * attempts starts a new count.
390
462
  *
391
463
  * @returns The item with its attempt counted, or null when none is due or the handle is stale.
392
464
  */
393
465
  async takeWork(h, id = null) {
394
466
  return this.withHandle(h, async (tx, row) => {
395
- const rows = await tx.all(`SELECT work FROM ${this.t.work}
396
- WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3
397
- AND ($4::text IS NULL OR source_id=$4) ORDER BY source_id`, [...params(h.lease.scope), id]);
398
467
  const now = this.now();
399
- for (const { work } of rows) {
400
- if (work.cycleId !== row.checkpoint?.cycleId || work.generation !== h.lease.generation)
401
- continue;
402
- if (work.disposition.kind !== 'pending' && work.disposition.kind !== 'retry')
403
- continue;
404
- if (work.disposition.kind === 'retry' && work.disposition.retryAt > now)
405
- 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;
406
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.
407
504
  await this.putWork(tx, h.lease.scope, { ...work, disposition: {
408
505
  kind: 'retry', reason: 'storage_unavailable', retryAt: now + row.policy.pollMs,
409
506
  } });
@@ -416,7 +513,6 @@ export class SqlAccountSourceSyncStore {
416
513
  await this.putWork(tx, h.lease.scope, next);
417
514
  return next;
418
515
  }
419
- return null;
420
516
  });
421
517
  }
422
518
  /**
@@ -515,7 +611,7 @@ export class SqlAccountSourceSyncStore {
515
611
  || !validId(work.sourceId) || typeof text !== 'string' || text.length === 0 || text.length > 1_048_576
516
612
  || typeof doc.contentHash !== 'string' || !doc.contentHash || doc.contentHash.length > 512
517
613
  || doc.cache.kind !== 'cache' || !doc.cache.evidenceId || !validNumber(doc.cache.expiresAt)
518
- || doc.cache.expiresAt <= now || !validNumber(doc.activityAt) || doc.activityAt > now
614
+ || !validNumber(doc.activityAt) || doc.activityAt > now
519
615
  || !doc.messages.length || doc.messages.length > 1000)
520
616
  fail();
521
617
  let end = 0;
@@ -552,6 +648,15 @@ export class SqlAccountSourceSyncStore {
552
648
  WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3`, params(scope));
553
649
  return rows.map((r) => r.document);
554
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
+ }
555
660
  /** Internal provenance enumeration. Never expose this as a browser retrieval response. */
556
661
  async documents(scope) {
557
662
  return this.documentRows(this.db, scope);
@@ -578,15 +683,26 @@ export class SqlAccountSourceSyncStore {
578
683
  }
579
684
  /**
580
685
  * Applies one read result for a work item taken in this cycle, under a live lease and the owner fence. A retry
581
- * keeps the work unresolved. A document is weighed first: excluded, outside the retained limits or over the
582
- * indexing allowance with the characters it adds to index, it is recorded as a skip with its reason. A document
583
- * that fits replaces the source's old copy in the same transaction (host write, indexing charge and provenance),
584
- * after the old copy and every result derived from it are purged, evicting this connection's older sources the
585
- * limits no longer admit. A read that repeats the text and message spans of a copy the store still holds, within
586
- * its expiry and window, adds nothing to index and is never charged. Any other read of such a copy adds its new and
587
- * changed messages and is weighed and charged by them alone: a message the copy holds with the same id, text hash,
588
- * cut and length is free, as is the text before it (its separator and any header lines) when that text is the
589
- * 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
590
706
  * text, a message edited in place adds itself with the separators on both sides of it, a changed header line or
591
707
  * separator adds the text it is in, a message moved after another neighbour adds its separator, and a read that
592
708
  * only drops messages adds nothing, since the messages it keeps follow the same neighbours among those kept (a
@@ -607,17 +723,20 @@ export class SqlAccountSourceSyncStore {
607
723
  * out of the store until a read of it fits the allowance whole.
608
724
  *
609
725
  * A `gone` result and a skip the provider answers withdraw the old copy before that, in a transaction of their own
610
- * (`withdraw`): its expiry moves to the store's clock, so from then on `readCurrent` answers it expired and the fence
611
- * neither commits nor delivers a result derived from it, even when the purge that follows fails, as when the host's
612
- * `purge` binding throws. A failing binding rolls the rest of the application back; the withdrawn copy is past its
613
- * expiry, so the next `expire` or `sweepExpired` removes it, as does a later application of the read.
726
+ * (`withdraw`), and so does an `expired` skip, before its application runs again: the copy's expiry moves to the
727
+ * store's clock, so from then on `readCurrent` answers it expired and the fence neither commits nor delivers a result
728
+ * derived from it, even when the purge that follows fails, as when the host's `purge` binding throws. A failing
729
+ * binding rolls the rest of the application back; the withdrawn copy is past its expiry, so the next `expire` or
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.
614
732
  *
615
733
  * @param prepared - The host's prepared value for a document result; null for any other result, and for a document
616
734
  * weighed before its preparation.
617
735
  * @returns The disposition and the handle later calls use, whose ticket reflects any purge; `pending` with the same
618
736
  * handle, and nothing recorded, for a document without its prepared value that the store has to write; null for a
619
737
  * stale handle or work item.
620
- * @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.
621
740
  * @throws {Error} `invalid_source_allowance` when the host's allowance is not made of nonnegative safe integers, and
622
741
  * `invalid_source_sync_charge` should the characters a read adds come out below zero or fractional, which the
623
742
  * ordered spans of a valid read and an intact copy rule out; nothing is charged or written.
@@ -625,32 +744,64 @@ export class SqlAccountSourceSyncStore {
625
744
  async applyResult(h, work, result, prepared) {
626
745
  // The purge below runs in the transaction that records the result, so a binding that throws rolls the copy's
627
746
  // invalidation back with it. The withdrawal commits first and survives that rollback. Only this lease writes the
628
- // connection's copies, so a copy not held at this read cannot appear before the purge.
629
- if ((result.kind === 'gone' || result.kind === 'skipped') && await this.holds(h.lease.scope, work.sourceId)
630
- && 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)
631
753
  return null;
632
- return this.withHandle(h, async (tx, row) => {
754
+ // `withdrawn` once the copy of a read the weighing below finds expired has been withdrawn (`WithdrawFirst`).
755
+ const apply = (withdrawn) => this.withHandle(h, async (tx, row) => {
633
756
  const s = h.lease.scope;
634
- await this.currentWork(tx, s, row.checkpoint, work);
757
+ const current = await this.currentWork(tx, s, row.checkpoint, work);
635
758
  if (result.kind === "skipped" && !skipReasons.includes(result.reason))
636
759
  throw new SourceSyncFault("invalid_response");
637
760
  if (result.kind !== 'document' && result.id !== work.sourceId)
638
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;
639
769
  if (result.kind === 'retry') {
640
770
  if (!validNumber(result.retryAt) || result.retryAt <= this.now()
641
771
  || !['network', 'rate_limited', 'provider_unavailable', 'storage_unavailable'].includes(result.reason)) {
642
772
  throw new SourceSyncFault('invalid_response');
643
773
  }
644
- const disposition = { kind: 'retry', reason: result.reason, retryAt: result.retryAt };
645
- await this.putWork(tx, s, { ...work, disposition });
646
- 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
+ }
647
797
  }
648
798
  const all = await this.documentRows(tx, s);
649
799
  const old = all.find((d) => d.dependency.sourceId === work.sourceId);
650
800
  let normalized = null;
651
- let disposition = result.kind === 'gone' ? { kind: 'gone' }
652
- : result.kind === 'skipped' ? { kind: 'skipped', reason: result.reason }
653
- : { 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' };
654
805
  let victims = [];
655
806
  // The old copy stays when the read is that copy and the host refreshes it in place (`refreshed`), or when an
656
807
  // indexing skip keeps it (`kept`, with `shortened` its provenance); a written read is charged the characters it
@@ -687,7 +838,18 @@ export class SqlAccountSourceSyncStore {
687
838
  // a negative or fractional one would mean a malformed copy, and is refused rather than charged.
688
839
  if (!validNumber(indexed))
689
840
  throw new Error('invalid_source_sync_charge');
690
- if (!selected.includes(read)) {
841
+ // A read whose cache lifetime has ended by the time it is weighed is an ordinary expired result, never a
842
+ // malformed one: a document admitted with seconds of its lifetime left and prepared for longer comes back past
843
+ // it, and refusing it as `invalid_response` would end the run with `sync_failed`, which clears the schedule.
844
+ // The skip is terminal, so the cycle commits, and the source is read again when it is listed again. It removes
845
+ // the copy the store holds, which a skip the provider answers withdraws before the purge: the copy still within
846
+ // its expiry is withdrawn the same way, in a commit of its own, and the read applied again.
847
+ if (read.dependency.expiresAt <= now) {
848
+ if (!withdrawn && old !== undefined && old.dependency.expiresAt > now)
849
+ throw new WithdrawFirst();
850
+ disposition = { kind: 'skipped', reason: 'expired' };
851
+ }
852
+ else if (!selected.includes(read)) {
691
853
  const reason = selected.length >= Math.min(row.policy.maxRetainedDocuments, maximumDocuments)
692
854
  ? 'document_limit' : 'held_limit';
693
855
  disposition = { kind: 'skipped', reason };
@@ -723,6 +885,11 @@ export class SqlAccountSourceSyncStore {
723
885
  disposition = { kind: 'applied', version: read.dependency.version };
724
886
  }
725
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
+ }
726
893
  let ticket = h.ticket;
727
894
  const localFence = this.fenceOn(tx);
728
895
  const purged = refreshed || kept ? victims : [...victims, work.sourceId];
@@ -755,9 +922,26 @@ export class SqlAccountSourceSyncStore {
755
922
  if (normalized && normalized.dependency.expiresAt <= this.now())
756
923
  throw new StaleSync();
757
924
  await this.checked(tx, h);
758
- 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);
759
933
  return { handle: { ...h, ticket }, disposition };
760
934
  });
935
+ try {
936
+ return await apply(false);
937
+ }
938
+ catch (error) {
939
+ if (!(error instanceof WithdrawFirst))
940
+ throw error;
941
+ if (await this.withdraw(h, work, { kind: 'skipped', id: work.sourceId, reason: 'expired' }) === null)
942
+ return null;
943
+ return apply(true);
944
+ }
761
945
  }
762
946
  /** Whether the connection holds a copy of the source within its expiry, read without a lock. */
763
947
  async holds(scope, sourceId) {
@@ -795,33 +979,80 @@ export class SqlAccountSourceSyncStore {
795
979
  return true;
796
980
  });
797
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
+ }
798
998
  async summary(tx, scope, cp) {
799
- const rows = await tx.all(`SELECT work FROM ${this.t.work}
800
- WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3`, params(scope));
801
999
  const out = { cycleId: cp.cycleId, generation: cp.generation, checkpointRevision: cp.revision,
802
1000
  total: 0, pending: 0, retry: 0, applied: 0, gone: 0, skipped: 0 };
803
- for (const { work } of rows) {
804
- if (work.cycleId !== cp.cycleId || work.generation !== cp.generation)
805
- throw new StaleSync();
806
- out.total++;
807
- out[work.disposition.kind]++;
1001
+ for (const { kind, count } of await this.workCounts(tx, scope, cp)) {
1002
+ out.total += count;
1003
+ out[kind] += count;
808
1004
  }
809
1005
  return out;
810
1006
  }
811
1007
  /**
812
- * 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.
813
1042
  *
814
1043
  * @returns The state, or null for a stale handle.
815
1044
  */
816
1045
  async progress(h) {
817
1046
  return this.withHandle(h, async (tx, row) => {
818
- if (!row.checkpoint)
1047
+ const cp = row.checkpoint;
1048
+ if (!cp)
819
1049
  return row.state;
820
- const summary = await this.summary(tx, h.lease.scope, row.checkpoint);
821
- 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 };
822
1053
  row.state = transitionSourceSync(row.state, { type: 'sync_progress', progress: {
823
- examined: summary.total - summary.pending - summary.retry, committed: documents.length,
824
- 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,
825
1056
  } }, this.now());
826
1057
  await this.save(tx, h.lease.scope, row);
827
1058
  return row.state;
@@ -844,7 +1075,7 @@ export class SqlAccountSourceSyncStore {
844
1075
  return null;
845
1076
  const now = this.now();
846
1077
  row.state = transitionSourceSync(row.state, { type: 'sync_progress', progress: {
847
- examined: summary.total, committed: (await this.documentRows(tx, h.lease.scope)).length,
1078
+ examined: summary.total, committed: await this.documentCount(tx, h.lease.scope),
848
1079
  skipped: summary.skipped, bounded: cp.bounded,
849
1080
  } }, now);
850
1081
  row.state = transitionSourceSync(row.state, { type: 'sync_succeeded', cursor, nextSyncAt: now + row.policy.pollMs }, now);