stitchkit 0.108.5 → 0.109.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/CHANGELOG.md +47 -0
- package/dist/browser/cancellation.d.ts +4 -5
- package/dist/browser/cancellation.d.ts.map +1 -1
- package/dist/browser/client-executors.d.ts.map +1 -1
- package/dist/browser/client.d.ts +1 -1
- package/dist/browser/client.d.ts.map +1 -1
- package/dist/browser/http.d.ts +1 -1
- package/dist/browser/http.d.ts.map +1 -1
- package/dist/browser/session.d.ts +1 -1
- package/dist/browser/session.d.ts.map +1 -1
- package/dist/browser/transport.d.ts +1 -1
- package/dist/browser/transport.d.ts.map +1 -1
- package/dist/entrypoints/cli.js +1 -1
- package/dist/entrypoints/index.js +2 -2
- package/dist/entrypoints/react.js +1 -1
- package/dist/entrypoints/remote.js +2 -2
- package/dist/entrypoints/telegram.d.ts +2 -2
- package/dist/entrypoints/telegram.d.ts.map +1 -1
- package/dist/entrypoints/telegram.js +868 -137
- package/dist/entrypoints/testing.js +2 -2
- package/dist/entrypoints/tools.js +1 -1
- package/dist/{index-7jvwrjqa.js → index-00v1qz76.js} +20 -17
- package/dist/{index-05s01c94.js → index-5cw390s6.js} +96 -16
- package/dist/{index-p5kx5vqd.js → index-e32vy8jw.js} +22 -11
- package/dist/telegram/update-intake-work.d.ts +39 -0
- package/dist/telegram/update-intake-work.d.ts.map +1 -0
- package/dist/telegram/update-intake.d.ts +47 -9
- package/dist/telegram/update-intake.d.ts.map +1 -1
- package/dist/telegram/update-store-check.d.ts.map +1 -1
- package/dist/telegram/update-store-postgres.d.ts +2 -2
- package/dist/telegram/update-store-postgres.d.ts.map +1 -1
- package/dist/telegram/update-store-sqlite.d.ts +2 -2
- package/dist/telegram/update-store-sqlite.d.ts.map +1 -1
- package/dist/telegram/update-store.d.ts +52 -3
- package/dist/telegram/update-store.d.ts.map +1 -1
- package/dist/tools/cli/args-fields.d.ts +7 -1
- package/dist/tools/cli/args-fields.d.ts.map +1 -1
- package/dist/tools/cli/args.d.ts.map +1 -1
- package/llms/cli.txt +9 -1
- package/llms/stitchkit.txt +10 -3
- package/llms/telegram.txt +59 -8
- package/llms/{upgrading-0.100.0-0.108.0.txt → upgrading-0.101.0-0.109.0.txt} +70 -72
- package/llms/upgrading-0.37.0.txt +176 -0
- package/llms/{upgrading-0.37.0-0.58.0.txt → upgrading-0.44.0-0.60.0.txt} +229 -169
- package/llms/{upgrading-0.59.0-0.71.0.txt → upgrading-0.61.0-0.73.0.txt} +122 -229
- package/llms/{upgrading-0.72.0-0.99.0.txt → upgrading-0.74.0-0.100.0.txt} +72 -122
- package/llms/upgrading.txt +5 -4
- package/llms-full.txt +145 -12
- package/llms.txt +9 -8
- package/package.json +1 -1
|
@@ -1220,7 +1220,226 @@ function createTelegramBotTransport(options = {}) {
|
|
|
1220
1220
|
return exchange(socket, Buffer.concat([Buffer.from(head, "latin1"), body]), { maxResponseBytes, responseTimeoutMs }, signal);
|
|
1221
1221
|
};
|
|
1222
1222
|
}
|
|
1223
|
+
// src/telegram/update-intake-work.ts
|
|
1224
|
+
function createTelegramUpdateWorkSlots(maxConcurrent) {
|
|
1225
|
+
const waiting = [];
|
|
1226
|
+
let running = 0;
|
|
1227
|
+
return {
|
|
1228
|
+
async take() {
|
|
1229
|
+
if (running < maxConcurrent) {
|
|
1230
|
+
running += 1;
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
await new Promise((resolve) => waiting.push(resolve));
|
|
1234
|
+
},
|
|
1235
|
+
release() {
|
|
1236
|
+
const next = waiting.shift();
|
|
1237
|
+
if (next)
|
|
1238
|
+
next();
|
|
1239
|
+
else
|
|
1240
|
+
running -= 1;
|
|
1241
|
+
}
|
|
1242
|
+
};
|
|
1243
|
+
}
|
|
1244
|
+
function createTelegramUpdateExhaustionScheduler(options) {
|
|
1245
|
+
const { config, store, now, slots, parse, reportStoreError } = options;
|
|
1246
|
+
const handler = config.handleExhaustion;
|
|
1247
|
+
const jobs = new Map;
|
|
1248
|
+
return {
|
|
1249
|
+
schedule(row, active) {
|
|
1250
|
+
if (!active || !store || !handler || jobs.has(row.updateId))
|
|
1251
|
+
return false;
|
|
1252
|
+
const update = parse(row.body);
|
|
1253
|
+
if (!update)
|
|
1254
|
+
return false;
|
|
1255
|
+
const exhaustion = Object.freeze({
|
|
1256
|
+
update: transportResult(update),
|
|
1257
|
+
updateId: row.updateId,
|
|
1258
|
+
attempt: row.attempt,
|
|
1259
|
+
claimId: row.claimId,
|
|
1260
|
+
exhaustedAt: row.exhaustedAt,
|
|
1261
|
+
error: row.error
|
|
1262
|
+
});
|
|
1263
|
+
const job = (async () => {
|
|
1264
|
+
await slots.take();
|
|
1265
|
+
try {
|
|
1266
|
+
try {
|
|
1267
|
+
await handler(exhaustion);
|
|
1268
|
+
} catch (error) {
|
|
1269
|
+
try {
|
|
1270
|
+
config.onExhaustionFailure?.({ exhaustion, error });
|
|
1271
|
+
} catch {}
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1274
|
+
await store.acknowledgeExhaustion(exhaustion, now()).catch((error) => reportStoreError(error, "settle"));
|
|
1275
|
+
} finally {
|
|
1276
|
+
slots.release();
|
|
1277
|
+
}
|
|
1278
|
+
})().finally(() => {
|
|
1279
|
+
if (jobs.get(row.updateId) === job)
|
|
1280
|
+
jobs.delete(row.updateId);
|
|
1281
|
+
});
|
|
1282
|
+
jobs.set(row.updateId, job);
|
|
1283
|
+
return true;
|
|
1284
|
+
},
|
|
1285
|
+
pending: () => [...jobs.values()]
|
|
1286
|
+
};
|
|
1287
|
+
}
|
|
1288
|
+
function handlerError(error) {
|
|
1289
|
+
return error instanceof Error && error.name === "BotError" && "ctx" in error && "error" in error ? error.error : error;
|
|
1290
|
+
}
|
|
1291
|
+
function createTelegramUpdateAttemptRunner(options) {
|
|
1292
|
+
const {
|
|
1293
|
+
config,
|
|
1294
|
+
store,
|
|
1295
|
+
fenced,
|
|
1296
|
+
durable,
|
|
1297
|
+
now,
|
|
1298
|
+
retry,
|
|
1299
|
+
maxAttempts,
|
|
1300
|
+
leaseMs,
|
|
1301
|
+
reportStoreError,
|
|
1302
|
+
scheduleExhaustion,
|
|
1303
|
+
ownershipLostReason
|
|
1304
|
+
} = options;
|
|
1305
|
+
return async (update, updateId) => {
|
|
1306
|
+
const taken = now();
|
|
1307
|
+
const claimOptions = {
|
|
1308
|
+
now: taken,
|
|
1309
|
+
leaseUntil: taken + leaseMs,
|
|
1310
|
+
maxAttempts,
|
|
1311
|
+
...durable && { durableExhaustion: true }
|
|
1312
|
+
};
|
|
1313
|
+
const ownedIdentity = fenced ? await fenced.claimOwned(updateId, claimOptions).catch((error) => reportStoreError(error, "claim")) : undefined;
|
|
1314
|
+
const legacyAttempt = fenced ? undefined : await store.claim(updateId, claimOptions).catch((error) => reportStoreError(error, "claim"));
|
|
1315
|
+
const attempt = ownedIdentity?.attempt ?? legacyAttempt;
|
|
1316
|
+
if (attempt === undefined)
|
|
1317
|
+
return;
|
|
1318
|
+
const identity = Object.freeze(ownedIdentity ?? { updateId, attempt, claimId: "" });
|
|
1319
|
+
const ownership = new AbortController;
|
|
1320
|
+
let active = true;
|
|
1321
|
+
const loseOwnership = () => {
|
|
1322
|
+
if (!ownership.signal.aborted)
|
|
1323
|
+
ownership.abort(ownershipLostReason(identity));
|
|
1324
|
+
};
|
|
1325
|
+
let ownershipDeadline;
|
|
1326
|
+
const armOwnershipDeadline = (leaseUntil) => {
|
|
1327
|
+
if (!active)
|
|
1328
|
+
return;
|
|
1329
|
+
if (ownershipDeadline)
|
|
1330
|
+
clearTimeout(ownershipDeadline);
|
|
1331
|
+
ownershipDeadline = setTimeout(loseOwnership, Math.max(1, leaseUntil - now()));
|
|
1332
|
+
ownershipDeadline.unref?.();
|
|
1333
|
+
};
|
|
1334
|
+
armOwnershipDeadline(taken + leaseMs);
|
|
1335
|
+
let renewalInFlight = false;
|
|
1336
|
+
const renewal = setInterval(() => {
|
|
1337
|
+
if (!active || renewalInFlight)
|
|
1338
|
+
return;
|
|
1339
|
+
renewalInFlight = true;
|
|
1340
|
+
const at = now();
|
|
1341
|
+
const leaseUntil = at + leaseMs;
|
|
1342
|
+
let request;
|
|
1343
|
+
try {
|
|
1344
|
+
request = fenced ? fenced.renewOwned(identity, leaseUntil, at) : store.renew(updateId, attempt, leaseUntil, at);
|
|
1345
|
+
} catch (error) {
|
|
1346
|
+
renewalInFlight = false;
|
|
1347
|
+
if (active)
|
|
1348
|
+
reportStoreError(error, "renew");
|
|
1349
|
+
return;
|
|
1350
|
+
}
|
|
1351
|
+
request.then((held) => {
|
|
1352
|
+
renewalInFlight = false;
|
|
1353
|
+
if (!active)
|
|
1354
|
+
return;
|
|
1355
|
+
if (held)
|
|
1356
|
+
armOwnershipDeadline(leaseUntil);
|
|
1357
|
+
else
|
|
1358
|
+
loseOwnership();
|
|
1359
|
+
}, (error) => {
|
|
1360
|
+
renewalInFlight = false;
|
|
1361
|
+
if (active)
|
|
1362
|
+
reportStoreError(error, "renew");
|
|
1363
|
+
});
|
|
1364
|
+
}, Math.max(1, Math.floor(leaseMs / 4)));
|
|
1365
|
+
renewal.unref?.();
|
|
1366
|
+
const context = Object.freeze({
|
|
1367
|
+
...identity,
|
|
1368
|
+
fence: identity,
|
|
1369
|
+
ownerLost: ownership.signal
|
|
1370
|
+
});
|
|
1371
|
+
const settleAttempt = (settlement) => fenced ? fenced.settleOwned(identity, settlement) : store.settle(updateId, attempt, settlement).then(() => true);
|
|
1372
|
+
try {
|
|
1373
|
+
try {
|
|
1374
|
+
const typedUpdate = transportResult(update);
|
|
1375
|
+
if ("handleAttempt" in config)
|
|
1376
|
+
await config.handleAttempt(typedUpdate, context);
|
|
1377
|
+
else
|
|
1378
|
+
await config.handle(typedUpdate);
|
|
1379
|
+
const settled = await settleAttempt({ state: "completed", at: now() }).catch((error) => {
|
|
1380
|
+
reportStoreError(error, "settle");
|
|
1381
|
+
return false;
|
|
1382
|
+
});
|
|
1383
|
+
if (settled === false)
|
|
1384
|
+
loseOwnership();
|
|
1385
|
+
} catch (thrown) {
|
|
1386
|
+
const error = handlerError(thrown);
|
|
1387
|
+
const delay = attempt >= maxAttempts ? false : retry(error, attempt);
|
|
1388
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1389
|
+
let exhaustion;
|
|
1390
|
+
let retrying = delay !== false;
|
|
1391
|
+
if (delay === false && durable) {
|
|
1392
|
+
const at = now();
|
|
1393
|
+
const persisted = await durable.exhaust(identity, { at, error: message }).catch((cause) => {
|
|
1394
|
+
reportStoreError(cause, "settle");
|
|
1395
|
+
return false;
|
|
1396
|
+
});
|
|
1397
|
+
if (persisted) {
|
|
1398
|
+
exhaustion = {
|
|
1399
|
+
...identity,
|
|
1400
|
+
body: JSON.stringify(update),
|
|
1401
|
+
exhaustedAt: at,
|
|
1402
|
+
error: message
|
|
1403
|
+
};
|
|
1404
|
+
} else {
|
|
1405
|
+
loseOwnership();
|
|
1406
|
+
retrying = true;
|
|
1407
|
+
}
|
|
1408
|
+
} else {
|
|
1409
|
+
const at = now();
|
|
1410
|
+
const settled = await settleAttempt(delay === false ? { state: "abandoned", at, error: message } : { state: "failed", at, retryAt: at + delay, error: message }).catch((cause) => {
|
|
1411
|
+
reportStoreError(cause, "settle");
|
|
1412
|
+
return false;
|
|
1413
|
+
});
|
|
1414
|
+
if (settled === false) {
|
|
1415
|
+
loseOwnership();
|
|
1416
|
+
retrying = true;
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
try {
|
|
1420
|
+
config.onFailure?.({ updateId, attempt, error, retrying });
|
|
1421
|
+
} catch {}
|
|
1422
|
+
if (exhaustion)
|
|
1423
|
+
scheduleExhaustion(exhaustion);
|
|
1424
|
+
}
|
|
1425
|
+
} finally {
|
|
1426
|
+
active = false;
|
|
1427
|
+
clearInterval(renewal);
|
|
1428
|
+
if (ownershipDeadline)
|
|
1429
|
+
clearTimeout(ownershipDeadline);
|
|
1430
|
+
}
|
|
1431
|
+
};
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1223
1434
|
// src/telegram/update-intake.ts
|
|
1435
|
+
class TelegramUpdateOwnershipLostError extends Error {
|
|
1436
|
+
identity;
|
|
1437
|
+
constructor(identity) {
|
|
1438
|
+
super(`[stitchkit] telegram update ${identity.updateId} attempt ${identity.attempt} lost ownership`);
|
|
1439
|
+
this.identity = identity;
|
|
1440
|
+
this.name = "TelegramUpdateOwnershipLostError";
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1224
1443
|
var SWEEP_BATCH = 100;
|
|
1225
1444
|
function defaultRetry(error, attempt) {
|
|
1226
1445
|
const failure = classifyTelegramSendFailure2(error);
|
|
@@ -1256,17 +1475,30 @@ function parsed(body) {
|
|
|
1256
1475
|
return;
|
|
1257
1476
|
}
|
|
1258
1477
|
}
|
|
1259
|
-
function handlerError(error) {
|
|
1260
|
-
return error instanceof Error && error.name === "BotError" && "ctx" in error && "error" in error ? error.error : error;
|
|
1261
|
-
}
|
|
1262
1478
|
function positive(name, value) {
|
|
1263
1479
|
if (!Number.isSafeInteger(value) || value < 1) {
|
|
1264
1480
|
throw new RangeError(`[stitchkit] telegram update intake: ${name} is a positive integer`);
|
|
1265
1481
|
}
|
|
1266
1482
|
return value;
|
|
1267
1483
|
}
|
|
1484
|
+
function isTelegramUpdateFencedStore(store) {
|
|
1485
|
+
return "claimOwned" in store && typeof store.claimOwned === "function" && "renewOwned" in store && typeof store.renewOwned === "function" && "owns" in store && typeof store.owns === "function" && "settleOwned" in store && typeof store.settleOwned === "function";
|
|
1486
|
+
}
|
|
1487
|
+
function isTelegramUpdateDurableStore(store) {
|
|
1488
|
+
return isTelegramUpdateFencedStore(store) && "exhaust" in store && typeof store.exhaust === "function" && "dueExhaustions" in store && typeof store.dueExhaustions === "function" && "acknowledgeExhaustion" in store && typeof store.acknowledgeExhaustion === "function";
|
|
1489
|
+
}
|
|
1268
1490
|
function createTelegramUpdateIntake(config) {
|
|
1269
1491
|
const { store } = config;
|
|
1492
|
+
const exhaustionHandler = config.handleExhaustion;
|
|
1493
|
+
const attemptAware = "handleAttempt" in config;
|
|
1494
|
+
const fenced = attemptAware || exhaustionHandler !== undefined ? isTelegramUpdateFencedStore(store) ? store : undefined : undefined;
|
|
1495
|
+
const durable = exhaustionHandler ? isTelegramUpdateDurableStore(store) ? store : undefined : undefined;
|
|
1496
|
+
if (attemptAware && !fenced) {
|
|
1497
|
+
throw new TypeError("[stitchkit] telegram update intake: handleAttempt requires a TelegramUpdateFencedStore");
|
|
1498
|
+
}
|
|
1499
|
+
if (exhaustionHandler && !durable) {
|
|
1500
|
+
throw new TypeError("[stitchkit] telegram update intake: handleExhaustion requires a TelegramUpdateDurableStore");
|
|
1501
|
+
}
|
|
1270
1502
|
const now = config.now ?? Date.now;
|
|
1271
1503
|
const retry = config.retry ?? defaultRetry;
|
|
1272
1504
|
const maxAttempts = positive("maxAttempts", config.maxAttempts ?? 5);
|
|
@@ -1277,66 +1509,52 @@ function createTelegramUpdateIntake(config) {
|
|
|
1277
1509
|
const maxConcurrent = positive("maxConcurrent", config.maxConcurrent ?? 32);
|
|
1278
1510
|
const lines = new Map;
|
|
1279
1511
|
const scheduled = new Set;
|
|
1280
|
-
const waiting = [];
|
|
1281
|
-
let running = 0;
|
|
1282
1512
|
let started = false;
|
|
1513
|
+
let starting;
|
|
1514
|
+
let lifecycleGeneration = 0;
|
|
1283
1515
|
let timer;
|
|
1516
|
+
let exhaustionCursor;
|
|
1284
1517
|
const storeError = (error, step) => {
|
|
1285
1518
|
try {
|
|
1286
1519
|
config.onStoreError?.(error, step);
|
|
1287
1520
|
} catch {}
|
|
1288
1521
|
};
|
|
1289
|
-
const
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
},
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
await config.handle(transportResult(update));
|
|
1314
|
-
settlement = { state: "completed", at: now() };
|
|
1315
|
-
} catch (thrown) {
|
|
1316
|
-
const error = handlerError(thrown);
|
|
1317
|
-
const delay = number >= maxAttempts ? false : retry(error, number);
|
|
1318
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
1319
|
-
settlement = delay === false ? { state: "abandoned", at: now(), error: message } : { state: "failed", at: now(), retryAt: now() + delay, error: message };
|
|
1320
|
-
try {
|
|
1321
|
-
config.onFailure?.({ updateId, attempt: number, error, retrying: delay !== false });
|
|
1322
|
-
} catch {}
|
|
1323
|
-
} finally {
|
|
1324
|
-
clearInterval(renewal);
|
|
1325
|
-
}
|
|
1326
|
-
await store.settle(updateId, number, settlement).catch((error) => storeError(error, "settle"));
|
|
1327
|
-
};
|
|
1522
|
+
const slots = createTelegramUpdateWorkSlots(maxConcurrent);
|
|
1523
|
+
const exhaustionScheduler = createTelegramUpdateExhaustionScheduler({
|
|
1524
|
+
config,
|
|
1525
|
+
store: durable,
|
|
1526
|
+
now,
|
|
1527
|
+
slots,
|
|
1528
|
+
parse: parsed,
|
|
1529
|
+
reportStoreError: storeError
|
|
1530
|
+
});
|
|
1531
|
+
const attempt = createTelegramUpdateAttemptRunner({
|
|
1532
|
+
config,
|
|
1533
|
+
store,
|
|
1534
|
+
fenced,
|
|
1535
|
+
durable,
|
|
1536
|
+
now,
|
|
1537
|
+
retry,
|
|
1538
|
+
maxAttempts,
|
|
1539
|
+
leaseMs,
|
|
1540
|
+
reportStoreError: storeError,
|
|
1541
|
+
scheduleExhaustion: (row) => {
|
|
1542
|
+
exhaustionScheduler.schedule(row, started);
|
|
1543
|
+
},
|
|
1544
|
+
ownershipLostReason: (identity) => new TelegramUpdateOwnershipLostError(identity)
|
|
1545
|
+
});
|
|
1328
1546
|
const schedule = (update) => {
|
|
1329
1547
|
const updateId = Number(update.update_id);
|
|
1330
1548
|
if (!started || scheduled.has(updateId))
|
|
1331
|
-
return;
|
|
1549
|
+
return false;
|
|
1332
1550
|
scheduled.add(updateId);
|
|
1333
1551
|
const line = lineOf(update);
|
|
1334
1552
|
const next = (lines.get(line) ?? Promise.resolve()).then(async () => {
|
|
1335
|
-
await
|
|
1553
|
+
await slots.take();
|
|
1336
1554
|
try {
|
|
1337
1555
|
await attempt(update, updateId);
|
|
1338
1556
|
} finally {
|
|
1339
|
-
release();
|
|
1557
|
+
slots.release();
|
|
1340
1558
|
}
|
|
1341
1559
|
}).finally(() => {
|
|
1342
1560
|
scheduled.delete(updateId);
|
|
@@ -1344,6 +1562,7 @@ function createTelegramUpdateIntake(config) {
|
|
|
1344
1562
|
lines.delete(line);
|
|
1345
1563
|
});
|
|
1346
1564
|
lines.set(line, next);
|
|
1565
|
+
return true;
|
|
1347
1566
|
};
|
|
1348
1567
|
const sweep = async (pendingBefore) => {
|
|
1349
1568
|
const at = now();
|
|
@@ -1351,9 +1570,22 @@ function createTelegramUpdateIntake(config) {
|
|
|
1351
1570
|
let count = 0;
|
|
1352
1571
|
for (const row of due) {
|
|
1353
1572
|
const update = parsed(row.body);
|
|
1354
|
-
if (update &&
|
|
1355
|
-
schedule(update);
|
|
1573
|
+
if (update && schedule(update))
|
|
1356
1574
|
count += 1;
|
|
1575
|
+
}
|
|
1576
|
+
if (durable) {
|
|
1577
|
+
let exhaustions = await durable.dueExhaustions({
|
|
1578
|
+
limit: SWEEP_BATCH,
|
|
1579
|
+
...exhaustionCursor !== undefined && { afterUpdateId: exhaustionCursor }
|
|
1580
|
+
});
|
|
1581
|
+
if (exhaustions.length === 0 && exhaustionCursor !== undefined) {
|
|
1582
|
+
exhaustionCursor = undefined;
|
|
1583
|
+
exhaustions = await durable.dueExhaustions({ limit: SWEEP_BATCH });
|
|
1584
|
+
}
|
|
1585
|
+
exhaustionCursor = exhaustions.at(-1)?.updateId;
|
|
1586
|
+
for (const exhaustion of exhaustions) {
|
|
1587
|
+
if (exhaustionScheduler.schedule(exhaustion, started))
|
|
1588
|
+
count += 1;
|
|
1357
1589
|
}
|
|
1358
1590
|
}
|
|
1359
1591
|
await store.prune(at - retainMs);
|
|
@@ -1371,32 +1603,66 @@ function createTelegramUpdateIntake(config) {
|
|
|
1371
1603
|
schedule(update);
|
|
1372
1604
|
return "accepted";
|
|
1373
1605
|
},
|
|
1374
|
-
|
|
1606
|
+
start() {
|
|
1607
|
+
if (starting)
|
|
1608
|
+
return starting;
|
|
1375
1609
|
if (started)
|
|
1376
|
-
return;
|
|
1610
|
+
return Promise.resolve();
|
|
1377
1611
|
started = true;
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1612
|
+
const generation = ++lifecycleGeneration;
|
|
1613
|
+
const operation = (async () => {
|
|
1614
|
+
try {
|
|
1615
|
+
await sweep(now());
|
|
1616
|
+
} catch (error) {
|
|
1617
|
+
if (started && lifecycleGeneration === generation)
|
|
1618
|
+
started = false;
|
|
1619
|
+
throw error;
|
|
1620
|
+
}
|
|
1621
|
+
if (!started || lifecycleGeneration !== generation)
|
|
1622
|
+
return;
|
|
1623
|
+
timer = setInterval(() => {
|
|
1624
|
+
if (!started || lifecycleGeneration !== generation)
|
|
1625
|
+
return;
|
|
1626
|
+
sweep(now() - pendingGraceMs).catch((error) => storeError(error, "sweep"));
|
|
1627
|
+
}, sweepEveryMs);
|
|
1628
|
+
timer.unref?.();
|
|
1629
|
+
})();
|
|
1630
|
+
starting = operation;
|
|
1631
|
+
const clearStarting = () => {
|
|
1632
|
+
if (starting === operation)
|
|
1633
|
+
starting = undefined;
|
|
1634
|
+
};
|
|
1635
|
+
operation.then(clearStarting, clearStarting);
|
|
1636
|
+
return operation;
|
|
1383
1637
|
},
|
|
1384
1638
|
sweep: () => sweep(now() - pendingGraceMs),
|
|
1385
1639
|
async idle() {
|
|
1386
|
-
while (lines.size > 0)
|
|
1387
|
-
await Promise.all(lines.values());
|
|
1640
|
+
while (lines.size > 0 || exhaustionScheduler.pending().length > 0) {
|
|
1641
|
+
await Promise.all([...lines.values(), ...exhaustionScheduler.pending()]);
|
|
1642
|
+
}
|
|
1388
1643
|
},
|
|
1389
1644
|
async close() {
|
|
1390
1645
|
started = false;
|
|
1646
|
+
lifecycleGeneration += 1;
|
|
1391
1647
|
if (timer)
|
|
1392
1648
|
clearInterval(timer);
|
|
1393
1649
|
timer = undefined;
|
|
1394
|
-
|
|
1395
|
-
|
|
1650
|
+
const pendingStart = starting;
|
|
1651
|
+
if (pendingStart) {
|
|
1652
|
+
await pendingStart.then(() => {
|
|
1653
|
+
return;
|
|
1654
|
+
}, () => {
|
|
1655
|
+
return;
|
|
1656
|
+
});
|
|
1657
|
+
}
|
|
1658
|
+
while (lines.size > 0 || exhaustionScheduler.pending().length > 0) {
|
|
1659
|
+
await Promise.all([...lines.values(), ...exhaustionScheduler.pending()]);
|
|
1660
|
+
}
|
|
1396
1661
|
}
|
|
1397
1662
|
};
|
|
1398
1663
|
}
|
|
1399
1664
|
// src/telegram/update-store.ts
|
|
1665
|
+
import { randomUUID } from "node:crypto";
|
|
1400
1666
|
function isClaimable(record, now) {
|
|
1401
1667
|
if (record.state === "pending")
|
|
1402
1668
|
return true;
|
|
@@ -1406,9 +1672,41 @@ function isClaimable(record, now) {
|
|
|
1406
1672
|
}
|
|
1407
1673
|
function memoryTelegramUpdateStore() {
|
|
1408
1674
|
const records = new Map;
|
|
1409
|
-
const holding = (updateId, attempt) => {
|
|
1675
|
+
const holding = (updateId, attempt, claimId) => {
|
|
1676
|
+
const record = records.get(updateId);
|
|
1677
|
+
return record?.state === "processing" && record.attempts === attempt && (claimId === undefined || record.claimId === claimId) ? record : undefined;
|
|
1678
|
+
};
|
|
1679
|
+
const settleCurrent = (updateId, attempt, settlement, claimId) => {
|
|
1680
|
+
const record = holding(updateId, attempt, claimId);
|
|
1681
|
+
if (!record || record.dueAt < settlement.at)
|
|
1682
|
+
return false;
|
|
1683
|
+
record.state = settlement.state;
|
|
1684
|
+
record.error = settlement.state === "completed" ? undefined : settlement.error;
|
|
1685
|
+
if (settlement.state === "failed")
|
|
1686
|
+
record.dueAt = settlement.retryAt;
|
|
1687
|
+
else
|
|
1688
|
+
record.settledAt = settlement.at;
|
|
1689
|
+
return true;
|
|
1690
|
+
};
|
|
1691
|
+
const claimOwned = (updateId, options) => {
|
|
1410
1692
|
const record = records.get(updateId);
|
|
1411
|
-
|
|
1693
|
+
if (!record || !isClaimable(record, options.now))
|
|
1694
|
+
return;
|
|
1695
|
+
if (record.attempts >= options.maxAttempts) {
|
|
1696
|
+
Object.assign(record, {
|
|
1697
|
+
state: "abandoned",
|
|
1698
|
+
dueAt: options.now,
|
|
1699
|
+
settledAt: options.durableExhaustion ? undefined : options.now,
|
|
1700
|
+
error: "attempts spent",
|
|
1701
|
+
claimId: record.claimId ?? randomUUID()
|
|
1702
|
+
});
|
|
1703
|
+
return;
|
|
1704
|
+
}
|
|
1705
|
+
record.state = "processing";
|
|
1706
|
+
record.attempts += 1;
|
|
1707
|
+
record.dueAt = options.leaseUntil;
|
|
1708
|
+
record.claimId = randomUUID();
|
|
1709
|
+
return { updateId, attempt: record.attempts, claimId: record.claimId };
|
|
1412
1710
|
};
|
|
1413
1711
|
return {
|
|
1414
1712
|
async add(update) {
|
|
@@ -1420,47 +1718,76 @@ function memoryTelegramUpdateStore() {
|
|
|
1420
1718
|
attempts: 0,
|
|
1421
1719
|
dueAt: update.receivedAt,
|
|
1422
1720
|
settledAt: undefined,
|
|
1423
|
-
error: undefined
|
|
1721
|
+
error: undefined,
|
|
1722
|
+
claimId: undefined
|
|
1424
1723
|
});
|
|
1425
1724
|
return true;
|
|
1426
1725
|
},
|
|
1427
1726
|
async claim(updateId, options) {
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
Object.assign(record, {
|
|
1433
|
-
state: "abandoned",
|
|
1434
|
-
settledAt: options.now,
|
|
1435
|
-
error: "attempts spent"
|
|
1436
|
-
});
|
|
1437
|
-
return;
|
|
1438
|
-
}
|
|
1439
|
-
record.state = "processing";
|
|
1440
|
-
record.attempts += 1;
|
|
1441
|
-
record.dueAt = options.leaseUntil;
|
|
1442
|
-
return record.attempts;
|
|
1727
|
+
return claimOwned(updateId, options)?.attempt;
|
|
1728
|
+
},
|
|
1729
|
+
async claimOwned(updateId, options) {
|
|
1730
|
+
return claimOwned(updateId, options);
|
|
1443
1731
|
},
|
|
1444
|
-
async renew(updateId, attempt, leaseUntil) {
|
|
1732
|
+
async renew(updateId, attempt, leaseUntil, at) {
|
|
1445
1733
|
const record = holding(updateId, attempt);
|
|
1446
|
-
if (record)
|
|
1447
|
-
|
|
1448
|
-
|
|
1734
|
+
if (!record || at !== undefined && record.dueAt < at)
|
|
1735
|
+
return false;
|
|
1736
|
+
record.dueAt = Math.max(record.dueAt, leaseUntil);
|
|
1737
|
+
return true;
|
|
1449
1738
|
},
|
|
1450
1739
|
async settle(updateId, attempt, settlement) {
|
|
1451
|
-
|
|
1452
|
-
if (!record)
|
|
1453
|
-
return;
|
|
1454
|
-
record.state = settlement.state;
|
|
1455
|
-
record.error = settlement.state === "completed" ? undefined : settlement.error;
|
|
1456
|
-
if (settlement.state === "failed")
|
|
1457
|
-
record.dueAt = settlement.retryAt;
|
|
1458
|
-
else
|
|
1459
|
-
record.settledAt = settlement.at;
|
|
1740
|
+
settleCurrent(updateId, attempt, settlement);
|
|
1460
1741
|
},
|
|
1461
1742
|
async due(query) {
|
|
1462
1743
|
return [...records.values()].filter((record) => record.state === "pending" ? record.receivedAt <= query.pendingBefore : isClaimable(record, query.now)).sort((left, right) => left.updateId - right.updateId).slice(0, query.limit).map(({ updateId, body }) => ({ updateId, body }));
|
|
1463
1744
|
},
|
|
1745
|
+
async owns(identity, at) {
|
|
1746
|
+
const record = holding(identity.updateId, identity.attempt, identity.claimId);
|
|
1747
|
+
return record !== undefined && record.dueAt >= at;
|
|
1748
|
+
},
|
|
1749
|
+
async renewOwned(identity, leaseUntil, at) {
|
|
1750
|
+
const record = holding(identity.updateId, identity.attempt, identity.claimId);
|
|
1751
|
+
if (!record || at !== undefined && record.dueAt < at)
|
|
1752
|
+
return false;
|
|
1753
|
+
record.dueAt = Math.max(record.dueAt, leaseUntil);
|
|
1754
|
+
return true;
|
|
1755
|
+
},
|
|
1756
|
+
async settleOwned(identity, settlement) {
|
|
1757
|
+
return settleCurrent(identity.updateId, identity.attempt, settlement, identity.claimId);
|
|
1758
|
+
},
|
|
1759
|
+
async exhaust(identity, exhaustion) {
|
|
1760
|
+
const record = holding(identity.updateId, identity.attempt, identity.claimId);
|
|
1761
|
+
if (!record || record.dueAt < exhaustion.at)
|
|
1762
|
+
return false;
|
|
1763
|
+
Object.assign(record, {
|
|
1764
|
+
state: "abandoned",
|
|
1765
|
+
dueAt: exhaustion.at,
|
|
1766
|
+
settledAt: undefined,
|
|
1767
|
+
error: exhaustion.error
|
|
1768
|
+
});
|
|
1769
|
+
return true;
|
|
1770
|
+
},
|
|
1771
|
+
async dueExhaustions(query) {
|
|
1772
|
+
return [...records.values()].filter((record) => record.state === "abandoned" && record.settledAt === undefined && (query.afterUpdateId === undefined || record.updateId > query.afterUpdateId)).sort((left, right) => left.updateId - right.updateId).slice(0, query.limit).flatMap(({ updateId, body, attempts, dueAt, error, claimId }) => claimId ? [
|
|
1773
|
+
{
|
|
1774
|
+
updateId,
|
|
1775
|
+
body,
|
|
1776
|
+
attempt: attempts,
|
|
1777
|
+
claimId,
|
|
1778
|
+
exhaustedAt: dueAt,
|
|
1779
|
+
error: error ?? "attempts spent"
|
|
1780
|
+
}
|
|
1781
|
+
] : []);
|
|
1782
|
+
},
|
|
1783
|
+
async acknowledgeExhaustion(identity, at) {
|
|
1784
|
+
const record = records.get(identity.updateId);
|
|
1785
|
+
if (record?.state !== "abandoned" || record.attempts !== identity.attempt || record.claimId !== identity.claimId || record.settledAt !== undefined) {
|
|
1786
|
+
return false;
|
|
1787
|
+
}
|
|
1788
|
+
record.settledAt = at;
|
|
1789
|
+
return true;
|
|
1790
|
+
},
|
|
1464
1791
|
async prune(before) {
|
|
1465
1792
|
let pruned = 0;
|
|
1466
1793
|
for (const [updateId, record] of records) {
|
|
@@ -1478,6 +1805,12 @@ function same(actual, expected) {
|
|
|
1478
1805
|
return JSON.stringify(actual) === JSON.stringify(expected);
|
|
1479
1806
|
}
|
|
1480
1807
|
var claim = { maxAttempts: 2 };
|
|
1808
|
+
function fenced(store) {
|
|
1809
|
+
return "claimOwned" in store && typeof store.claimOwned === "function" && "renewOwned" in store && typeof store.renewOwned === "function" && "owns" in store && typeof store.owns === "function" && "settleOwned" in store && typeof store.settleOwned === "function";
|
|
1810
|
+
}
|
|
1811
|
+
function durable(store) {
|
|
1812
|
+
return fenced(store) && "exhaust" in store && typeof store.exhaust === "function" && "dueExhaustions" in store && typeof store.dueExhaustions === "function" && "acknowledgeExhaustion" in store && typeof store.acknowledgeExhaustion === "function";
|
|
1813
|
+
}
|
|
1481
1814
|
var RULES = {
|
|
1482
1815
|
"one record per update_id": async (store, violated) => {
|
|
1483
1816
|
if (!await store.add({ updateId: 7, body: "b", receivedAt: 0 }))
|
|
@@ -1502,7 +1835,13 @@ var RULES = {
|
|
|
1502
1835
|
},
|
|
1503
1836
|
"only the attempt holding the update renews or settles it": async (store, violated) => {
|
|
1504
1837
|
await store.add({ updateId: 1, body: "b", receivedAt: 0 });
|
|
1505
|
-
|
|
1838
|
+
const owned = fenced(store) ? store : undefined;
|
|
1839
|
+
const firstIdentity = owned ? await owned.claimOwned(1, { ...claim, now: 0, leaseUntil: 10 }) : undefined;
|
|
1840
|
+
if (!owned)
|
|
1841
|
+
await store.claim(1, { ...claim, now: 0, leaseUntil: 10 });
|
|
1842
|
+
if (firstIdentity && owned && await owned.settleOwned(firstIdentity, { state: "completed", at: 11 })) {
|
|
1843
|
+
violated("an attempt whose lease expired settled the update");
|
|
1844
|
+
}
|
|
1506
1845
|
await store.claim(1, { ...claim, now: 11, leaseUntil: 50 });
|
|
1507
1846
|
if (await store.renew(1, 1, 500))
|
|
1508
1847
|
violated("an attempt that lost its lease renewed it");
|
|
@@ -1536,7 +1875,7 @@ var RULES = {
|
|
|
1536
1875
|
},
|
|
1537
1876
|
"settled updates are forgotten after they are pruned": async (store, violated) => {
|
|
1538
1877
|
await store.add({ updateId: 3, body: "b", receivedAt: 0 });
|
|
1539
|
-
await store.claim(3, { ...claim, now: 0, leaseUntil:
|
|
1878
|
+
await store.claim(3, { ...claim, now: 0, leaseUntil: 100 });
|
|
1540
1879
|
await store.settle(3, 1, { state: "abandoned", at: 36, error: "x" });
|
|
1541
1880
|
await store.add({ updateId: 4, body: "b", receivedAt: 0 });
|
|
1542
1881
|
if (await store.prune(36) !== 0)
|
|
@@ -1553,6 +1892,199 @@ var RULES = {
|
|
|
1553
1892
|
const taken = await Promise.all(Array.from({ length: 8 }, () => store.claim(9, { maxAttempts: 5, now: 1, leaseUntil: 100 })));
|
|
1554
1893
|
if (taken.filter((attempt) => attempt !== undefined).length !== 1)
|
|
1555
1894
|
violated(`${taken.filter((attempt) => attempt !== undefined).length} of 8 concurrent claims took one update`);
|
|
1895
|
+
},
|
|
1896
|
+
"of concurrent fenced claims exactly one takes the update": async (store, violated) => {
|
|
1897
|
+
if (!fenced(store))
|
|
1898
|
+
return;
|
|
1899
|
+
await store.add({ updateId: 19, body: "b", receivedAt: 0 });
|
|
1900
|
+
const taken = await Promise.all(Array.from({ length: 8 }, () => store.claimOwned(19, { maxAttempts: 5, now: 1, leaseUntil: 100 })));
|
|
1901
|
+
if (taken.filter((identity) => identity !== undefined).length !== 1) {
|
|
1902
|
+
violated(`${taken.filter((identity) => identity !== undefined).length} of 8 concurrent fenced claims took one update`);
|
|
1903
|
+
}
|
|
1904
|
+
},
|
|
1905
|
+
"renewal never shortens a live lease": async (store, violated) => {
|
|
1906
|
+
await store.add({ updateId: 18, body: "b", receivedAt: 0 });
|
|
1907
|
+
const identity = fenced(store) ? await store.claimOwned(18, { ...claim, now: 0, leaseUntil: 10 }) : undefined;
|
|
1908
|
+
if (!identity)
|
|
1909
|
+
await store.claim(18, { ...claim, now: 0, leaseUntil: 10 });
|
|
1910
|
+
if (!await store.renew(18, 1, 30, 1)) {
|
|
1911
|
+
violated("the live attempt could not extend its lease");
|
|
1912
|
+
return;
|
|
1913
|
+
}
|
|
1914
|
+
if (!await store.renew(18, 1, 20, 2)) {
|
|
1915
|
+
violated("an older live renewal was not accepted");
|
|
1916
|
+
return;
|
|
1917
|
+
}
|
|
1918
|
+
if ((await store.due({ now: 21, pendingBefore: 0, limit: 10 })).length !== 0) {
|
|
1919
|
+
violated("an older renewal shortened the current lease");
|
|
1920
|
+
}
|
|
1921
|
+
if (identity && fenced(store)) {
|
|
1922
|
+
if (!await store.renewOwned(identity, 40, 3)) {
|
|
1923
|
+
violated("the fenced attempt could not extend its lease");
|
|
1924
|
+
return;
|
|
1925
|
+
}
|
|
1926
|
+
if (!await store.renewOwned(identity, 35, 4)) {
|
|
1927
|
+
violated("an older fenced renewal was not accepted");
|
|
1928
|
+
return;
|
|
1929
|
+
}
|
|
1930
|
+
if (!await store.owns(identity, 39)) {
|
|
1931
|
+
violated("an older fenced renewal shortened the current lease");
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
},
|
|
1935
|
+
"an attempt fence expires and never adopts a later claim": async (store, violated) => {
|
|
1936
|
+
if (!fenced(store))
|
|
1937
|
+
return;
|
|
1938
|
+
await store.add({ updateId: 10, body: "b", receivedAt: 0 });
|
|
1939
|
+
const first = await store.claimOwned(10, { ...claim, now: 0, leaseUntil: 10 });
|
|
1940
|
+
if (!first) {
|
|
1941
|
+
violated("the first fenced claim was not taken");
|
|
1942
|
+
return;
|
|
1943
|
+
}
|
|
1944
|
+
if (!await store.owns(first, 10))
|
|
1945
|
+
violated("the current attempt did not own its lease at the bound");
|
|
1946
|
+
const wrongFirst = { ...first, claimId: `${first.claimId}-wrong` };
|
|
1947
|
+
if (await store.owns(wrongFirst, 10))
|
|
1948
|
+
violated("a different claim token owned the current attempt");
|
|
1949
|
+
if (await store.renewOwned(wrongFirst, 10, 0))
|
|
1950
|
+
violated("a different claim token renewed the current attempt");
|
|
1951
|
+
if (await store.owns(first, 11))
|
|
1952
|
+
violated("an expired attempt still owned its lease");
|
|
1953
|
+
if (await store.renewOwned(first, 50, 11))
|
|
1954
|
+
violated("an expired attempt resurrected its lease");
|
|
1955
|
+
const second = await store.claimOwned(10, { ...claim, now: 11, leaseUntil: 30 });
|
|
1956
|
+
if (!second) {
|
|
1957
|
+
violated("the later fenced claim was not taken");
|
|
1958
|
+
return;
|
|
1959
|
+
}
|
|
1960
|
+
if (await store.owns(first, 12))
|
|
1961
|
+
violated("the old attempt adopted the later claim");
|
|
1962
|
+
if (!await store.owns(second, 12))
|
|
1963
|
+
violated("the later attempt did not own its claim");
|
|
1964
|
+
if (!await store.settleOwned(second, { state: "completed", at: 12 }))
|
|
1965
|
+
violated("the later attempt could not settle its claim");
|
|
1966
|
+
if (await store.prune(13) !== 1) {
|
|
1967
|
+
violated("the settled fenced attempt could not be pruned");
|
|
1968
|
+
return;
|
|
1969
|
+
}
|
|
1970
|
+
await store.add({ updateId: 10, body: "new b", receivedAt: 13 });
|
|
1971
|
+
const recycled = await store.claimOwned(10, { ...claim, now: 13, leaseUntil: 30 });
|
|
1972
|
+
if (!recycled) {
|
|
1973
|
+
violated("the recycled update_id could not be claimed");
|
|
1974
|
+
return;
|
|
1975
|
+
}
|
|
1976
|
+
if (recycled.attempt !== first.attempt)
|
|
1977
|
+
violated("the recycled update_id did not restart its attempt number");
|
|
1978
|
+
const wrongRecycled = { ...recycled, claimId: `${recycled.claimId}-wrong` };
|
|
1979
|
+
if (await store.renewOwned(wrongRecycled, 40, 14))
|
|
1980
|
+
violated("a different claim token renewed a recycled update_id");
|
|
1981
|
+
if (await store.settleOwned(wrongRecycled, { state: "completed", at: 14 }))
|
|
1982
|
+
violated("a different claim token settled a recycled update_id");
|
|
1983
|
+
if (await store.owns(first, 14))
|
|
1984
|
+
violated("a pruned claim identity adopted a recycled update_id");
|
|
1985
|
+
if (await store.renewOwned(first, 40, 14))
|
|
1986
|
+
violated("a pruned claim identity renewed a recycled update_id");
|
|
1987
|
+
if (await store.settleOwned(first, { state: "completed", at: 14 }))
|
|
1988
|
+
violated("a pruned claim identity settled a recycled update_id");
|
|
1989
|
+
},
|
|
1990
|
+
"durable exhaustion survives owner loss until its exact acknowledgement": async (store, violated) => {
|
|
1991
|
+
if (!durable(store))
|
|
1992
|
+
return;
|
|
1993
|
+
await store.add({ updateId: 11, body: "crash", receivedAt: 0 });
|
|
1994
|
+
const first = await store.claimOwned(11, {
|
|
1995
|
+
now: 0,
|
|
1996
|
+
leaseUntil: 10,
|
|
1997
|
+
maxAttempts: 1,
|
|
1998
|
+
durableExhaustion: true
|
|
1999
|
+
});
|
|
2000
|
+
if (!first) {
|
|
2001
|
+
violated("the durable claim was not taken");
|
|
2002
|
+
return;
|
|
2003
|
+
}
|
|
2004
|
+
if (await store.claimOwned(11, {
|
|
2005
|
+
now: 11,
|
|
2006
|
+
leaseUntil: 20,
|
|
2007
|
+
maxAttempts: 1,
|
|
2008
|
+
durableExhaustion: true
|
|
2009
|
+
}) !== undefined) {
|
|
2010
|
+
violated("an attempts-spent update was claimed again");
|
|
2011
|
+
}
|
|
2012
|
+
const [exhaustion] = await store.dueExhaustions({ limit: 10 });
|
|
2013
|
+
if (exhaustion?.updateId !== 11 || exhaustion.attempt !== 1 || exhaustion.body !== "crash") {
|
|
2014
|
+
violated("the attempts-spent abandonment was not recoverable with its identity and body");
|
|
2015
|
+
return;
|
|
2016
|
+
}
|
|
2017
|
+
if (await store.prune(1000) !== 0)
|
|
2018
|
+
violated("prune removed an unacknowledged exhaustion");
|
|
2019
|
+
if (await store.acknowledgeExhaustion({ ...exhaustion, claimId: `${exhaustion.claimId}-wrong` }, 20)) {
|
|
2020
|
+
violated("a different claim token acknowledged the exhaustion");
|
|
2021
|
+
}
|
|
2022
|
+
if (await store.acknowledgeExhaustion({ ...exhaustion, attempt: 2 }, 20))
|
|
2023
|
+
violated("a different attempt acknowledged the exhaustion");
|
|
2024
|
+
if (!await store.acknowledgeExhaustion(exhaustion, 20))
|
|
2025
|
+
violated("the exact attempt could not acknowledge the exhaustion");
|
|
2026
|
+
if ((await store.dueExhaustions({ limit: 10 })).length !== 0)
|
|
2027
|
+
violated("an acknowledged exhaustion remained due");
|
|
2028
|
+
if (await store.prune(21) !== 1)
|
|
2029
|
+
violated("an acknowledged exhaustion could not be pruned");
|
|
2030
|
+
await store.add({ updateId: 12, body: "terminal", receivedAt: 0 });
|
|
2031
|
+
const terminal = await store.claimOwned(12, { now: 0, leaseUntil: 10, maxAttempts: 2 });
|
|
2032
|
+
if (!terminal) {
|
|
2033
|
+
violated("the terminal claim was not taken");
|
|
2034
|
+
return;
|
|
2035
|
+
}
|
|
2036
|
+
if (await store.exhaust({ ...terminal, claimId: `${terminal.claimId}-wrong` }, { at: 1, error: "wrong terminal refusal" })) {
|
|
2037
|
+
violated("a different claim token persisted terminal exhaustion");
|
|
2038
|
+
}
|
|
2039
|
+
if (!await store.exhaust(terminal, { at: 1, error: "terminal refusal" })) {
|
|
2040
|
+
violated("the holding attempt could not persist its terminal exhaustion");
|
|
2041
|
+
}
|
|
2042
|
+
if ((await store.dueExhaustions({ limit: 10 }))[0]?.error !== "terminal refusal")
|
|
2043
|
+
violated("the terminal exhaustion did not preserve its reason");
|
|
2044
|
+
await store.add({ updateId: 13, body: "expired", receivedAt: 0 });
|
|
2045
|
+
const expired = await store.claimOwned(13, { now: 0, leaseUntil: 10, maxAttempts: 2 });
|
|
2046
|
+
if (!expired) {
|
|
2047
|
+
violated("the expiring claim was not taken");
|
|
2048
|
+
return;
|
|
2049
|
+
}
|
|
2050
|
+
if (await store.exhaust(expired, { at: 11, error: "late terminal refusal" })) {
|
|
2051
|
+
violated("an expired attempt persisted a terminal exhaustion");
|
|
2052
|
+
}
|
|
2053
|
+
if ((await store.claimOwned(13, { now: 11, leaseUntil: 20, maxAttempts: 2 }))?.attempt !== 2)
|
|
2054
|
+
violated("an expired terminal attempt prevented a later claim");
|
|
2055
|
+
await store.add({ updateId: 14, body: "next terminal", receivedAt: 0 });
|
|
2056
|
+
const next = await store.claimOwned(14, { now: 0, leaseUntil: 10, maxAttempts: 2 });
|
|
2057
|
+
if (!next) {
|
|
2058
|
+
violated("the next terminal claim was not taken");
|
|
2059
|
+
return;
|
|
2060
|
+
}
|
|
2061
|
+
await store.exhaust(next, { at: 1, error: "next terminal refusal" });
|
|
2062
|
+
if (!same((await store.dueExhaustions({ limit: 10, afterUpdateId: 12 })).map(({ updateId }) => updateId), [14])) {
|
|
2063
|
+
violated("exhaustion pagination did not continue after its update_id cursor");
|
|
2064
|
+
}
|
|
2065
|
+
await store.add({ updateId: 15, body: "base durable", receivedAt: 0 });
|
|
2066
|
+
if (await store.claim(15, {
|
|
2067
|
+
now: 0,
|
|
2068
|
+
leaseUntil: 10,
|
|
2069
|
+
maxAttempts: 1,
|
|
2070
|
+
durableExhaustion: true
|
|
2071
|
+
}) !== 1) {
|
|
2072
|
+
violated("the base durable claim was not taken");
|
|
2073
|
+
return;
|
|
2074
|
+
}
|
|
2075
|
+
await store.claim(15, {
|
|
2076
|
+
now: 11,
|
|
2077
|
+
leaseUntil: 20,
|
|
2078
|
+
maxAttempts: 1,
|
|
2079
|
+
durableExhaustion: true
|
|
2080
|
+
});
|
|
2081
|
+
const baseExhaustion = (await store.dueExhaustions({ limit: 20 })).find(({ updateId }) => updateId === 15);
|
|
2082
|
+
if (!baseExhaustion) {
|
|
2083
|
+
violated("a base durable claim became an invisible exhaustion");
|
|
2084
|
+
return;
|
|
2085
|
+
}
|
|
2086
|
+
if (!await store.acknowledgeExhaustion(baseExhaustion, 20))
|
|
2087
|
+
violated("a base durable exhaustion could not be acknowledged");
|
|
1556
2088
|
}
|
|
1557
2089
|
};
|
|
1558
2090
|
async function checkTelegramUpdateStore(make) {
|
|
@@ -1567,6 +2099,7 @@ async function checkTelegramUpdateStore(make) {
|
|
|
1567
2099
|
return violations;
|
|
1568
2100
|
}
|
|
1569
2101
|
// src/telegram/update-store-postgres.ts
|
|
2102
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
1570
2103
|
var TABLE_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,62}$/;
|
|
1571
2104
|
function checkedTable(table) {
|
|
1572
2105
|
if (!TABLE_NAME.test(table)) {
|
|
@@ -1584,8 +2117,10 @@ function postgresTelegramUpdateStoreSchema(table = "telegram_updates") {
|
|
|
1584
2117
|
due_at BIGINT NOT NULL,
|
|
1585
2118
|
received_at BIGINT NOT NULL,
|
|
1586
2119
|
settled_at BIGINT,
|
|
1587
|
-
error TEXT
|
|
2120
|
+
error TEXT,
|
|
2121
|
+
claim_id TEXT
|
|
1588
2122
|
);
|
|
2123
|
+
ALTER TABLE ${name} ADD COLUMN IF NOT EXISTS claim_id TEXT;
|
|
1589
2124
|
CREATE INDEX IF NOT EXISTS ${name}_due ON ${name} (state, due_at);`;
|
|
1590
2125
|
}
|
|
1591
2126
|
var CLAIMABLE = `(state = 'pending' OR (state = 'failed' AND due_at <= $1::bigint)
|
|
@@ -1601,7 +2136,7 @@ function integer(value) {
|
|
|
1601
2136
|
function field(row, name) {
|
|
1602
2137
|
return typeof row === "object" && row !== null && name in row ? Reflect.get(row, name) : undefined;
|
|
1603
2138
|
}
|
|
1604
|
-
function
|
|
2139
|
+
function createPostgresTelegramUpdateContext(config) {
|
|
1605
2140
|
const table = checkedTable(config.table ?? "telegram_updates");
|
|
1606
2141
|
const { query } = config;
|
|
1607
2142
|
let ready;
|
|
@@ -1617,6 +2152,54 @@ function postgresTelegramUpdateStore(config) {
|
|
|
1617
2152
|
}
|
|
1618
2153
|
return query(text, parameters);
|
|
1619
2154
|
};
|
|
2155
|
+
const settleOwned = async (updateId, attempt, settlement, claimId) => {
|
|
2156
|
+
const rows = await run(`UPDATE ${table} SET state = $1::text, due_at = COALESCE($2::bigint, due_at),
|
|
2157
|
+
settled_at = $3::bigint, error = $4::text
|
|
2158
|
+
WHERE update_id = $5::bigint AND attempts = $6::integer AND state = 'processing'
|
|
2159
|
+
AND due_at >= $7::bigint
|
|
2160
|
+
AND claim_id = $8::text
|
|
2161
|
+
RETURNING update_id`, [
|
|
2162
|
+
settlement.state,
|
|
2163
|
+
settlement.state === "failed" ? settlement.retryAt : null,
|
|
2164
|
+
settlement.state === "failed" ? null : settlement.at,
|
|
2165
|
+
settlement.state === "completed" ? null : settlement.error,
|
|
2166
|
+
updateId,
|
|
2167
|
+
attempt,
|
|
2168
|
+
settlement.at,
|
|
2169
|
+
claimId
|
|
2170
|
+
]);
|
|
2171
|
+
return rows.length > 0;
|
|
2172
|
+
};
|
|
2173
|
+
const takeAttempt = async (updateId, options) => {
|
|
2174
|
+
const claimId = randomUUID2();
|
|
2175
|
+
const [row] = await run(`UPDATE ${table} SET state = 'processing', attempts = attempts + 1,
|
|
2176
|
+
due_at = $2::bigint, error = NULL, claim_id = $5::text
|
|
2177
|
+
WHERE update_id = $3::bigint AND attempts < $4::integer AND ${CLAIMABLE}
|
|
2178
|
+
RETURNING attempts, claim_id`, [options.now, options.leaseUntil, updateId, options.maxAttempts, claimId]);
|
|
2179
|
+
const attempt = integer(field(row, "attempts"));
|
|
2180
|
+
const persistedClaimId = field(row, "claim_id");
|
|
2181
|
+
if (attempt !== undefined && typeof persistedClaimId === "string") {
|
|
2182
|
+
return { updateId, attempt, claimId: persistedClaimId };
|
|
2183
|
+
}
|
|
2184
|
+
await run(`UPDATE ${table} SET state = 'abandoned', due_at = $1::bigint,
|
|
2185
|
+
settled_at = CASE WHEN $4::integer = 1 THEN NULL ELSE $1::bigint END,
|
|
2186
|
+
error = 'attempts spent', claim_id = COALESCE(claim_id, $5::text)
|
|
2187
|
+
WHERE update_id = $2::bigint AND attempts >= $3::integer AND ${CLAIMABLE}
|
|
2188
|
+
RETURNING update_id`, [options.now, updateId, options.maxAttempts, options.durableExhaustion ? 1 : 0, claimId]);
|
|
2189
|
+
return;
|
|
2190
|
+
};
|
|
2191
|
+
const renewCurrent = async (updateId, attempt, leaseUntil, at, claimId) => {
|
|
2192
|
+
const rows = await run(`UPDATE ${table} SET due_at = GREATEST(due_at, $1::bigint)
|
|
2193
|
+
WHERE update_id = $2::bigint AND attempts = $3::integer AND state = 'processing'
|
|
2194
|
+
AND ($4::bigint IS NULL OR due_at >= $4::bigint)
|
|
2195
|
+
AND claim_id = $5::text
|
|
2196
|
+
RETURNING update_id`, [leaseUntil, updateId, attempt, at ?? null, claimId]);
|
|
2197
|
+
return rows.length > 0;
|
|
2198
|
+
};
|
|
2199
|
+
return { table, run, settleOwned, takeAttempt, renewCurrent };
|
|
2200
|
+
}
|
|
2201
|
+
function postgresTelegramUpdateStore(config) {
|
|
2202
|
+
const { table, run, settleOwned, takeAttempt, renewCurrent } = createPostgresTelegramUpdateContext(config);
|
|
1620
2203
|
return {
|
|
1621
2204
|
async add(update) {
|
|
1622
2205
|
const rows = await run(`INSERT INTO ${table} (update_id, body, state, attempts, due_at, received_at)
|
|
@@ -1625,35 +2208,45 @@ function postgresTelegramUpdateStore(config) {
|
|
|
1625
2208
|
return rows.length > 0;
|
|
1626
2209
|
},
|
|
1627
2210
|
async claim(updateId, options) {
|
|
2211
|
+
if (options.durableExhaustion)
|
|
2212
|
+
return (await takeAttempt(updateId, options))?.attempt;
|
|
1628
2213
|
const [row] = await run(`UPDATE ${table} SET state = 'processing', attempts = attempts + 1,
|
|
1629
2214
|
due_at = $2::bigint, error = NULL
|
|
1630
2215
|
WHERE update_id = $3::bigint AND attempts < $4::integer AND ${CLAIMABLE}
|
|
1631
2216
|
RETURNING attempts`, [options.now, options.leaseUntil, updateId, options.maxAttempts]);
|
|
1632
|
-
const
|
|
1633
|
-
if (
|
|
1634
|
-
return
|
|
1635
|
-
await run(`UPDATE ${table} SET state = 'abandoned',
|
|
2217
|
+
const attempt = integer(field(row, "attempts"));
|
|
2218
|
+
if (attempt !== undefined)
|
|
2219
|
+
return attempt;
|
|
2220
|
+
await run(`UPDATE ${table} SET state = 'abandoned', due_at = $1::bigint,
|
|
2221
|
+
settled_at = CASE WHEN $4::integer = 1 THEN NULL ELSE $1::bigint END,
|
|
2222
|
+
error = 'attempts spent'
|
|
1636
2223
|
WHERE update_id = $2::bigint AND attempts >= $3::integer AND ${CLAIMABLE}
|
|
1637
|
-
RETURNING update_id`, [options.now, updateId, options.maxAttempts]);
|
|
2224
|
+
RETURNING update_id`, [options.now, updateId, options.maxAttempts, options.durableExhaustion ? 1 : 0]);
|
|
1638
2225
|
return;
|
|
1639
2226
|
},
|
|
1640
|
-
async
|
|
1641
|
-
|
|
2227
|
+
async claimOwned(updateId, options) {
|
|
2228
|
+
return takeAttempt(updateId, options);
|
|
2229
|
+
},
|
|
2230
|
+
async renew(updateId, attempt, leaseUntil, at) {
|
|
2231
|
+
const rows = await run(`UPDATE ${table} SET due_at = GREATEST(due_at, $1::bigint)
|
|
1642
2232
|
WHERE update_id = $2::bigint AND attempts = $3::integer AND state = 'processing'
|
|
1643
|
-
|
|
2233
|
+
AND ($4::bigint IS NULL OR due_at >= $4::bigint)
|
|
2234
|
+
RETURNING update_id`, [leaseUntil, updateId, attempt, at ?? null]);
|
|
1644
2235
|
return rows.length > 0;
|
|
1645
2236
|
},
|
|
1646
2237
|
async settle(updateId, attempt, settlement) {
|
|
1647
2238
|
await run(`UPDATE ${table} SET state = $1::text, due_at = COALESCE($2::bigint, due_at),
|
|
1648
2239
|
settled_at = $3::bigint, error = $4::text
|
|
1649
2240
|
WHERE update_id = $5::bigint AND attempts = $6::integer AND state = 'processing'
|
|
2241
|
+
AND due_at >= $7::bigint
|
|
1650
2242
|
RETURNING update_id`, [
|
|
1651
2243
|
settlement.state,
|
|
1652
2244
|
settlement.state === "failed" ? settlement.retryAt : null,
|
|
1653
2245
|
settlement.state === "failed" ? null : settlement.at,
|
|
1654
2246
|
settlement.state === "completed" ? null : settlement.error,
|
|
1655
2247
|
updateId,
|
|
1656
|
-
attempt
|
|
2248
|
+
attempt,
|
|
2249
|
+
settlement.at
|
|
1657
2250
|
]);
|
|
1658
2251
|
},
|
|
1659
2252
|
async due(options) {
|
|
@@ -1667,6 +2260,56 @@ function postgresTelegramUpdateStore(config) {
|
|
|
1667
2260
|
return updateId !== undefined && typeof body === "string" ? [{ updateId, body }] : [];
|
|
1668
2261
|
});
|
|
1669
2262
|
},
|
|
2263
|
+
async owns(identity, at) {
|
|
2264
|
+
const rows = await run(`SELECT update_id FROM ${table}
|
|
2265
|
+
WHERE update_id = $1::bigint AND attempts = $2::integer
|
|
2266
|
+
AND state = 'processing' AND due_at >= $3::bigint
|
|
2267
|
+
AND claim_id = $4::text
|
|
2268
|
+
FOR UPDATE`, [identity.updateId, identity.attempt, at, identity.claimId]);
|
|
2269
|
+
return rows.length > 0;
|
|
2270
|
+
},
|
|
2271
|
+
async renewOwned(identity, leaseUntil, at) {
|
|
2272
|
+
return renewCurrent(identity.updateId, identity.attempt, leaseUntil, at, identity.claimId);
|
|
2273
|
+
},
|
|
2274
|
+
async settleOwned(identity, settlement) {
|
|
2275
|
+
return settleOwned(identity.updateId, identity.attempt, settlement, identity.claimId);
|
|
2276
|
+
},
|
|
2277
|
+
async exhaust(identity, exhaustion) {
|
|
2278
|
+
const rows = await run(`UPDATE ${table} SET state = 'abandoned', due_at = $1::bigint,
|
|
2279
|
+
settled_at = NULL, error = $2::text
|
|
2280
|
+
WHERE update_id = $3::bigint AND attempts = $4::integer AND state = 'processing'
|
|
2281
|
+
AND due_at >= $1::bigint AND claim_id = $5::text
|
|
2282
|
+
RETURNING update_id`, [
|
|
2283
|
+
exhaustion.at,
|
|
2284
|
+
exhaustion.error,
|
|
2285
|
+
identity.updateId,
|
|
2286
|
+
identity.attempt,
|
|
2287
|
+
identity.claimId
|
|
2288
|
+
]);
|
|
2289
|
+
return rows.length > 0;
|
|
2290
|
+
},
|
|
2291
|
+
async dueExhaustions(options) {
|
|
2292
|
+
const rows = await run(`SELECT update_id, body, attempts, due_at, error, claim_id FROM ${table}
|
|
2293
|
+
WHERE state = 'abandoned' AND settled_at IS NULL
|
|
2294
|
+
AND ($2::bigint IS NULL OR update_id > $2::bigint)
|
|
2295
|
+
ORDER BY update_id LIMIT $1::integer`, [options.limit, options.afterUpdateId ?? null]);
|
|
2296
|
+
return rows.flatMap((row) => {
|
|
2297
|
+
const updateId = integer(field(row, "update_id"));
|
|
2298
|
+
const body = field(row, "body");
|
|
2299
|
+
const attempt = integer(field(row, "attempts"));
|
|
2300
|
+
const exhaustedAt = integer(field(row, "due_at"));
|
|
2301
|
+
const error = field(row, "error");
|
|
2302
|
+
const claimId = field(row, "claim_id");
|
|
2303
|
+
return updateId !== undefined && typeof body === "string" && attempt !== undefined && typeof claimId === "string" && exhaustedAt !== undefined && typeof error === "string" ? [{ updateId, body, attempt, claimId, exhaustedAt, error }] : [];
|
|
2304
|
+
});
|
|
2305
|
+
},
|
|
2306
|
+
async acknowledgeExhaustion(identity, at) {
|
|
2307
|
+
const rows = await run(`UPDATE ${table} SET settled_at = $1::bigint
|
|
2308
|
+
WHERE update_id = $2::bigint AND attempts = $3::integer
|
|
2309
|
+
AND state = 'abandoned' AND settled_at IS NULL AND claim_id = $4::text
|
|
2310
|
+
RETURNING update_id`, [at, identity.updateId, identity.attempt, identity.claimId]);
|
|
2311
|
+
return rows.length > 0;
|
|
2312
|
+
},
|
|
1670
2313
|
async prune(before) {
|
|
1671
2314
|
const rows = await run(`DELETE FROM ${table} WHERE state IN ('completed', 'abandoned') AND settled_at < $1::bigint
|
|
1672
2315
|
RETURNING update_id`, [before]);
|
|
@@ -1675,9 +2318,68 @@ function postgresTelegramUpdateStore(config) {
|
|
|
1675
2318
|
};
|
|
1676
2319
|
}
|
|
1677
2320
|
// src/telegram/update-store-sqlite.ts
|
|
2321
|
+
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
1678
2322
|
var TABLE_NAME2 = /^[A-Za-z_][A-Za-z0-9_]{0,62}$/;
|
|
1679
2323
|
var CLAIMABLE2 = `(state = 'pending' OR (state = 'failed' AND due_at <= ?1)
|
|
1680
2324
|
OR (state = 'processing' AND due_at < ?1))`;
|
|
2325
|
+
function prepareTelegramSqliteUpdateStatements(database, table) {
|
|
2326
|
+
database.exec(`CREATE TABLE IF NOT EXISTS ${table} (
|
|
2327
|
+
update_id INTEGER PRIMARY KEY,
|
|
2328
|
+
body TEXT NOT NULL,
|
|
2329
|
+
state TEXT NOT NULL,
|
|
2330
|
+
attempts INTEGER NOT NULL,
|
|
2331
|
+
due_at INTEGER NOT NULL,
|
|
2332
|
+
received_at INTEGER NOT NULL,
|
|
2333
|
+
settled_at INTEGER,
|
|
2334
|
+
error TEXT,
|
|
2335
|
+
claim_id TEXT
|
|
2336
|
+
);
|
|
2337
|
+
CREATE INDEX IF NOT EXISTS ${table}_due ON ${table} (state, due_at);`);
|
|
2338
|
+
const hasClaimId = () => database.prepare(`PRAGMA table_info(${table})`).all().some((row) => typeof row === "object" && row !== null && ("name" in row) && row.name === "claim_id");
|
|
2339
|
+
if (!hasClaimId()) {
|
|
2340
|
+
try {
|
|
2341
|
+
database.exec(`ALTER TABLE ${table} ADD COLUMN claim_id TEXT`);
|
|
2342
|
+
} catch (error) {
|
|
2343
|
+
if (!hasClaimId())
|
|
2344
|
+
throw error;
|
|
2345
|
+
}
|
|
2346
|
+
}
|
|
2347
|
+
return {
|
|
2348
|
+
insert: database.prepare(`INSERT OR IGNORE INTO ${table} (update_id, body, state, attempts, due_at, received_at)
|
|
2349
|
+
VALUES (?1, ?2, 'pending', 0, ?3, ?3)`),
|
|
2350
|
+
take: database.prepare(`UPDATE ${table} SET state = 'processing', attempts = attempts + 1, due_at = ?2,
|
|
2351
|
+
error = NULL, claim_id = ?5
|
|
2352
|
+
WHERE update_id = ?3 AND attempts < ?4 AND ${CLAIMABLE2}
|
|
2353
|
+
RETURNING attempts, claim_id`),
|
|
2354
|
+
spend: database.prepare(`UPDATE ${table} SET state = 'abandoned', due_at = ?1,
|
|
2355
|
+
settled_at = CASE WHEN ?4 = 1 THEN NULL ELSE ?1 END, error = 'attempts spent',
|
|
2356
|
+
claim_id = COALESCE(claim_id, ?5)
|
|
2357
|
+
WHERE update_id = ?2 AND attempts >= ?3 AND ${CLAIMABLE2}`),
|
|
2358
|
+
renew: database.prepare(`UPDATE ${table} SET due_at = MAX(due_at, ?1)
|
|
2359
|
+
WHERE update_id = ?2 AND attempts = ?3 AND state = 'processing'
|
|
2360
|
+
AND (?4 IS NULL OR due_at >= ?4) AND (?5 IS NULL OR claim_id = ?5)`),
|
|
2361
|
+
settle: database.prepare(`UPDATE ${table} SET state = ?1, due_at = COALESCE(?2, due_at), settled_at = ?3, error = ?4
|
|
2362
|
+
WHERE update_id = ?5 AND attempts = ?6 AND state = 'processing' AND due_at >= ?7
|
|
2363
|
+
AND (?8 IS NULL OR claim_id = ?8)`),
|
|
2364
|
+
due: database.prepare(`SELECT update_id, body FROM ${table}
|
|
2365
|
+
WHERE (state = 'pending' AND received_at <= ?2) OR (state <> 'pending' AND ${CLAIMABLE2})
|
|
2366
|
+
ORDER BY update_id LIMIT ?3`),
|
|
2367
|
+
prune: database.prepare(`DELETE FROM ${table} WHERE state IN ('completed', 'abandoned') AND settled_at < ?1`),
|
|
2368
|
+
owns: database.prepare(`SELECT update_id FROM ${table}
|
|
2369
|
+
WHERE update_id = ?1 AND attempts = ?2 AND state = 'processing' AND due_at >= ?3
|
|
2370
|
+
AND claim_id = ?4`),
|
|
2371
|
+
exhaust: database.prepare(`UPDATE ${table} SET state = 'abandoned', due_at = ?1, settled_at = NULL, error = ?2
|
|
2372
|
+
WHERE update_id = ?3 AND attempts = ?4 AND state = 'processing' AND due_at >= ?1
|
|
2373
|
+
AND claim_id = ?5`),
|
|
2374
|
+
dueExhaustions: database.prepare(`SELECT update_id, body, attempts, due_at, error, claim_id FROM ${table}
|
|
2375
|
+
WHERE state = 'abandoned' AND settled_at IS NULL
|
|
2376
|
+
AND (?2 IS NULL OR update_id > ?2)
|
|
2377
|
+
ORDER BY update_id LIMIT ?1`),
|
|
2378
|
+
acknowledgeExhaustion: database.prepare(`UPDATE ${table} SET settled_at = ?1
|
|
2379
|
+
WHERE update_id = ?2 AND attempts = ?3 AND state = 'abandoned' AND settled_at IS NULL
|
|
2380
|
+
AND claim_id = ?4`)
|
|
2381
|
+
};
|
|
2382
|
+
}
|
|
1681
2383
|
function rowOf(row) {
|
|
1682
2384
|
if (typeof row !== "object" || row === null)
|
|
1683
2385
|
return;
|
|
@@ -1685,57 +2387,85 @@ function rowOf(row) {
|
|
|
1685
2387
|
const body = "body" in row ? row.body : undefined;
|
|
1686
2388
|
return typeof updateId === "number" && typeof body === "string" ? { updateId, body } : undefined;
|
|
1687
2389
|
}
|
|
2390
|
+
function exhaustionOf(row) {
|
|
2391
|
+
if (typeof row !== "object" || row === null)
|
|
2392
|
+
return;
|
|
2393
|
+
const updateId = "update_id" in row ? row.update_id : undefined;
|
|
2394
|
+
const body = "body" in row ? row.body : undefined;
|
|
2395
|
+
const attempt = "attempts" in row ? row.attempts : undefined;
|
|
2396
|
+
const exhaustedAt = "due_at" in row ? row.due_at : undefined;
|
|
2397
|
+
const error = "error" in row ? row.error : undefined;
|
|
2398
|
+
const claimId = "claim_id" in row ? row.claim_id : undefined;
|
|
2399
|
+
return typeof updateId === "number" && typeof body === "string" && typeof attempt === "number" && typeof claimId === "string" && typeof exhaustedAt === "number" && typeof error === "string" ? { updateId, body, attempt, claimId, exhaustedAt, error } : undefined;
|
|
2400
|
+
}
|
|
1688
2401
|
function sqliteTelegramUpdateStore(config) {
|
|
1689
2402
|
const table = config.table ?? "telegram_updates";
|
|
1690
2403
|
if (!TABLE_NAME2.test(table)) {
|
|
1691
2404
|
throw new TypeError("[stitchkit] telegram update store: a table name is [A-Za-z_][A-Za-z0-9_]*");
|
|
1692
2405
|
}
|
|
1693
2406
|
const database = config.database;
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
const
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
2407
|
+
const {
|
|
2408
|
+
insert,
|
|
2409
|
+
take,
|
|
2410
|
+
spend,
|
|
2411
|
+
renew,
|
|
2412
|
+
settle,
|
|
2413
|
+
due,
|
|
2414
|
+
prune,
|
|
2415
|
+
owns,
|
|
2416
|
+
exhaust,
|
|
2417
|
+
dueExhaustions,
|
|
2418
|
+
acknowledgeExhaustion
|
|
2419
|
+
} = prepareTelegramSqliteUpdateStatements(database, table);
|
|
2420
|
+
const settleCurrent = (updateId, attempt, settlement, claimId) => Number(settle.run(settlement.state, settlement.state === "failed" ? settlement.retryAt : null, settlement.state === "failed" ? null : settlement.at, settlement.state === "completed" ? null : settlement.error, updateId, attempt, settlement.at, claimId ?? null).changes) > 0;
|
|
2421
|
+
const takeAttempt = (updateId, options) => {
|
|
2422
|
+
const claimId = randomUUID3();
|
|
2423
|
+
const [row] = take.all(options.now, options.leaseUntil, updateId, options.maxAttempts, claimId);
|
|
2424
|
+
const attempt = typeof row === "object" && row !== null && "attempts" in row ? row.attempts : undefined;
|
|
2425
|
+
const persistedClaimId = typeof row === "object" && row !== null && "claim_id" in row ? row.claim_id : undefined;
|
|
2426
|
+
if (typeof attempt === "number" && typeof persistedClaimId === "string") {
|
|
2427
|
+
return { updateId, attempt, claimId: persistedClaimId };
|
|
2428
|
+
}
|
|
2429
|
+
spend.run(options.now, updateId, options.maxAttempts, options.durableExhaustion ? 1 : 0, claimId);
|
|
2430
|
+
return;
|
|
2431
|
+
};
|
|
1718
2432
|
return {
|
|
1719
2433
|
async add(update) {
|
|
1720
2434
|
return Number(insert.run(update.updateId, update.body, update.receivedAt).changes) > 0;
|
|
1721
2435
|
},
|
|
1722
2436
|
async claim(updateId, options) {
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
spend.run(options.now, updateId, options.maxAttempts);
|
|
1728
|
-
return;
|
|
2437
|
+
return takeAttempt(updateId, options)?.attempt;
|
|
2438
|
+
},
|
|
2439
|
+
async claimOwned(updateId, options) {
|
|
2440
|
+
return takeAttempt(updateId, options);
|
|
1729
2441
|
},
|
|
1730
|
-
async renew(updateId, attempt, leaseUntil) {
|
|
1731
|
-
return Number(renew.run(leaseUntil, updateId, attempt).changes) > 0;
|
|
2442
|
+
async renew(updateId, attempt, leaseUntil, at) {
|
|
2443
|
+
return Number(renew.run(leaseUntil, updateId, attempt, at ?? null, null).changes) > 0;
|
|
1732
2444
|
},
|
|
1733
2445
|
async settle(updateId, attempt, settlement) {
|
|
1734
|
-
|
|
2446
|
+
settleCurrent(updateId, attempt, settlement);
|
|
1735
2447
|
},
|
|
1736
2448
|
async due(query) {
|
|
1737
2449
|
return due.all(query.now, query.pendingBefore, query.limit).flatMap((row) => rowOf(row) ?? []);
|
|
1738
2450
|
},
|
|
2451
|
+
async owns(identity, at) {
|
|
2452
|
+
return owns.all(identity.updateId, identity.attempt, at, identity.claimId).length > 0;
|
|
2453
|
+
},
|
|
2454
|
+
async renewOwned(identity, leaseUntil, at) {
|
|
2455
|
+
return Number(renew.run(leaseUntil, identity.updateId, identity.attempt, at ?? null, identity.claimId).changes) > 0;
|
|
2456
|
+
},
|
|
2457
|
+
async settleOwned(identity, settlement) {
|
|
2458
|
+
return settleCurrent(identity.updateId, identity.attempt, settlement, identity.claimId);
|
|
2459
|
+
},
|
|
2460
|
+
async exhaust(identity, exhaustion) {
|
|
2461
|
+
return Number(exhaust.run(exhaustion.at, exhaustion.error, identity.updateId, identity.attempt, identity.claimId).changes) > 0;
|
|
2462
|
+
},
|
|
2463
|
+
async dueExhaustions(query) {
|
|
2464
|
+
return dueExhaustions.all(query.limit, query.afterUpdateId ?? null).flatMap((row) => exhaustionOf(row) ?? []);
|
|
2465
|
+
},
|
|
2466
|
+
async acknowledgeExhaustion(identity, at) {
|
|
2467
|
+
return Number(acknowledgeExhaustion.run(at, identity.updateId, identity.attempt, identity.claimId).changes) > 0;
|
|
2468
|
+
},
|
|
1739
2469
|
async prune(before) {
|
|
1740
2470
|
return Number(prune.run(before).changes);
|
|
1741
2471
|
}
|
|
@@ -1857,6 +2587,7 @@ export {
|
|
|
1857
2587
|
TelegramBroadcastFailureSchema,
|
|
1858
2588
|
TelegramLocalFileError,
|
|
1859
2589
|
TelegramNotDispatchedError2 as TelegramNotDispatchedError,
|
|
2590
|
+
TelegramUpdateOwnershipLostError,
|
|
1860
2591
|
TelegramWebhookClaimError,
|
|
1861
2592
|
callTelegramBotApi,
|
|
1862
2593
|
checkTelegramUpdateStore,
|