@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.
- package/README.md +6 -6
- package/dist/sync/AccountSourceSync.d.ts +8 -2
- package/dist/sync/AccountSourceSync.d.ts.map +1 -1
- package/dist/sync/AccountSourceSync.js +36 -9
- package/dist/sync/AccountSourceSync.js.map +1 -1
- package/dist/sync/SqlAccountSourceSyncStore.d.ts +60 -20
- package/dist/sync/SqlAccountSourceSyncStore.d.ts.map +1 -1
- package/dist/sync/SqlAccountSourceSyncStore.js +261 -63
- package/dist/sync/SqlAccountSourceSyncStore.js.map +1 -1
- package/dist/sync/schema.d.ts +7 -2
- package/dist/sync/schema.d.ts.map +1 -1
- package/dist/sync/schema.js +10 -2
- package/dist/sync/schema.js.map +1 -1
- package/dist/sync/types.d.ts +56 -4
- package/dist/sync/types.d.ts.map +1 -1
- package/manifest.json +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
346
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
395
|
-
*
|
|
396
|
-
*
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
|
590
|
-
*
|
|
591
|
-
*
|
|
592
|
-
*
|
|
593
|
-
*
|
|
594
|
-
* after
|
|
595
|
-
*
|
|
596
|
-
*
|
|
597
|
-
*
|
|
598
|
-
*
|
|
599
|
-
*
|
|
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
|
-
|
|
641
|
-
|
|
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
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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 =
|
|
664
|
-
: result.kind === '
|
|
665
|
-
: { kind: '
|
|
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, { ...
|
|
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 {
|
|
837
|
-
|
|
838
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
1047
|
+
const cp = row.checkpoint;
|
|
1048
|
+
if (!cp)
|
|
852
1049
|
return row.state;
|
|
853
|
-
const
|
|
854
|
-
|
|
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:
|
|
857
|
-
skipped:
|
|
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:
|
|
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);
|