@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.
- 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 +265 -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,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
|
|
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
|
+
// 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
|
|
590
|
-
*
|
|
591
|
-
*
|
|
592
|
-
*
|
|
593
|
-
*
|
|
594
|
-
* after
|
|
595
|
-
*
|
|
596
|
-
*
|
|
597
|
-
*
|
|
598
|
-
*
|
|
599
|
-
*
|
|
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
|
-
|
|
641
|
-
|
|
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
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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 =
|
|
664
|
-
: result.kind === '
|
|
665
|
-
: { kind: '
|
|
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, { ...
|
|
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 {
|
|
837
|
-
|
|
838
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
1051
|
+
const cp = row.checkpoint;
|
|
1052
|
+
if (!cp)
|
|
852
1053
|
return row.state;
|
|
853
|
-
const
|
|
854
|
-
|
|
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:
|
|
857
|
-
skipped:
|
|
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:
|
|
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);
|