@agentpulselabs/cli-plugin-pulse 1.1.3 → 1.1.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/package.json +3 -2
- package/src/hub/core/db.mjs +40 -5
- package/src/hub/core/wake.mjs +5 -3
- package/src/hub/workers/collector.mjs +59 -19
- package/src/hub/workers/manager.mjs +44 -6
- package/src/lib/contract.sha256 +1 -1
- package/src/lib/daemonRuntime.js +25 -7
- package/src/lib/hubDbReader.js +6 -0
- package/src/lib/hubScan.js +1 -0
- package/src/lib/ledger.js +21 -12
- package/src/lib/lifecycle.js +3 -1
- package/src/lib/mirrorSource.js +19 -6
- package/src/lib/runnerStore.js +61 -49
- package/src/runner/bridge.js +8 -3
- package/src/runner/busIdentity.js +11 -1
- package/src/runner/capture.js +83 -20
- package/src/runner/channelDiscipline.js +48 -14
- package/src/runner/channelFiles.js +39 -0
- package/src/runner/channelWalk.js +42 -0
- package/src/runner/collectWorker.js +92 -0
- package/src/runner/deliveryStates.js +22 -9
- package/src/runner/directoryCache.js +34 -0
- package/src/runner/facade.js +149 -87
- package/src/runner/fileReads.js +64 -0
- package/src/runner/fileReadsWorker.js +61 -0
- package/src/runner/hubCapture.js +105 -56
- package/src/runner/loopMonitor.js +89 -0
- package/src/runner/runnerHub.js +3 -1
- package/src/runner/runnerProcess.js +58 -16
- package/src/runner/runnerService.js +18 -2
- package/src/runner/serial.js +5 -0
- package/src/runner/spool.js +46 -11
- package/src/runner/transcriptFeed.js +88 -0
package/src/lib/runnerStore.js
CHANGED
|
@@ -91,6 +91,8 @@ export class RunnerStore {
|
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
#statements = new Map();
|
|
95
|
+
|
|
94
96
|
constructor({ db, file, now, maxPending }) {
|
|
95
97
|
this.db = db;
|
|
96
98
|
this.file = file;
|
|
@@ -103,20 +105,30 @@ export class RunnerStore {
|
|
|
103
105
|
this.db.close();
|
|
104
106
|
}
|
|
105
107
|
|
|
108
|
+
/** The statement for this SQL, prepared once per connection: the scanners call the store once or more per row. */
|
|
109
|
+
sql(text) {
|
|
110
|
+
let statement = this.#statements.get(text);
|
|
111
|
+
if (!statement) {
|
|
112
|
+
statement = this.db.prepare(text);
|
|
113
|
+
this.#statements.set(text, statement);
|
|
114
|
+
}
|
|
115
|
+
return statement;
|
|
116
|
+
}
|
|
117
|
+
|
|
106
118
|
/**
|
|
107
119
|
* Run `fn` in one immediate transaction: everything it enqueues and every cursor it moves commit together. Inside a
|
|
108
120
|
* transaction already open on this connection (the runner's capture) it joins that one.
|
|
109
121
|
*/
|
|
110
122
|
transaction(fn) {
|
|
111
123
|
if (this.depth > 0 || this.db.isTransaction) return fn();
|
|
112
|
-
this.
|
|
124
|
+
this.sql('BEGIN IMMEDIATE').run();
|
|
113
125
|
this.depth += 1;
|
|
114
126
|
try {
|
|
115
127
|
const out = fn();
|
|
116
|
-
this.
|
|
128
|
+
this.sql('COMMIT').run();
|
|
117
129
|
return out;
|
|
118
130
|
} catch (error) {
|
|
119
|
-
this.
|
|
131
|
+
this.sql('ROLLBACK').run();
|
|
120
132
|
throw error;
|
|
121
133
|
} finally {
|
|
122
134
|
this.depth -= 1;
|
|
@@ -124,34 +136,34 @@ export class RunnerStore {
|
|
|
124
136
|
}
|
|
125
137
|
|
|
126
138
|
meta(key) {
|
|
127
|
-
const row = this.
|
|
139
|
+
const row = this.sql('SELECT value FROM meta WHERE key = ?').get(key);
|
|
128
140
|
return row ? JSON.parse(row.value) : null;
|
|
129
141
|
}
|
|
130
142
|
|
|
131
143
|
setMeta(key, value) {
|
|
132
|
-
this.
|
|
144
|
+
this.sql('INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value')
|
|
133
145
|
.run(key, JSON.stringify(value));
|
|
134
146
|
}
|
|
135
147
|
|
|
136
148
|
cursor(source) {
|
|
137
|
-
const row = this.
|
|
149
|
+
const row = this.sql('SELECT position FROM cursor WHERE source = ?').get(source);
|
|
138
150
|
return row ? JSON.parse(row.position) : null;
|
|
139
151
|
}
|
|
140
152
|
|
|
141
153
|
setCursor(source, position) {
|
|
142
|
-
this.
|
|
154
|
+
this.sql('INSERT INTO cursor (source, position) VALUES (?, ?) ON CONFLICT (source) DO UPDATE SET position = excluded.position')
|
|
143
155
|
.run(source, JSON.stringify(position));
|
|
144
156
|
}
|
|
145
157
|
|
|
146
158
|
/** Whether a key was ever enqueued: pending, acknowledged or dead. */
|
|
147
159
|
known(key) {
|
|
148
|
-
return Boolean(this.
|
|
160
|
+
return Boolean(this.sql(
|
|
149
161
|
'SELECT 1 AS found FROM outbox WHERE key = ? UNION ALL SELECT 1 FROM sent WHERE key = ? UNION ALL SELECT 1 FROM dead WHERE key = ? LIMIT 1',
|
|
150
162
|
).get(key, key, key));
|
|
151
163
|
}
|
|
152
164
|
|
|
153
165
|
pendingCount() {
|
|
154
|
-
return Number(this.
|
|
166
|
+
return Number(this.sql('SELECT COUNT(*) AS n FROM outbox').get().n);
|
|
155
167
|
}
|
|
156
168
|
|
|
157
169
|
/**
|
|
@@ -160,7 +172,7 @@ export class RunnerStore {
|
|
|
160
172
|
*/
|
|
161
173
|
enqueue(entries) {
|
|
162
174
|
return this.transaction(() => {
|
|
163
|
-
const insert = this.
|
|
175
|
+
const insert = this.sql(
|
|
164
176
|
`INSERT INTO outbox (key, grp, type, payload, source, enqueued_at)
|
|
165
177
|
SELECT ?, ?, ?, ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM sent WHERE key = ?) AND NOT EXISTS (SELECT 1 FROM dead WHERE key = ?)
|
|
166
178
|
ON CONFLICT (key) DO NOTHING`,
|
|
@@ -214,40 +226,40 @@ export class RunnerStore {
|
|
|
214
226
|
/** The cloud stored (or already had) this key. */
|
|
215
227
|
acked(key, { seq = null, duplicate = false } = {}) {
|
|
216
228
|
this.transaction(() => {
|
|
217
|
-
const row = this.
|
|
229
|
+
const row = this.sql('SELECT source, type FROM outbox WHERE key = ?').get(key);
|
|
218
230
|
if (!row) return;
|
|
219
|
-
this.
|
|
231
|
+
this.sql('INSERT INTO sent (key, source, type, seq, duplicate, at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (key) DO NOTHING')
|
|
220
232
|
.run(key, row.source, row.type, seq === null ? null : String(seq), duplicate ? 1 : 0, this.now());
|
|
221
|
-
this.
|
|
233
|
+
this.sql('DELETE FROM outbox WHERE key = ?').run(key);
|
|
222
234
|
});
|
|
223
235
|
}
|
|
224
236
|
|
|
225
237
|
/** A retryable refusal: the row stays first in its group and is due again after `delayMs`. */
|
|
226
238
|
retry(key, { code, delayMs }) {
|
|
227
|
-
this.
|
|
239
|
+
this.sql('UPDATE outbox SET attempts = attempts + 1, next_at = ?, last_error = ? WHERE key = ?').run(this.now() + delayMs, code, key);
|
|
228
240
|
}
|
|
229
241
|
|
|
230
242
|
/** Held behind an earlier command of its group: due again after `delayMs`, without counting an attempt. */
|
|
231
243
|
hold(key, { code, delayMs }) {
|
|
232
|
-
this.
|
|
244
|
+
this.sql('UPDATE outbox SET next_at = ?, last_error = ? WHERE key = ?').run(this.now() + delayMs, code, key);
|
|
233
245
|
}
|
|
234
246
|
|
|
235
247
|
/** A refusal that repeating cannot fix: the row leaves the outbox with its reason. */
|
|
236
248
|
dead(key, { code, message = null }) {
|
|
237
249
|
this.transaction(() => {
|
|
238
|
-
const row = this.
|
|
250
|
+
const row = this.sql('SELECT grp, type, payload, source FROM outbox WHERE key = ?').get(key);
|
|
239
251
|
if (!row) return;
|
|
240
|
-
this.
|
|
252
|
+
this.sql(
|
|
241
253
|
`INSERT INTO dead (key, grp, type, payload, source, code, message, at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
242
254
|
ON CONFLICT (key) DO UPDATE SET code = excluded.code, message = excluded.message, at = excluded.at`,
|
|
243
255
|
).run(key, row.grp, row.type, row.payload, row.source, code, message, this.now());
|
|
244
|
-
this.
|
|
256
|
+
this.sql('DELETE FROM outbox WHERE key = ?').run(key);
|
|
245
257
|
});
|
|
246
258
|
}
|
|
247
259
|
|
|
248
260
|
/** A command refused before it was queued (it never reaches the cloud), recorded with its reason. */
|
|
249
261
|
refuse(entry, { code, message = null }) {
|
|
250
|
-
this.
|
|
262
|
+
this.sql(
|
|
251
263
|
`INSERT INTO dead (key, grp, type, payload, source, code, message, at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
252
264
|
ON CONFLICT (key) DO NOTHING`,
|
|
253
265
|
).run(entry.key, entry.group, entry.type, JSON.stringify(entry.payload ?? null), entry.source, code, message, this.now());
|
|
@@ -255,38 +267,38 @@ export class RunnerStore {
|
|
|
255
267
|
|
|
256
268
|
/** The code a key was refused or dead-lettered with, or null. */
|
|
257
269
|
deadCode(key) {
|
|
258
|
-
return this.
|
|
270
|
+
return this.sql('SELECT code FROM dead WHERE key = ?').get(key)?.code ?? null;
|
|
259
271
|
}
|
|
260
272
|
|
|
261
273
|
/** Every queued command in queue order, payload parsed (a reconcile reads a scratch store's whole scan this way). */
|
|
262
274
|
planned() {
|
|
263
|
-
return this.
|
|
275
|
+
return this.sql('SELECT key, grp, type, payload, source FROM outbox ORDER BY ord').all()
|
|
264
276
|
.map((row) => ({ key: row.key, group: row.grp, type: row.type, payload: JSON.parse(row.payload), source: row.source }));
|
|
265
277
|
}
|
|
266
278
|
|
|
267
279
|
/** Every set-aside key with its code (all of them; deadRows is the newest few). */
|
|
268
280
|
deadKeys() {
|
|
269
|
-
return this.
|
|
281
|
+
return this.sql('SELECT key, type, source, code FROM dead ORDER BY key').all().map((row) => ({ ...row }));
|
|
270
282
|
}
|
|
271
283
|
|
|
272
284
|
deadRows({ limit = 20 } = {}) {
|
|
273
|
-
return this.
|
|
285
|
+
return this.sql('SELECT key, type, source, code, message, at FROM dead ORDER BY at DESC LIMIT ?').all(limit).map((row) => ({ ...row }));
|
|
274
286
|
}
|
|
275
287
|
|
|
276
288
|
/** Counts per source: pending, sent (written or duplicate) and dead (by code). */
|
|
277
289
|
counts() {
|
|
278
290
|
const out = {};
|
|
279
291
|
const at = (source) => (out[source] ||= { pending: 0, written: 0, duplicate: 0, dead: {} });
|
|
280
|
-
for (const row of this.
|
|
281
|
-
for (const row of this.
|
|
292
|
+
for (const row of this.sql('SELECT source, COUNT(*) AS n FROM outbox GROUP BY source').all()) at(row.source).pending = Number(row.n);
|
|
293
|
+
for (const row of this.sql('SELECT source, duplicate, COUNT(*) AS n FROM sent GROUP BY source, duplicate').all()) {
|
|
282
294
|
at(row.source)[row.duplicate ? 'duplicate' : 'written'] = Number(row.n);
|
|
283
295
|
}
|
|
284
|
-
for (const row of this.
|
|
296
|
+
for (const row of this.sql('SELECT source, code, COUNT(*) AS n FROM dead GROUP BY source, code').all()) at(row.source).dead[row.code] = Number(row.n);
|
|
285
297
|
return out;
|
|
286
298
|
}
|
|
287
299
|
|
|
288
300
|
version(entity) {
|
|
289
|
-
const row = this.
|
|
301
|
+
const row = this.sql('SELECT sha, raw_sha, static_sha, key, at FROM version WHERE entity = ?').get(entity);
|
|
290
302
|
return row ? { sha: row.sha, rawSha: row.raw_sha, staticSha: row.static_sha, key: row.key, at: Number(row.at) } : null;
|
|
291
303
|
}
|
|
292
304
|
|
|
@@ -295,7 +307,7 @@ export class RunnerStore {
|
|
|
295
307
|
* `<prefix><sep><sha>.<k>` when it returns after another version (A→B→A), so the return is a new event that applies.
|
|
296
308
|
*/
|
|
297
309
|
versionKey(entity, prefix, sha, separator = ':') {
|
|
298
|
-
const seen = this.
|
|
310
|
+
const seen = this.sql('SELECT count FROM version_seen WHERE entity = ? AND sha = ?').get(entity, sha);
|
|
299
311
|
const count = seen ? Number(seen.count) : 0;
|
|
300
312
|
return count === 0 ? `${prefix}${separator}${sha}` : `${prefix}${separator}${sha}.${count}`;
|
|
301
313
|
}
|
|
@@ -304,12 +316,12 @@ export class RunnerStore {
|
|
|
304
316
|
setVersion(entity, { sha, rawSha = null, staticSha = null, key }) {
|
|
305
317
|
const last = this.version(entity);
|
|
306
318
|
if (last?.sha !== sha) {
|
|
307
|
-
this.
|
|
319
|
+
this.sql(
|
|
308
320
|
`INSERT INTO version_seen (entity, sha, count) VALUES (?, ?, 1)
|
|
309
321
|
ON CONFLICT (entity, sha) DO UPDATE SET count = count + 1`,
|
|
310
322
|
).run(entity, sha);
|
|
311
323
|
}
|
|
312
|
-
this.
|
|
324
|
+
this.sql(
|
|
313
325
|
`INSERT INTO version (entity, sha, raw_sha, static_sha, key, at) VALUES (?, ?, ?, ?, ?, ?)
|
|
314
326
|
ON CONFLICT (entity) DO UPDATE SET sha = excluded.sha, raw_sha = excluded.raw_sha, static_sha = excluded.static_sha,
|
|
315
327
|
key = excluded.key, at = excluded.at`,
|
|
@@ -318,67 +330,67 @@ export class RunnerStore {
|
|
|
318
330
|
|
|
319
331
|
/** The raw form last read, without changing what was sent (an unchanged row skips redaction next time). */
|
|
320
332
|
touchVersion(entity, rawSha) {
|
|
321
|
-
this.
|
|
333
|
+
this.sql('UPDATE version SET raw_sha = ? WHERE entity = ?').run(rawSha, entity);
|
|
322
334
|
}
|
|
323
335
|
|
|
324
336
|
dropVersion(entity) {
|
|
325
|
-
this.
|
|
337
|
+
this.sql('DELETE FROM version WHERE entity = ?').run(entity);
|
|
326
338
|
}
|
|
327
339
|
|
|
328
340
|
entities(prefix) {
|
|
329
|
-
return this.
|
|
341
|
+
return this.sql("SELECT entity FROM version WHERE entity >= ? AND entity < ? || char(1114111)").all(prefix, prefix).map((row) => row.entity);
|
|
330
342
|
}
|
|
331
343
|
|
|
332
344
|
/** The receipt state last queued for each recipient row still able to change (not acked or expired). */
|
|
333
345
|
openReceipts() {
|
|
334
|
-
return this.
|
|
346
|
+
return this.sql("SELECT message_id, recipient, state FROM receipt_state WHERE state NOT IN ('acked', 'expired')").all()
|
|
335
347
|
.map((row) => ({ messageId: row.message_id, recipient: row.recipient, state: row.state }));
|
|
336
348
|
}
|
|
337
349
|
|
|
338
350
|
receiptState(messageId, recipient) {
|
|
339
|
-
return this.
|
|
351
|
+
return this.sql('SELECT state FROM receipt_state WHERE message_id = ? AND recipient = ?').get(messageId, recipient)?.state ?? null;
|
|
340
352
|
}
|
|
341
353
|
|
|
342
354
|
setReceiptState(messageId, recipient, state) {
|
|
343
|
-
this.
|
|
355
|
+
this.sql(
|
|
344
356
|
`INSERT INTO receipt_state (message_id, recipient, state) VALUES (?, ?, ?)
|
|
345
357
|
ON CONFLICT (message_id, recipient) DO UPDATE SET state = excluded.state`,
|
|
346
358
|
).run(messageId, recipient, state);
|
|
347
359
|
}
|
|
348
360
|
|
|
349
361
|
dropReceipts(messageId) {
|
|
350
|
-
this.
|
|
362
|
+
this.sql('DELETE FROM receipt_state WHERE message_id = ?').run(messageId);
|
|
351
363
|
}
|
|
352
364
|
|
|
353
365
|
rememberMessage(id, createdAt, group) {
|
|
354
|
-
this.
|
|
366
|
+
this.sql('INSERT INTO recent_message (id, created_at, grp) VALUES (?, ?, ?) ON CONFLICT (id) DO NOTHING').run(id, createdAt, group);
|
|
355
367
|
}
|
|
356
368
|
|
|
357
369
|
recentMessages(sinceMs) {
|
|
358
|
-
return this.
|
|
370
|
+
return this.sql('SELECT id, created_at, grp FROM recent_message WHERE created_at >= ? ORDER BY created_at').all(sinceMs)
|
|
359
371
|
.map((row) => ({ id: row.id, createdAt: Number(row.created_at), group: row.grp }));
|
|
360
372
|
}
|
|
361
373
|
|
|
362
374
|
forgetMessage(id) {
|
|
363
|
-
this.
|
|
375
|
+
this.sql('DELETE FROM recent_message WHERE id = ?').run(id);
|
|
364
376
|
}
|
|
365
377
|
|
|
366
378
|
pruneRecentMessages(beforeMs) {
|
|
367
|
-
this.
|
|
379
|
+
this.sql('DELETE FROM recent_message WHERE created_at < ?').run(beforeMs);
|
|
368
380
|
}
|
|
369
381
|
|
|
370
382
|
ingested(channel) {
|
|
371
|
-
return this.
|
|
383
|
+
return this.sql('SELECT ordinal, sha, post_key, at FROM ingested WHERE channel = ? ORDER BY ordinal').all(channel)
|
|
372
384
|
.map((row) => ({ ordinal: Number(row.ordinal), sha: row.sha, postKey: row.post_key, at: Number(row.at) }));
|
|
373
385
|
}
|
|
374
386
|
|
|
375
387
|
/** The key a channel's block (its digest and occurrence) was first sent under, or null. */
|
|
376
388
|
ingestedKey(channel, sha) {
|
|
377
|
-
return this.
|
|
389
|
+
return this.sql('SELECT post_key FROM ingested WHERE channel = ? AND sha = ?').get(channel, sha)?.post_key ?? null;
|
|
378
390
|
}
|
|
379
391
|
|
|
380
392
|
setIngested(channel, { ordinal, sha, postKey, at }) {
|
|
381
|
-
this.
|
|
393
|
+
this.sql(
|
|
382
394
|
`INSERT INTO ingested (channel, ordinal, sha, post_key, at) VALUES (?, ?, ?, ?, ?)
|
|
383
395
|
ON CONFLICT (channel, ordinal) DO UPDATE SET sha = excluded.sha, post_key = excluded.post_key, at = excluded.at`,
|
|
384
396
|
).run(channel, ordinal, sha, postKey, at);
|
|
@@ -387,18 +399,18 @@ export class RunnerStore {
|
|
|
387
399
|
/** What one channel file holds now, by position, replacing what was recorded before. */
|
|
388
400
|
replaceIngested(channel, rows) {
|
|
389
401
|
this.transaction(() => {
|
|
390
|
-
this.
|
|
402
|
+
this.sql('DELETE FROM ingested WHERE channel = ?').run(channel);
|
|
391
403
|
for (const row of rows) this.setIngested(channel, row);
|
|
392
404
|
});
|
|
393
405
|
}
|
|
394
406
|
|
|
395
407
|
presenceSent(actorId) {
|
|
396
|
-
const row = this.
|
|
408
|
+
const row = this.sql('SELECT last_seen, sent_at FROM presence_sent WHERE actor_id = ?').get(actorId);
|
|
397
409
|
return row ? { lastSeen: Number(row.last_seen), sentAt: Number(row.sent_at) } : null;
|
|
398
410
|
}
|
|
399
411
|
|
|
400
412
|
setPresenceSent(actorId, lastSeen) {
|
|
401
|
-
this.
|
|
413
|
+
this.sql(
|
|
402
414
|
`INSERT INTO presence_sent (actor_id, last_seen, sent_at) VALUES (?, ?, ?)
|
|
403
415
|
ON CONFLICT (actor_id) DO UPDATE SET last_seen = excluded.last_seen, sent_at = excluded.sent_at`,
|
|
404
416
|
).run(actorId, lastSeen, this.now());
|
|
@@ -406,12 +418,12 @@ export class RunnerStore {
|
|
|
406
418
|
|
|
407
419
|
/** Whether the cloud acknowledged `key` as a command of `type`. */
|
|
408
420
|
sentAs(key, type) {
|
|
409
|
-
return Boolean(this.
|
|
421
|
+
return Boolean(this.sql('SELECT 1 AS found FROM sent WHERE key = ? AND type = ?').get(key, type));
|
|
410
422
|
}
|
|
411
423
|
|
|
412
424
|
/** Acknowledged keys older than `beforeMs` go; the cloud's dedupe is the backstop for anything resent after that. */
|
|
413
425
|
pruneSent(beforeMs) {
|
|
414
|
-
return Number(this.
|
|
426
|
+
return Number(this.sql('DELETE FROM sent WHERE at < ?').run(beforeMs).changes);
|
|
415
427
|
}
|
|
416
428
|
}
|
|
417
429
|
|
package/src/runner/bridge.js
CHANGED
|
@@ -24,6 +24,8 @@ const PRESENCE_CURSOR = 'presence_cursor';
|
|
|
24
24
|
export const SYNC_FAILING_AFTER = 3;
|
|
25
25
|
/** SCOPE-02: while blocked for a scope or grant, the sync is retried this often with the sign-in read again, so a re-login clears it. */
|
|
26
26
|
export const BLOCK_RETRY_MS = 60_000;
|
|
27
|
+
/** Changes capture refused to send because they hold secret-like content (hubScan): set aside by design, not a fault. */
|
|
28
|
+
const WITHHELD_CODE = 'content_withheld';
|
|
27
29
|
const ERROR_CODES = new Set(['HUB_LOGIN_REQUIRED', 'HUB_SCOPE_DENIED', 'capability_denied', 'cloud_first_off', 'HUB_MACHINE_REVOKED']);
|
|
28
30
|
|
|
29
31
|
/**
|
|
@@ -63,6 +65,7 @@ export class RunnerBridge {
|
|
|
63
65
|
if (this.running) return;
|
|
64
66
|
this.running = true;
|
|
65
67
|
this.runner.on('wrote', () => this.kick());
|
|
68
|
+
this.runner.on('loop_stall', (message) => this.log('loop_stall', message.details || {}));
|
|
66
69
|
this.syncing = this.#syncLoop();
|
|
67
70
|
this.flushing = this.#flushLoop();
|
|
68
71
|
if (this.leaseConfig) this.leasing = this.#leaseLoop();
|
|
@@ -254,10 +257,12 @@ export class RunnerBridge {
|
|
|
254
257
|
const now = this.now();
|
|
255
258
|
const pending = this.store.pendingCount();
|
|
256
259
|
const dead = Number(this.store.db.prepare('SELECT COUNT(*) AS n FROM dead').get().n);
|
|
260
|
+
const withheld = Number(this.store.db.prepare('SELECT COUNT(*) AS n FROM dead WHERE code = ?').get(WITHHELD_CODE).n);
|
|
261
|
+
const setAside = dead - withheld;
|
|
257
262
|
const quarantined = Number(this.store.db.prepare('SELECT COUNT(*) AS n FROM pulse_quarantine').get()?.n || 0);
|
|
258
263
|
const applied = this.applied();
|
|
259
264
|
const lag = Math.max(0, this.state.head - applied);
|
|
260
|
-
const outbox = { pending, dead, capacity: OUTBOX_MAX_PENDING };
|
|
265
|
+
const outbox = { pending, dead, withheld, capacity: OUTBOX_MAX_PENDING };
|
|
261
266
|
const base = { lastSyncAt: this.state.lastSyncAt, applied, head: this.state.head, lag, outbox, quarantined,
|
|
262
267
|
staleSeconds: this.state.lastSyncAt ? (now - this.state.lastSyncAt) / 1000 : null };
|
|
263
268
|
const { block } = this.state.error || {};
|
|
@@ -276,8 +281,8 @@ export class RunnerBridge {
|
|
|
276
281
|
return { ...base, state: 'offline', reason: this.state.lastContactAt ? 'no contact with Pulse' : 'not connected yet' };
|
|
277
282
|
}
|
|
278
283
|
if (lag > LAG_EVENTS || (pending > 0 && this.state.flushError)) return { ...base, state: 'catching_up', reason: `${lag} events and ${pending} changes to go` };
|
|
279
|
-
if (
|
|
280
|
-
return { ...base, state: 'degraded', reason: `${
|
|
284
|
+
if (setAside || quarantined || pending > 0.8 * OUTBOX_MAX_PENDING) {
|
|
285
|
+
return { ...base, state: 'degraded', reason: `${setAside} change${setAside === 1 ? '' : 's'} and ${quarantined} event${quarantined === 1 ? '' : 's'} set aside` };
|
|
281
286
|
}
|
|
282
287
|
if (!this.state.lastSyncAt || now - this.state.lastSyncAt > ONLINE_MS) return { ...base, state: 'catching_up', reason: 'waiting for a sync' };
|
|
283
288
|
return { ...base, state: 'online', reason: 'in sync' };
|
|
@@ -204,8 +204,18 @@ export function createBusIdentity(hub, { now: clock = Date.now } = {}) {
|
|
|
204
204
|
refreshProof: db.prepare("INSERT INTO pulse_proofs (address, at, via, used) VALUES (?, ?, 'listen', 0) ON CONFLICT (address) DO UPDATE SET at = MAX(pulse_proofs.at, excluded.at)"),
|
|
205
205
|
useProof: db.prepare('UPDATE pulse_proofs SET used = 1 WHERE address = ?'),
|
|
206
206
|
remote: db.prepare('SELECT * FROM pulse_remote_presence WHERE actor_id = ?'),
|
|
207
|
+
queuedFor: db.prepare(`SELECT 1 AS found FROM deliveries d JOIN messages m ON m.id = d.message_id
|
|
208
|
+
WHERE d.recipient = ? AND d.state = 'queued' AND m.expires_at > ? LIMIT 1`),
|
|
209
|
+
lapsedFor: db.prepare(`SELECT 1 AS found FROM deliveries d JOIN messages m ON m.id = d.message_id
|
|
210
|
+
WHERE d.recipient = ? AND d.state = 'leased' AND d.lease_until < ? AND m.expires_at > ? LIMIT 1`),
|
|
207
211
|
};
|
|
208
212
|
|
|
213
|
+
/**
|
|
214
|
+
* Whether messaging.claim would find anything for one of `addresses` now (its own rows: queued, or leased with a lapsed
|
|
215
|
+
* lease, and not expired), read without the write lock, so an empty `bus listen` poll needs no write transaction.
|
|
216
|
+
*/
|
|
217
|
+
const claimable = (addresses, now = clock()) => addresses.some((a) => q.queuedFor.get(a, now) || q.lapsedFor.get(a, now, now));
|
|
218
|
+
|
|
209
219
|
/**
|
|
210
220
|
* Every chat the hub already knows to be a session, before its next hook: hook-seen and transcript-found actors, and
|
|
211
221
|
* presence rows whose last event came from a hook. Without this, right after an upgrade another session could take a
|
|
@@ -464,7 +474,7 @@ export function createBusIdentity(hub, { now: clock = Date.now } = {}) {
|
|
|
464
474
|
return { registrations: shown.map(({ active: _active, ...e }) => e), groups: [...groups.values()], window: { minutes: windowMs / 60e3, all: !!all } };
|
|
465
475
|
}
|
|
466
476
|
|
|
467
|
-
return { addressesOf, inboxFor, sessionOf, aliasesOf, activity, known, suggest, reach, sawHook, proved, listened, bindFromHook, notices, who };
|
|
477
|
+
return { addressesOf, inboxFor, claimable, sessionOf, aliasesOf, activity, known, suggest, reach, sawHook, proved, listened, bindFromHook, notices, who };
|
|
468
478
|
}
|
|
469
479
|
|
|
470
480
|
/**
|
package/src/runner/capture.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
1
2
|
import { withTx } from '../hub/core/db.mjs';
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -18,6 +19,17 @@ const SCHEMA = [
|
|
|
18
19
|
'CREATE TABLE IF NOT EXISTS pulse_capture (id INTEGER PRIMARY KEY AUTOINCREMENT, tbl TEXT NOT NULL, op TEXT NOT NULL, k1 TEXT NOT NULL, k2 TEXT)',
|
|
19
20
|
];
|
|
20
21
|
|
|
22
|
+
/** One chunk of swept or pumped work holds the write lock and the event loop about this long, then yields. */
|
|
23
|
+
export const CHUNK_MS = 50;
|
|
24
|
+
/**
|
|
25
|
+
* Between chunks the lock is left free this long (writeInChunks' pause, without blocking the loop); longer while
|
|
26
|
+
* another connection is writing (it committed since the last pause), because its busy handler sleeps up to 100 ms
|
|
27
|
+
* between tries and would otherwise rarely land in a short gap.
|
|
28
|
+
*/
|
|
29
|
+
export const CHUNK_PAUSE_MS = 15;
|
|
30
|
+
export const WRITER_PAUSE_MS = 120;
|
|
31
|
+
const JOURNAL_PAGE = 200;
|
|
32
|
+
|
|
21
33
|
function triggerSql({ table, keys, ops }) {
|
|
22
34
|
return ops.map((op) => {
|
|
23
35
|
const row = op === 'DELETE' ? 'OLD' : 'NEW';
|
|
@@ -37,30 +49,40 @@ export function installCapture(db) {
|
|
|
37
49
|
* Turns every captured hub write into outbox commands inside the transaction that made it.
|
|
38
50
|
*
|
|
39
51
|
* `tx(fn, { guard })` runs fn in one write transaction (the ported core's own withTx calls join it), drains the trigger
|
|
40
|
-
*
|
|
52
|
+
* rows fn added through `mapping.commands(change, db)` into the outbox, then runs `guard`; a guard that throws rolls the
|
|
41
53
|
* hub write and its commands back together. `applying(fn)` writes with the triggers off, for rows that came from the
|
|
42
54
|
* cloud: other connections never see the flag set, because SQLite serializes writers and the flag is set and reset
|
|
43
|
-
* inside one write transaction. `pump()` drains what writes
|
|
55
|
+
* inside one write transaction. `pump({ budgetMs })` drains what writes on other connections (the collect worker) left
|
|
56
|
+
* in the journal, oldest first, for at most budgetMs per transaction. `chunks(steps)` runs steps in capture
|
|
57
|
+
* transactions of about CHUNK_MS each, leaving the event loop and the lock free between them (`yieldLock`).
|
|
44
58
|
*/
|
|
45
59
|
export function createCapture({ db, store, mapping, log = () => {} }) {
|
|
46
|
-
const journal = db.prepare('SELECT id, tbl, op, k1, k2 FROM pulse_capture ORDER BY id');
|
|
47
|
-
const
|
|
60
|
+
const journal = db.prepare('SELECT id, tbl, op, k1, k2 FROM pulse_capture WHERE id > ? ORDER BY id LIMIT ?');
|
|
61
|
+
const newest = db.prepare('SELECT COALESCE(MAX(id), 0) AS id FROM pulse_capture');
|
|
62
|
+
const waiting = db.prepare('SELECT EXISTS (SELECT 1 FROM pulse_capture) AS found');
|
|
63
|
+
const clear = db.prepare('DELETE FROM pulse_capture WHERE id > ? AND id <= ?');
|
|
48
64
|
const setApplying = db.prepare('UPDATE pulse_ctx SET applying = ? WHERE one = 1');
|
|
65
|
+
const dataVersion = db.prepare('PRAGMA data_version');
|
|
66
|
+
let lastVersion = null;
|
|
67
|
+
const batchOf = () => (mapping.batch ? mapping.batch(db) : (change) => mapping.commands(change, db));
|
|
49
68
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
69
|
+
const entityOf = (row) => `${row.tbl}\0${row.k1}\0${row.k2 ?? ''}`;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Commands for one page of journal rows, each entity once at its latest row in the page, in row order. Stops after
|
|
73
|
+
* the row at which `over()` turns true; returns the id of the last row it is done with and what it queued. Rows
|
|
74
|
+
* before that whose entity comes again later in the page are left to that later row.
|
|
75
|
+
*/
|
|
76
|
+
function drainPage(rows, commands, over) {
|
|
77
|
+
const lastAt = new Map(rows.map((row, index) => [entityOf(row), index]));
|
|
59
78
|
const entries = [];
|
|
60
|
-
|
|
79
|
+
let done = rows.length - 1;
|
|
80
|
+
for (const [index, row] of rows.entries()) {
|
|
81
|
+
if (lastAt.get(entityOf(row)) !== index) continue;
|
|
82
|
+
const { tbl, op, k1, k2 } = row;
|
|
61
83
|
const change = { tbl, op, k1, k2 };
|
|
62
84
|
try {
|
|
63
|
-
entries.push(...
|
|
85
|
+
entries.push(...commands(change));
|
|
64
86
|
} catch (error) {
|
|
65
87
|
// A full outbox refuses the write itself (507 pulse_backlog), so the caller is told.
|
|
66
88
|
if (error.code === 'pulse_backlog') throw error;
|
|
@@ -71,16 +93,36 @@ export function createCapture({ db, store, mapping, log = () => {} }) {
|
|
|
71
93
|
);
|
|
72
94
|
log('capture_failed', { tbl, op, code: error?.code || 'capture_failed' });
|
|
73
95
|
}
|
|
96
|
+
if (index < rows.length - 1 && over()) { done = index; break; }
|
|
74
97
|
}
|
|
75
98
|
if (entries.length) store.enqueue(entries);
|
|
76
|
-
|
|
77
|
-
|
|
99
|
+
return { last: rows[done].id, queued: entries.length, all: done === rows.length - 1 };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Drains journal rows after `after`, a page at a time, until none are left or `budgetMs` has passed. */
|
|
103
|
+
function drain({ after = 0, budgetMs = Infinity } = {}) {
|
|
104
|
+
const started = performance.now();
|
|
105
|
+
const over = () => performance.now() - started >= budgetMs;
|
|
106
|
+
const commands = batchOf();
|
|
107
|
+
let last = after;
|
|
108
|
+
let queued = 0;
|
|
109
|
+
for (;;) {
|
|
110
|
+
const rows = journal.all(last, JOURNAL_PAGE);
|
|
111
|
+
if (!rows.length) break;
|
|
112
|
+
const page = drainPage(rows, commands, over);
|
|
113
|
+
queued += page.queued;
|
|
114
|
+
last = page.last;
|
|
115
|
+
if (!page.all || rows.length < JOURNAL_PAGE || over()) break;
|
|
116
|
+
}
|
|
117
|
+
if (last > after) clear.run(after, last);
|
|
118
|
+
return queued;
|
|
78
119
|
}
|
|
79
120
|
|
|
80
121
|
function tx(fn, { guard = null, label = 'capture' } = {}) {
|
|
81
122
|
return withTx(db, () => {
|
|
123
|
+
const mark = newest.get().id;
|
|
82
124
|
const out = fn();
|
|
83
|
-
drain();
|
|
125
|
+
drain({ after: mark });
|
|
84
126
|
if (guard) guard();
|
|
85
127
|
return out;
|
|
86
128
|
}, label);
|
|
@@ -97,7 +139,28 @@ export function createCapture({ db, store, mapping, log = () => {} }) {
|
|
|
97
139
|
}, 'apply');
|
|
98
140
|
}
|
|
99
141
|
|
|
100
|
-
const pump = () => withTx(db, drain, 'capture pump');
|
|
142
|
+
const pump = ({ budgetMs = Infinity } = {}) => withTx(db, () => drain({ budgetMs }), 'capture pump');
|
|
143
|
+
const pending = () => Boolean(waiting.get().found);
|
|
144
|
+
const yieldLock = () => {
|
|
145
|
+
const version = dataVersion.get().data_version;
|
|
146
|
+
const writing = lastVersion !== null && version !== lastVersion;
|
|
147
|
+
lastVersion = version;
|
|
148
|
+
return sleep(writing ? WRITER_PAUSE_MS : CHUNK_PAUSE_MS);
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
async function chunks(steps, { budgetMs = CHUNK_MS, label = 'capture chunk' } = {}) {
|
|
152
|
+
let i = 0;
|
|
153
|
+
let n = 0;
|
|
154
|
+
while (i < steps.length) {
|
|
155
|
+
const started = performance.now();
|
|
156
|
+
n += 1;
|
|
157
|
+
tx(() => {
|
|
158
|
+
do steps[i++](); while (i < steps.length && performance.now() - started < budgetMs);
|
|
159
|
+
}, { label: `${label} (chunk ${n})` });
|
|
160
|
+
if (i < steps.length) await yieldLock();
|
|
161
|
+
}
|
|
162
|
+
return n;
|
|
163
|
+
}
|
|
101
164
|
|
|
102
|
-
return { tx, applying, pump, drain };
|
|
165
|
+
return { tx, applying, pump, pending, chunks, yieldLock, drain };
|
|
103
166
|
}
|