@framers/agentos-ext-account-sources 0.10.0 → 0.11.1
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 +8 -6
- package/dist/sync/SqlAccountSourceSyncStore.d.ts +104 -22
- package/dist/sync/SqlAccountSourceSyncStore.d.ts.map +1 -1
- package/dist/sync/SqlAccountSourceSyncStore.js +223 -41
- package/dist/sync/SqlAccountSourceSyncStore.js.map +1 -1
- package/dist/sync/schema.d.ts +16 -2
- package/dist/sync/schema.d.ts.map +1 -1
- package/dist/sync/schema.js +22 -2
- package/dist/sync/schema.js.map +1 -1
- package/dist/sync/types.d.ts +8 -1
- package/dist/sync/types.d.ts.map +1 -1
- package/manifest.json +2 -2
- package/package.json +5 -4
|
@@ -142,6 +142,21 @@ const addedByMessage = (stored, read) => {
|
|
|
142
142
|
*/
|
|
143
143
|
const sameCopy = (stored, read) => sameText(stored, read) && stored.dependency.version === read.dependency.version
|
|
144
144
|
&& stored.contentHash === read.contentHash;
|
|
145
|
+
/**
|
|
146
|
+
* The host's allowance, checked: its three amounts nonnegative safe integers, and its period's end one too when given.
|
|
147
|
+
*
|
|
148
|
+
* @throws {Error} `invalid_source_allowance` for any other value.
|
|
149
|
+
*/
|
|
150
|
+
const checkedAllowance = (allowance) => {
|
|
151
|
+
if (![allowance.maximumConnectionDocuments, allowance.maximumConnectionHeldCharacters,
|
|
152
|
+
allowance.additionalIndexedCharacters].every(validNumber)
|
|
153
|
+
|| (allowance.indexingPeriodEndsAt !== undefined && !validNumber(allowance.indexingPeriodEndsAt))) {
|
|
154
|
+
throw new Error('invalid_source_allowance');
|
|
155
|
+
}
|
|
156
|
+
return allowance;
|
|
157
|
+
};
|
|
158
|
+
/** The end of the indexing period the allowance names, or null when it names none or the period has ended by `now`. */
|
|
159
|
+
const periodOf = (allowance, now) => allowance.indexingPeriodEndsAt !== undefined && allowance.indexingPeriodEndsAt > now ? allowance.indexingPeriodEndsAt : null;
|
|
145
160
|
/**
|
|
146
161
|
* Durable synchronization state for account sources on PostgreSQL. Every change to an owner's content runs under
|
|
147
162
|
* that owner's fence (`fence`), and a change made with a handle checks its lease, generation and host liveness in
|
|
@@ -150,8 +165,11 @@ const sameCopy = (stored, read) => sameText(stored, read) && stored.dependency.v
|
|
|
150
165
|
* before purging it, so a purge that fails leaves nothing derived from it deliverable. A read that repeats the text of
|
|
151
166
|
* a copy the store holds is never charged again, one that changes it is charged, by the hash the store keeps of each
|
|
152
167
|
* message's text, its new and changed messages and the text beside them, and with the host's `refresh` binding a read
|
|
153
|
-
* of that very copy keeps it in place.
|
|
154
|
-
*
|
|
168
|
+
* of that very copy keeps it in place. A host whose allowance names the end of its indexing period has each source's
|
|
169
|
+
* indexed text weighed for the rest of the period too, past its copy's expiry, and a source the allowance could not
|
|
170
|
+
* take settled with no read until it can, for a reconcile interval at a time. Provider reads, embedding and model calls
|
|
171
|
+
* stay outside the store. The constructor runs no DDL: the host's migration runs `sourceSyncSchema` and core's
|
|
172
|
+
* `sourceDerivationSchema` first.
|
|
155
173
|
*
|
|
156
174
|
* @typeParam Prepared - The host's in-memory value for one document, passed through to its `write` binding.
|
|
157
175
|
*/
|
|
@@ -186,6 +204,88 @@ export class SqlAccountSourceSyncStore {
|
|
|
186
204
|
throw new Error('invalid_source_sync_clock');
|
|
187
205
|
return n;
|
|
188
206
|
}
|
|
207
|
+
/** Whether the charges table was found in place; a table found stays found, one missing is looked for again. */
|
|
208
|
+
chargesTable = false;
|
|
209
|
+
/**
|
|
210
|
+
* Whether the charges table of `sourceSyncSchema` is in place, which a database migrated by an earlier release's
|
|
211
|
+
* statements lacks: without it the store records no charge and no deferral.
|
|
212
|
+
*/
|
|
213
|
+
async chargesHeld(tx) {
|
|
214
|
+
if (!this.chargesTable) {
|
|
215
|
+
const found = await tx.get(`SELECT to_regclass($1::text) IS NOT NULL AS present`, [this.t.charges]);
|
|
216
|
+
this.chargesTable = found?.present === true;
|
|
217
|
+
}
|
|
218
|
+
return this.chargesTable;
|
|
219
|
+
}
|
|
220
|
+
/** The source's charge record in the period ending at `period`, or null when it has none there. */
|
|
221
|
+
async chargeRecord(tx, scope, sourceId, period) {
|
|
222
|
+
return tx.get(`SELECT period_ends_at,charged,deferred FROM ${this.t.charges}
|
|
223
|
+
WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3 AND source_id=$4 AND period_ends_at=$5`, [...params(scope), sourceId, period]);
|
|
224
|
+
}
|
|
225
|
+
/** Writes the source's charge record for the period ending at `period`, in place of any earlier one. */
|
|
226
|
+
async recordCharge(tx, scope, sourceId, period, charged, deferred) {
|
|
227
|
+
await tx.run(`INSERT INTO ${this.t.charges}(tenant_id,owner_id,connection_id,source_id,period_ends_at,charged,deferred)
|
|
228
|
+
VALUES($1,$2,$3,$4,$5,$6::jsonb,$7::jsonb) ON CONFLICT(tenant_id,owner_id,connection_id,source_id)
|
|
229
|
+
DO UPDATE SET period_ends_at=excluded.period_ends_at,charged=excluded.charged,deferred=excluded.deferred`, [...params(scope), sourceId, period, charged === null ? null : JSON.stringify(charged),
|
|
230
|
+
deferred === null ? null : JSON.stringify(deferred)]);
|
|
231
|
+
}
|
|
232
|
+
/** Removes the source's charge record, when the table is in place. */
|
|
233
|
+
async forgetCharge(tx, scope, sourceId) {
|
|
234
|
+
if (!await this.chargesHeld(tx))
|
|
235
|
+
return;
|
|
236
|
+
await tx.run(`DELETE FROM ${this.t.charges} WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3
|
|
237
|
+
AND source_id=$4`, [...params(scope), sourceId]);
|
|
238
|
+
}
|
|
239
|
+
/** Whether the source has a charge record, in any period, when the table is in place. */
|
|
240
|
+
async recorded(scope, sourceId) {
|
|
241
|
+
if (!await this.chargesHeld(this.db))
|
|
242
|
+
return false;
|
|
243
|
+
return !!await this.db.get(`SELECT 1 FROM ${this.t.charges} WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3
|
|
244
|
+
AND source_id=$4`, [...params(scope), sourceId]);
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Removes up to `limit` charge records whose period has ended by the store's clock, the earliest first: every
|
|
248
|
+
* owner's, or `owner`'s alone, or only those of `connectionId` of that owner. Answers how many went.
|
|
249
|
+
*/
|
|
250
|
+
async forgetEnded(owner, connectionId, limit) {
|
|
251
|
+
if (!await this.chargesHeld(this.db))
|
|
252
|
+
return 0;
|
|
253
|
+
const ended = `period_ends_at<=$1 AND ($2::text IS NULL OR (tenant_id=$2 AND owner_id=$3))
|
|
254
|
+
AND ($4::text IS NULL OR connection_id=$4)`;
|
|
255
|
+
// The period's end is checked on the row itself as well, so a record a read moved to a later period since the
|
|
256
|
+
// look stays.
|
|
257
|
+
const removed = await this.db.run(`DELETE FROM ${this.t.charges} WHERE ${ended}
|
|
258
|
+
AND (tenant_id,owner_id,connection_id,source_id) IN (SELECT tenant_id,owner_id,connection_id,source_id
|
|
259
|
+
FROM ${this.t.charges} WHERE ${ended} ORDER BY period_ends_at,tenant_id,owner_id,connection_id,source_id LIMIT $5)`, [this.now(), owner?.tenantId ?? null, owner?.ownerId ?? null, connectionId, limit]);
|
|
260
|
+
return removed.changes;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* The listed sources `page` settles as `indexing_limit` skips with no read: each holds a deferral of the host's
|
|
264
|
+
* current indexing period at the connection's generation, recorded less than the policy's `reconcileMs` ago, the store
|
|
265
|
+
* holds no copy of it within its expiry, and the room the allowance leaves is no larger than the room it was skipped
|
|
266
|
+
* with. The allowance is read only when a listed source holds such a deferral. Past the reconcile interval the source
|
|
267
|
+
* is read again, so a change at the source, a deletion or an edit that makes it fit, reaches the store, as a
|
|
268
|
+
* reconcile reads every listed source again.
|
|
269
|
+
*/
|
|
270
|
+
async deferredOf(tx, scope, policy, generation, ids) {
|
|
271
|
+
if (ids.length === 0 || !await this.chargesHeld(tx))
|
|
272
|
+
return new Set();
|
|
273
|
+
const now = this.now();
|
|
274
|
+
const marks = await tx.all(`SELECT c.source_id,c.period_ends_at,c.deferred FROM ${this.t.charges} c
|
|
275
|
+
WHERE c.tenant_id=$1 AND c.owner_id=$2 AND c.connection_id=$3 AND c.source_id=ANY($4::text[])
|
|
276
|
+
AND c.deferred IS NOT NULL AND c.period_ends_at>$5
|
|
277
|
+
AND NOT EXISTS(SELECT 1 FROM ${this.t.documents} d WHERE d.tenant_id=c.tenant_id AND d.owner_id=c.owner_id
|
|
278
|
+
AND d.connection_id=c.connection_id AND d.source_id=c.source_id AND d.expires_at>$5)`, [...params(scope), [...ids], now]);
|
|
279
|
+
const current = marks.filter((mark) => mark.deferred.generation === generation && validNumber(mark.deferred.limit)
|
|
280
|
+
&& validNumber(mark.deferred.at) && now < mark.deferred.at + policy.reconcileMs);
|
|
281
|
+
if (current.length === 0)
|
|
282
|
+
return new Set();
|
|
283
|
+
const allowance = checkedAllowance(await this.options.bindings.allowance(tx, scope));
|
|
284
|
+
const period = periodOf(allowance, now);
|
|
285
|
+
const room = Math.min(policy.maxIndexedCharacters, allowance.additionalIndexedCharacters);
|
|
286
|
+
return new Set(current.filter((mark) => period !== null && Number(mark.period_ends_at) === period
|
|
287
|
+
&& room <= mark.deferred.limit).map((mark) => mark.source_id));
|
|
288
|
+
}
|
|
189
289
|
fenceOn(db) {
|
|
190
290
|
return new SqlSourceDerivationStore(db, {
|
|
191
291
|
tablePrefix: this.options.derivationPrefix, clock: this.clock,
|
|
@@ -386,8 +486,17 @@ export class SqlAccountSourceSyncStore {
|
|
|
386
486
|
* counts they are weighed against, so a read given up before is given up again at its next failure; a read the
|
|
387
487
|
* provider answered starts a count of its own.
|
|
388
488
|
*
|
|
489
|
+
* A source an `indexing_limit` skip deferred in the host's current indexing period is queued settled, as the same
|
|
490
|
+
* skip, with no read, no purge and no deletion record, while the connection keeps the generation of the skip, less
|
|
491
|
+
* than the policy's `reconcileMs` has passed since it, the store holds no copy of the source and the allowance leaves
|
|
492
|
+
* no more room than the skip had: the host's `allowance` binding is read for that, only when a listed source holds
|
|
493
|
+
* such a deferral. The skip counts among the cycle's settled and skipped items, and not as a provider's answer. Once
|
|
494
|
+
* the period turns, the room grows, the generation moves or the reconcile interval has passed, the source is queued
|
|
495
|
+
* pending and read again, so a change at the source reaches the store within about one reconcile interval.
|
|
496
|
+
*
|
|
389
497
|
* @throws {SourceSyncFault} `invalid_response` for an id longer than 512 characters, and the page rules'
|
|
390
498
|
* `invalid_source_sync_page` for a page the checkpoint does not admit.
|
|
499
|
+
* @throws {Error} `invalid_source_allowance` when the allowance read for a deferral is not valid.
|
|
391
500
|
*/
|
|
392
501
|
async page(h, expected, page) {
|
|
393
502
|
return this.withHandle(h, async (tx, row) => {
|
|
@@ -420,19 +529,25 @@ export class SqlAccountSourceSyncStore {
|
|
|
420
529
|
earlier.set(work.sourceId, work);
|
|
421
530
|
}
|
|
422
531
|
}
|
|
532
|
+
// The sources the allowance deferred, settled here as their skip, so the run reads none of them.
|
|
533
|
+
const deferred = await this.deferredOf(tx, s, row.policy, cp.generation, ids);
|
|
423
534
|
for (const id of ids) {
|
|
424
535
|
const before = earlier.get(id);
|
|
425
536
|
if (tally && before && isTerminalSourceWork(before.disposition)) {
|
|
426
537
|
tally = { ...tally, settled: tally.settled - 1,
|
|
427
538
|
skipped: tally.skipped - (before.disposition.kind === 'skipped' ? 1 : 0) };
|
|
428
539
|
}
|
|
540
|
+
const settled = deferred.has(id);
|
|
429
541
|
const failing = before !== undefined && !answers(before.disposition) ? before : undefined;
|
|
430
542
|
const work = { cycleId: cp.cycleId, generation: cp.generation, sourceId: id,
|
|
431
|
-
discoveredRevision: next.checkpoint.revision, attempts: 0,
|
|
543
|
+
discoveredRevision: next.checkpoint.revision, attempts: 0,
|
|
544
|
+
disposition: settled ? { kind: 'skipped', reason: 'indexing_limit' } : { kind: 'pending' },
|
|
432
545
|
...(failing?.failures !== undefined ? { failures: failing.failures } : {}),
|
|
433
546
|
...(failing?.answeredAtFailure !== undefined ? { answeredAtFailure: failing.answeredAtFailure } : {}),
|
|
434
547
|
...(failing?.preparedAtFailure !== undefined ? { preparedAtFailure: failing.preparedAtFailure } : {}) };
|
|
435
548
|
await this.putWork(tx, s, work);
|
|
549
|
+
if (tally && settled)
|
|
550
|
+
tally = { ...tally, settled: tally.settled + 1, skipped: tally.skipped + 1 };
|
|
436
551
|
}
|
|
437
552
|
// Counts that would fall below zero were not the store's: count them again from the rows just written.
|
|
438
553
|
if (!validTally(tally))
|
|
@@ -726,6 +841,16 @@ export class SqlAccountSourceSyncStore {
|
|
|
726
841
|
* does not begin with the copy's text, so with the allowance spent it removes the thread's copy, and the thread stays
|
|
727
842
|
* out of the store until a read of it fits the allowance whole.
|
|
728
843
|
*
|
|
844
|
+
* An allowance that names the end of the host's indexing period (`indexingPeriodEndsAt`), with the charges table in
|
|
845
|
+
* place, has the read weighed against the newest text the store wrote or refreshed for the source in that period as
|
|
846
|
+
* well, by the same rules, and charged the smaller amount: a source read again after its copy expired and was
|
|
847
|
+
* removed is charged nothing when its text is unchanged and only what it adds otherwise, as a reply's separator and
|
|
848
|
+
* text. Each written or refreshed read becomes the period's text for the source. An `indexing_limit` skip that keeps
|
|
849
|
+
* no copy records a deferral with the room the allowance left it and the time, which `page` honours for a reconcile
|
|
850
|
+
* interval, and the record then keeps no provenance: nothing in it confirms the text of a source the store holds no
|
|
851
|
+
* copy of. A `gone` result and a skip the provider answers remove the source's record, before the application and
|
|
852
|
+
* with the withdrawal below, so a host purge that throws cannot keep it; the store's own skips keep it.
|
|
853
|
+
*
|
|
729
854
|
* A `gone` result and a skip the provider answers withdraw the old copy before that, in a transaction of their own
|
|
730
855
|
* (`withdraw`), and so does an `expired` skip, before its application runs again: the copy's expiry moves to the
|
|
731
856
|
* store's clock, so from then on `readCurrent` answers it expired and the fence neither commits nor delivers a result
|
|
@@ -741,9 +866,10 @@ export class SqlAccountSourceSyncStore {
|
|
|
741
866
|
* stale handle or work item.
|
|
742
867
|
* @throws {SourceSyncFault} `invalid_response` for a result that does not match its work item or is malformed, a skip
|
|
743
868
|
* that carries `attempt_limit` among them.
|
|
744
|
-
* @throws {Error} `invalid_source_allowance` when the host's allowance is not made of nonnegative safe integers,
|
|
745
|
-
* `invalid_source_sync_charge` should the characters a read adds come out
|
|
746
|
-
* ordered spans of a valid read and an intact copy rule out; nothing is charged
|
|
869
|
+
* @throws {Error} `invalid_source_allowance` when the host's allowance is not made of nonnegative safe integers, its
|
|
870
|
+
* period's end included when given, and `invalid_source_sync_charge` should the characters a read adds come out
|
|
871
|
+
* below zero or fractional, which the ordered spans of a valid read and an intact copy rule out; nothing is charged
|
|
872
|
+
* or written.
|
|
747
873
|
*/
|
|
748
874
|
async applyResult(h, work, result, prepared) {
|
|
749
875
|
// The purge below runs in the transaction that records the result, so a binding that throws rolls the copy's
|
|
@@ -751,9 +877,11 @@ export class SqlAccountSourceSyncStore {
|
|
|
751
877
|
// connection's copies, so a copy not held at this read cannot appear before the purge. A result that carries
|
|
752
878
|
// `attempt_limit`, the store's own reason, withdraws nothing and is refused below.
|
|
753
879
|
// 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.
|
|
880
|
+
// nothing, so the comparison is made on the reason as the store's wider type. The source's charge record goes in
|
|
881
|
+
// the withdrawal's transaction too, which runs for it even with no copy held, so the purge's rollback keeps none.
|
|
755
882
|
if ((result.kind === 'gone' || (result.kind === 'skipped' && result.reason !== 'attempt_limit'))
|
|
756
|
-
&& await this.holds(h.lease.scope, work.sourceId)
|
|
883
|
+
&& (await this.holds(h.lease.scope, work.sourceId) || await this.recorded(h.lease.scope, work.sourceId))
|
|
884
|
+
&& await this.withdraw(h, work, result, true) === null)
|
|
757
885
|
return null;
|
|
758
886
|
// `withdrawn` once the copy of a read the weighing below finds expired has been withdrawn (`WithdrawFirst`).
|
|
759
887
|
const apply = (withdrawn) => this.withHandle(h, async (tx, row) => {
|
|
@@ -814,34 +942,44 @@ export class SqlAccountSourceSyncStore {
|
|
|
814
942
|
let kept = false;
|
|
815
943
|
let charge = 0;
|
|
816
944
|
let shortened = null;
|
|
945
|
+
// With the host's indexing period named and the charges table in place: the period's end (`period`), the
|
|
946
|
+
// source's record in it (`record`), and the deferral an indexing skip that keeps no copy records (`deferral`).
|
|
947
|
+
let period = null;
|
|
948
|
+
let record = null;
|
|
949
|
+
let deferral = null;
|
|
817
950
|
const excluded = await tx.get(`SELECT 1 FROM ${this.t.exclusions}
|
|
818
951
|
WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3 AND source_id=$4`, [...params(s), work.sourceId]);
|
|
819
952
|
if (result.kind === 'document' && excluded)
|
|
820
953
|
disposition = { kind: 'skipped', reason: 'excluded' };
|
|
821
954
|
else if (result.kind === 'document') {
|
|
822
955
|
const read = this.normalized(h, work, result.document, row.policy);
|
|
823
|
-
const allowance = await this.options.bindings.allowance(tx, s);
|
|
824
|
-
if (!Object.values(allowance).every(validNumber))
|
|
825
|
-
throw new Error('invalid_source_allowance');
|
|
956
|
+
const allowance = checkedAllowance(await this.options.bindings.allowance(tx, s));
|
|
826
957
|
const maximumDocuments = allowance.maximumConnectionDocuments;
|
|
827
958
|
const maximumCharacters = allowance.maximumConnectionHeldCharacters;
|
|
828
959
|
const now = this.now();
|
|
960
|
+
period = periodOf(allowance, now);
|
|
961
|
+
if (period !== null && !await this.chargesHeld(tx))
|
|
962
|
+
period = null;
|
|
963
|
+
record = period === null ? null : await this.chargeRecord(tx, s, work.sourceId, period);
|
|
829
964
|
const selected = this.choose([...all.filter((d) => d !== old), read], row.policy, maximumDocuments, maximumCharacters, now);
|
|
830
|
-
// A copy
|
|
831
|
-
//
|
|
832
|
-
// adds
|
|
833
|
-
//
|
|
834
|
-
//
|
|
965
|
+
// A copy within its expiry and window was charged when it was admitted, and the newest text the store
|
|
966
|
+
// wrote or refreshed for the source in the host's current period was charged by then: a read that repeats
|
|
967
|
+
// either text adds nothing to index, and one that also repeats the copy's version and content hash is that
|
|
968
|
+
// copy. Any other read adds the messages the copy or the period's text does not hold unchanged, with the text
|
|
969
|
+
// beside them, or, against a copy whose messages keep no text hash, what follows the copy's text when the read
|
|
970
|
+
// begins with it; the smaller amount is the one charged. A copy past its expiry or window is not held,
|
|
971
|
+
// whether or not `expire` has removed it yet, while the period's text is weighed until the period ends.
|
|
835
972
|
const holding = old !== undefined && retainable(old, row.policy, now) ? old : undefined;
|
|
836
|
-
const repeats = holding !== undefined && sameText(holding, read);
|
|
837
973
|
const text = result.document.read.content.text;
|
|
838
|
-
const
|
|
839
|
-
:
|
|
840
|
-
?? (beginsWith(
|
|
974
|
+
const weigh = (base) => !base ? read.characters
|
|
975
|
+
: sameText(base, read) ? 0
|
|
976
|
+
: addedByMessage(base, read) ?? (beginsWith(base, read, text) ? read.characters - base.characters : read.characters);
|
|
977
|
+
const indexed = Math.min(weigh(holding), weigh(record?.charged));
|
|
841
978
|
// Each amount is the read's characters less parts of its text that do not overlap, so it is never negative;
|
|
842
979
|
// a negative or fractional one would mean a malformed copy, and is refused rather than charged.
|
|
843
980
|
if (!validNumber(indexed))
|
|
844
981
|
throw new Error('invalid_source_sync_charge');
|
|
982
|
+
const room = Math.min(row.policy.maxIndexedCharacters, allowance.additionalIndexedCharacters);
|
|
845
983
|
// A read whose cache lifetime has ended by the time it is weighed is an ordinary expired result, never a
|
|
846
984
|
// malformed one: a document admitted with seconds of its lifetime left and prepared for longer comes back past
|
|
847
985
|
// it, and refusing it as `invalid_response` would end the run with `sync_failed`, which clears the schedule.
|
|
@@ -858,7 +996,7 @@ export class SqlAccountSourceSyncStore {
|
|
|
858
996
|
? 'document_limit' : 'held_limit';
|
|
859
997
|
disposition = { kind: 'skipped', reason };
|
|
860
998
|
}
|
|
861
|
-
else if (indexed >
|
|
999
|
+
else if (indexed > room) {
|
|
862
1000
|
disposition = { kind: 'skipped', reason: 'indexing_limit' };
|
|
863
1001
|
// The copy stays where the read begins with its text and the retained limits still admit it as it is, so an
|
|
864
1002
|
// edit of a message it holds, a reply that cuts its oldest message at the cap, or a lowered ceiling,
|
|
@@ -874,6 +1012,12 @@ export class SqlAccountSourceSyncStore {
|
|
|
874
1012
|
const expiresAt = Math.min(holding.dependency.expiresAt, read.dependency.expiresAt, ...messages.map((m) => m.windowExpiresAt));
|
|
875
1013
|
shortened = { ...holding, messages, dependency: { ...holding.dependency, expiresAt } };
|
|
876
1014
|
}
|
|
1015
|
+
else if (period !== null) {
|
|
1016
|
+
// With no copy left, nothing a later read of the source decides needs it soon: `page` settles the source
|
|
1017
|
+
// as this skip with no read until the period turns, the room grows, the generation moves or a reconcile
|
|
1018
|
+
// interval has passed. A kept copy is read again at each listing, so a change at the source reaches it.
|
|
1019
|
+
deferral = { generation: h.lease.generation, limit: room, at: now };
|
|
1020
|
+
}
|
|
877
1021
|
}
|
|
878
1022
|
else {
|
|
879
1023
|
refreshed = holding !== undefined && sameCopy(holding, read)
|
|
@@ -922,6 +1066,18 @@ export class SqlAccountSourceSyncStore {
|
|
|
922
1066
|
VALUES($1,$2,$3,$4,$5::jsonb,$6)`, [...params(s), work.sourceId,
|
|
923
1067
|
JSON.stringify(normalized), normalized.dependency.expiresAt]);
|
|
924
1068
|
}
|
|
1069
|
+
// The period's record takes the text this application wrote or refreshed, which the index holds for the source
|
|
1070
|
+
// in the period, or the deferral beside the text the period already charged for the source, if it charged one: a
|
|
1071
|
+
// read once the room grows is weighed against that text, so the period never charges it twice. A source the period
|
|
1072
|
+
// never indexed keeps the deferral alone, with no message ids or hashes. A source the provider answers gone or
|
|
1073
|
+
// skipped keeps no record, which the withdrawal before this transaction removed: the provider has said the store
|
|
1074
|
+
// may not hold it. The store's own skips keep it, since the source may fit again in the period; an exclusion
|
|
1075
|
+
// removed it in `exclude`.
|
|
1076
|
+
if (period !== null && normalized !== null)
|
|
1077
|
+
await this.recordCharge(tx, s, work.sourceId, period, normalized, null);
|
|
1078
|
+
else if (period !== null && deferral !== null) {
|
|
1079
|
+
await this.recordCharge(tx, s, work.sourceId, period, record?.charged ?? null, deferral);
|
|
1080
|
+
}
|
|
925
1081
|
// A slow binding cannot extend permission or finish after its lease expired.
|
|
926
1082
|
if (normalized && normalized.dependency.expiresAt <= this.now())
|
|
927
1083
|
throw new StaleSync();
|
|
@@ -942,7 +1098,7 @@ export class SqlAccountSourceSyncStore {
|
|
|
942
1098
|
catch (error) {
|
|
943
1099
|
if (!(error instanceof WithdrawFirst))
|
|
944
1100
|
throw error;
|
|
945
|
-
if (await this.withdraw(h, work, { kind: 'skipped', id: work.sourceId, reason: 'expired' }) === null)
|
|
1101
|
+
if (await this.withdraw(h, work, { kind: 'skipped', id: work.sourceId, reason: 'expired' }, false) === null)
|
|
946
1102
|
return null;
|
|
947
1103
|
return apply(true);
|
|
948
1104
|
}
|
|
@@ -957,13 +1113,15 @@ export class SqlAccountSourceSyncStore {
|
|
|
957
1113
|
* before the one that removes it: the copy's expiry, and its dependency's, move to the store's clock. `readCurrent`
|
|
958
1114
|
* then answers it expired, so the fence neither commits nor delivers a result derived from it, and `expire` and
|
|
959
1115
|
* `sweepExpired` remove it should the removal fail. The handle, the work item and the result are checked as
|
|
960
|
-
* `applyResult` checks them, and a copy already past its expiry is left as it is.
|
|
1116
|
+
* `applyResult` checks them, and a copy already past its expiry is left as it is. With `forget`, for a provider's
|
|
1117
|
+
* answer, the source's charge record goes in the same transaction, so its message ids and hashes are gone whatever
|
|
1118
|
+
* the removal after it does; the store's own `expired` skip keeps it.
|
|
961
1119
|
*
|
|
962
1120
|
* @returns True once the copy is withdrawn or none is held within its expiry; null for a stale handle or work item.
|
|
963
1121
|
* @throws {SourceSyncFault} `invalid_response` for a result that does not match its work item or a skip reason the
|
|
964
1122
|
* store does not know, with nothing withdrawn.
|
|
965
1123
|
*/
|
|
966
|
-
async withdraw(h, work, result) {
|
|
1124
|
+
async withdraw(h, work, result, forget) {
|
|
967
1125
|
return this.withHandle(h, async (tx, row) => {
|
|
968
1126
|
const s = h.lease.scope;
|
|
969
1127
|
await this.currentWork(tx, s, row.checkpoint, work);
|
|
@@ -980,6 +1138,8 @@ export class SqlAccountSourceSyncStore {
|
|
|
980
1138
|
await tx.run(`UPDATE ${this.t.documents} SET document=$5::jsonb,expires_at=$6
|
|
981
1139
|
WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3 AND source_id=$4`, [...params(s), work.sourceId, JSON.stringify(withdrawn), now]);
|
|
982
1140
|
}
|
|
1141
|
+
if (forget)
|
|
1142
|
+
await this.forgetCharge(tx, s, work.sourceId);
|
|
983
1143
|
return true;
|
|
984
1144
|
});
|
|
985
1145
|
}
|
|
@@ -1250,13 +1410,22 @@ export class SqlAccountSourceSyncStore {
|
|
|
1250
1410
|
}
|
|
1251
1411
|
return states;
|
|
1252
1412
|
}
|
|
1253
|
-
/**
|
|
1413
|
+
/**
|
|
1414
|
+
* Removes the host's content and this store's provenance in the affected scope; the fence calls it inside an
|
|
1415
|
+
* invalidation. A connection's or an owner's purge removes their charge records too. One source's purge, which its
|
|
1416
|
+
* expiry and every replacement of its copy run, leaves the source's record: the record outlives the copy until its
|
|
1417
|
+
* period ends, and `exclude` and a provider's `gone` or skip remove it themselves.
|
|
1418
|
+
*/
|
|
1254
1419
|
async purgeSources(tx, scope, affected) {
|
|
1255
1420
|
await this.options.bindings.purge(tx, scope, affected);
|
|
1256
1421
|
const args = [scope.tenantId, scope.ownerId, affected.kind === 'owner' ? null : affected.connectionId,
|
|
1257
1422
|
affected.kind === 'source' ? affected.sourceId : null];
|
|
1258
1423
|
await tx.run(`DELETE FROM ${this.t.documents} WHERE tenant_id=$1 AND owner_id=$2
|
|
1259
1424
|
AND ($3::text IS NULL OR connection_id=$3) AND ($4::text IS NULL OR source_id=$4)`, args);
|
|
1425
|
+
if ((affected.kind === 'connection' || affected.kind === 'owner') && await this.chargesHeld(tx)) {
|
|
1426
|
+
await tx.run(`DELETE FROM ${this.t.charges} WHERE tenant_id=$1 AND owner_id=$2
|
|
1427
|
+
AND ($3::text IS NULL OR connection_id=$3)`, args.slice(0, 3));
|
|
1428
|
+
}
|
|
1260
1429
|
}
|
|
1261
1430
|
/**
|
|
1262
1431
|
* Applies a host control: completes any earlier barrier, then blocks the owner, moves the connection, records a
|
|
@@ -1406,9 +1575,7 @@ export class SqlAccountSourceSyncStore {
|
|
|
1406
1575
|
await remove(barrier.sourceId);
|
|
1407
1576
|
else if (barrier.action === 'prune') {
|
|
1408
1577
|
const docs = await this.documentRows(tx, scope);
|
|
1409
|
-
const allowance = await this.options.bindings.allowance(tx, scope);
|
|
1410
|
-
if (!Object.values(allowance).every(validNumber))
|
|
1411
|
-
throw new Error('invalid_source_allowance');
|
|
1578
|
+
const allowance = checkedAllowance(await this.options.bindings.allowance(tx, scope));
|
|
1412
1579
|
const kept = this.choose(docs, row.policy, allowance.maximumConnectionDocuments, allowance.maximumConnectionHeldCharacters, this.now());
|
|
1413
1580
|
for (const doc of docs)
|
|
1414
1581
|
if (!kept.includes(doc))
|
|
@@ -1442,8 +1609,9 @@ export class SqlAccountSourceSyncStore {
|
|
|
1442
1609
|
}
|
|
1443
1610
|
/**
|
|
1444
1611
|
* Removes one source and every result derived from it, and keeps a content-free exclusion so later syncs skip it.
|
|
1445
|
-
* The
|
|
1446
|
-
* cycle is
|
|
1612
|
+
* The source's charge record goes in the same transaction as the exclusion. The connection moves to a new generation
|
|
1613
|
+
* and its open cycle is dropped. Its committed cursor is kept, and the next cycle is a reconcile: a fresh bounded
|
|
1614
|
+
* scan, then history replayed from that cursor.
|
|
1447
1615
|
*
|
|
1448
1616
|
* @throws {SourceSyncFault} `invalid_response` for an empty id or one longer than 512 characters.
|
|
1449
1617
|
*/
|
|
@@ -1460,6 +1628,7 @@ export class SqlAccountSourceSyncStore {
|
|
|
1460
1628
|
this.exclusionsChanged(row);
|
|
1461
1629
|
await this.clearCycle(tx, scope, row);
|
|
1462
1630
|
await tx.run(`INSERT INTO ${this.t.exclusions} VALUES($1,$2,$3,$4) ON CONFLICT DO NOTHING`, [...params(scope), sourceId]);
|
|
1631
|
+
await this.forgetCharge(tx, scope, sourceId);
|
|
1463
1632
|
row.barrier = { token: randomUUID(), action: 'exclude', sourceId };
|
|
1464
1633
|
await this.rebind(tx, scope, row.state.generation);
|
|
1465
1634
|
await this.save(tx, scope, row);
|
|
@@ -1492,13 +1661,15 @@ export class SqlAccountSourceSyncStore {
|
|
|
1492
1661
|
* Removes retained sources whose expiry has passed, with every result derived from them, also while the connection
|
|
1493
1662
|
* is paused or the owner's fence is inactive, then expires the owner's derived results. An inactive owner, such as a
|
|
1494
1663
|
* suspended account or one a control's barrier holds, stays inactive; reads refuse an expired source regardless.
|
|
1495
|
-
* Each source goes in a transaction of its own, so a later failure leaves the removals before it in place.
|
|
1664
|
+
* Each source goes in a transaction of its own, so a later failure leaves the removals before it in place. Last, it
|
|
1665
|
+
* removes up to `limit` of the connection's charge records whose indexing period has ended by the store's clock.
|
|
1496
1666
|
*
|
|
1497
|
-
* @param limit - At most this many sources, an integer from 1 to 1,000.
|
|
1667
|
+
* @param limit - At most this many sources, and this many ended charge records, an integer from 1 to 1,000.
|
|
1498
1668
|
* @returns How many sources were removed.
|
|
1499
|
-
* @throws {Error} `invalid_sync_batch` for another limit, and the failure itself when a removal
|
|
1500
|
-
*
|
|
1501
|
-
* `cause` and the removals made before it in `partial`, counted in `removed`
|
|
1669
|
+
* @throws {Error} `invalid_sync_batch` for another limit, and the failure itself when a removal, the results' expiry
|
|
1670
|
+
* or the removal of ended charge records fails: once a source was removed, a `SourceSyncExpiryError` with the
|
|
1671
|
+
* failure's message, the failure as its `cause` and the removals made before it in `partial`, counted in `removed`
|
|
1672
|
+
* and named in `sourceIds`.
|
|
1502
1673
|
*/
|
|
1503
1674
|
async expire(scope, limit = 100) {
|
|
1504
1675
|
if (!Number.isInteger(limit) || limit < 1 || limit > 1000)
|
|
@@ -1512,6 +1683,7 @@ export class SqlAccountSourceSyncStore {
|
|
|
1512
1683
|
if (await this.expireSource(scope, row.source_id))
|
|
1513
1684
|
removed.push(row.source_id);
|
|
1514
1685
|
await this.fence.expireResults(scope, limit);
|
|
1686
|
+
await this.forgetEnded(scope, scope.connectionId, limit);
|
|
1515
1687
|
}
|
|
1516
1688
|
catch (error) {
|
|
1517
1689
|
// The removals before the failure have committed, so their count and their sources go to the caller with it.
|
|
@@ -1547,11 +1719,14 @@ export class SqlAccountSourceSyncStore {
|
|
|
1547
1719
|
* indexing skip that keeps a copy may have shortened. An owner whose removal fails, as when its host purge keeps
|
|
1548
1720
|
* failing, is counted and left out of the call's later reads, so a call with up to `limit` such owners never holds
|
|
1549
1721
|
* back another owner's copies. An owner whose derived results then fail to expire is counted too, once; the call
|
|
1550
|
-
* still answers every source it removed, and the other owners' results still expire.
|
|
1722
|
+
* still answers every source it removed, and the other owners' results still expire. Last, it removes up to `limit`
|
|
1723
|
+
* charge records whose indexing period has ended by the store's clock, every owner's or `owner`'s, the earliest end
|
|
1724
|
+
* first; a failure there counts once, after every copy the call could remove has gone.
|
|
1551
1725
|
*
|
|
1552
|
-
* @param limit - At most this many sources, an integer from 1 to 1,000.
|
|
1726
|
+
* @param limit - At most this many sources, and this many ended charge records, an integer from 1 to 1,000.
|
|
1553
1727
|
* @param owner - The owner whose sources alone are swept, or null for every owner.
|
|
1554
|
-
* @returns The sources removed, and the owners whose removal or whose derived results' expiry failed
|
|
1728
|
+
* @returns The sources removed, and the owners whose removal or whose derived results' expiry failed, with one more
|
|
1729
|
+
* when the removal of ended charge records failed.
|
|
1555
1730
|
* @throws {Error} `invalid_sync_batch` for another limit.
|
|
1556
1731
|
*/
|
|
1557
1732
|
async sweepExpired(limit = 100, owner = null) {
|
|
@@ -1605,12 +1780,19 @@ export class SqlAccountSourceSyncStore {
|
|
|
1605
1780
|
failed++;
|
|
1606
1781
|
}
|
|
1607
1782
|
}
|
|
1783
|
+
// A record of an ended period is weighed against nothing, so it goes once the copies have.
|
|
1784
|
+
try {
|
|
1785
|
+
await this.forgetEnded(owner, null, limit);
|
|
1786
|
+
}
|
|
1787
|
+
catch {
|
|
1788
|
+
failed++;
|
|
1789
|
+
}
|
|
1608
1790
|
return { removed, failed };
|
|
1609
1791
|
}
|
|
1610
1792
|
/**
|
|
1611
1793
|
* Removes a stopped owner's rows from the store in one transaction: every connection of the owner, with its work
|
|
1612
|
-
* rows, retained-source provenance and
|
|
1613
|
-
* or retained source. `remove` runs first in the same transaction, after the owner fence's row lock and the
|
|
1794
|
+
* rows, retained-source provenance, exclusions and charge records, once each connection is disconnected with no
|
|
1795
|
+
* barrier, lease, work or retained source. `remove` runs first in the same transaction, after the owner fence's row lock and the
|
|
1614
1796
|
* connections' row locks, for a lifecycle's own rows; `SourceRevocations.removeOwner` passes its own. The core
|
|
1615
1797
|
* fence's owner row and its deletion records stay: they hold no content, keep the owner's epoch ahead of any old
|
|
1616
1798
|
* handle, and a restore replays the deletion from them.
|
|
@@ -1636,7 +1818,7 @@ export class SqlAccountSourceSyncStore {
|
|
|
1636
1818
|
}
|
|
1637
1819
|
if (!await remove(tx))
|
|
1638
1820
|
throw new StaleSync();
|
|
1639
|
-
// The work rows, provenance and
|
|
1821
|
+
// The work rows, provenance, exclusions and charge records go with their connection (ON DELETE CASCADE).
|
|
1640
1822
|
await tx.run(`DELETE FROM ${this.t.connections} WHERE tenant_id=$1 AND owner_id=$2`, where);
|
|
1641
1823
|
});
|
|
1642
1824
|
return true;
|