@axlsdk/axl 0.26.0 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -4
- package/dist/index.cjs +1336 -238
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +508 -55
- package/dist/index.d.ts +508 -55
- package/dist/index.js +1336 -238
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.cjs
CHANGED
|
@@ -602,7 +602,7 @@ var sharedGuard = typeof installedGuard === "object" && installedGuard !== null
|
|
|
602
602
|
var buildPath = typeof __AXL_BUILD_PATH__ === "string" ? __AXL_BUILD_PATH__ : "<source>";
|
|
603
603
|
var thisCopy = {
|
|
604
604
|
path: buildPath.startsWith("file:") ? (0, import_node_url.fileURLToPath)(buildPath) : buildPath,
|
|
605
|
-
version: true ? "0.
|
|
605
|
+
version: true ? "0.27.0" : "unknown"
|
|
606
606
|
};
|
|
607
607
|
function warnCrossCopy(other, joined) {
|
|
608
608
|
if (other === thisCopy) return;
|
|
@@ -12869,8 +12869,17 @@ function detectDroppedRefinements(schema) {
|
|
|
12869
12869
|
var DOCS_URL = "docs/observability.md#schema-diagnostics";
|
|
12870
12870
|
var MAX_WARNED_KEYS = 2048;
|
|
12871
12871
|
var warnedDiagnosticKeys = /* @__PURE__ */ new Set();
|
|
12872
|
+
var silencedDepth = 0;
|
|
12873
|
+
function withDiagnosticsSilenced(fn) {
|
|
12874
|
+
silencedDepth++;
|
|
12875
|
+
try {
|
|
12876
|
+
return fn();
|
|
12877
|
+
} finally {
|
|
12878
|
+
silencedDepth--;
|
|
12879
|
+
}
|
|
12880
|
+
}
|
|
12872
12881
|
function warnDiagnosticOnce(key, message, silent, docs = DOCS_URL) {
|
|
12873
|
-
if (silent || process.env.AXL_DIAGNOSTICS_SILENT === "true") return;
|
|
12882
|
+
if (silent || silencedDepth > 0 || process.env.AXL_DIAGNOSTICS_SILENT === "true") return;
|
|
12874
12883
|
if (warnedDiagnosticKeys.has(key)) return;
|
|
12875
12884
|
if (warnedDiagnosticKeys.size >= MAX_WARNED_KEYS) warnedDiagnosticKeys.clear();
|
|
12876
12885
|
warnedDiagnosticKeys.add(key);
|
|
@@ -18934,10 +18943,135 @@ var import_node_async_hooks4 = require("async_hooks");
|
|
|
18934
18943
|
var import_node_fs3 = require("fs");
|
|
18935
18944
|
var import_node_path3 = require("path");
|
|
18936
18945
|
|
|
18946
|
+
// src/state/retention.ts
|
|
18947
|
+
var PRUNE_BATCH = 100;
|
|
18948
|
+
var DEFAULT_MEMORY_MAX_EXECUTIONS = 1e3;
|
|
18949
|
+
function warnExecutionRetentionUnset(storeName) {
|
|
18950
|
+
warnDiagnosticOnce(
|
|
18951
|
+
`execution_retention_unset\0${storeName}`,
|
|
18952
|
+
`${storeName} keeps execution history forever because no retention is set; set ttls.executionHistory${storeName === "RedisStore" ? " (or defaultTtl)" : ""} to a number of seconds, or ttls.executionHistory to null to keep it forever without this warning.`,
|
|
18953
|
+
void 0,
|
|
18954
|
+
"docs/migration/execution-history-retention.md#silencing-the-retention-warning"
|
|
18955
|
+
);
|
|
18956
|
+
}
|
|
18957
|
+
function resolveTtl(storeName, category, value) {
|
|
18958
|
+
if (value === void 0 || value === null) return null;
|
|
18959
|
+
if (typeof value !== "number") {
|
|
18960
|
+
throw new AxlError(
|
|
18961
|
+
"INVALID_CONFIG",
|
|
18962
|
+
`${storeName}: ttls.${category} must be a positive number of seconds or null; got ${typeof value}`
|
|
18963
|
+
);
|
|
18964
|
+
}
|
|
18965
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
18966
|
+
throw new AxlError(
|
|
18967
|
+
"INVALID_CONFIG",
|
|
18968
|
+
`${storeName}: ttls.${category} must be a positive number of seconds or null; got ${value}`
|
|
18969
|
+
);
|
|
18970
|
+
}
|
|
18971
|
+
return value;
|
|
18972
|
+
}
|
|
18973
|
+
function resolveHistoryTtls(storeName, ttls) {
|
|
18974
|
+
if (ttls !== void 0 && (ttls === null || typeof ttls !== "object")) {
|
|
18975
|
+
throw new AxlError(
|
|
18976
|
+
"INVALID_CONFIG",
|
|
18977
|
+
`${storeName}: ttls must be an object; got ${ttls === null ? "null" : typeof ttls}`
|
|
18978
|
+
);
|
|
18979
|
+
}
|
|
18980
|
+
return {
|
|
18981
|
+
executionHistory: resolveTtl(storeName, "executionHistory", ttls?.executionHistory),
|
|
18982
|
+
evalHistory: resolveTtl(storeName, "evalHistory", ttls?.evalHistory)
|
|
18983
|
+
};
|
|
18984
|
+
}
|
|
18985
|
+
function expiryFrom(now, ttlSeconds) {
|
|
18986
|
+
return ttlSeconds === null ? null : now + Math.round(ttlSeconds * 1e3);
|
|
18987
|
+
}
|
|
18988
|
+
function isExpired(expiresAt, now) {
|
|
18989
|
+
return expiresAt !== null && now >= expiresAt;
|
|
18990
|
+
}
|
|
18991
|
+
var DEFAULT_RETENTION_BATCH_SIZE = 500;
|
|
18992
|
+
function resolveHistoryRetentionOptions(storeName, options) {
|
|
18993
|
+
const method = `${storeName}.applyHistoryRetention()`;
|
|
18994
|
+
if (options === null || typeof options !== "object") {
|
|
18995
|
+
throw new AxlError("INVALID_ARGUMENT", `${method} options must be an object`);
|
|
18996
|
+
}
|
|
18997
|
+
const { kind, batchSize, cursor } = options;
|
|
18998
|
+
if (kind !== "executions" && kind !== "evals") {
|
|
18999
|
+
throw new AxlError(
|
|
19000
|
+
"INVALID_ARGUMENT",
|
|
19001
|
+
`${method} kind must be 'executions' or 'evals'; got ${String(kind)}`
|
|
19002
|
+
);
|
|
19003
|
+
}
|
|
19004
|
+
if (batchSize !== void 0 && !(typeof batchSize === "number" && Number.isSafeInteger(batchSize) && batchSize > 0)) {
|
|
19005
|
+
throw new AxlError(
|
|
19006
|
+
"INVALID_ARGUMENT",
|
|
19007
|
+
`${method} batchSize must be a positive integer; got ${String(batchSize)}`
|
|
19008
|
+
);
|
|
19009
|
+
}
|
|
19010
|
+
let parsedCursor;
|
|
19011
|
+
if (cursor !== void 0 && cursor !== null) {
|
|
19012
|
+
const candidate = cursor;
|
|
19013
|
+
if (typeof candidate !== "object" || typeof candidate.time !== "number" || !Number.isFinite(candidate.time) || typeof candidate.id !== "string") {
|
|
19014
|
+
throw new AxlError(
|
|
19015
|
+
"INVALID_ARGUMENT",
|
|
19016
|
+
`${method} cursor must be the { time, id } cursor returned by the previous call`
|
|
19017
|
+
);
|
|
19018
|
+
}
|
|
19019
|
+
parsedCursor = { time: candidate.time, id: candidate.id };
|
|
19020
|
+
}
|
|
19021
|
+
return {
|
|
19022
|
+
kind,
|
|
19023
|
+
batchSize: batchSize ?? DEFAULT_RETENTION_BATCH_SIZE,
|
|
19024
|
+
cursor: parsedCursor
|
|
19025
|
+
};
|
|
19026
|
+
}
|
|
19027
|
+
function retentionForUntimedRecord(lastSavedAt, ttlSeconds, now) {
|
|
19028
|
+
const expiresAt = lastSavedAt + Math.round(ttlSeconds * 1e3);
|
|
19029
|
+
return isExpired(expiresAt, now) ? { expired: true } : { expired: false, expiresAt };
|
|
19030
|
+
}
|
|
19031
|
+
function executionLastSavedAt(record) {
|
|
19032
|
+
return typeof record.completedAt === "number" && Number.isFinite(record.completedAt) ? record.completedAt : record.startedAt;
|
|
19033
|
+
}
|
|
19034
|
+
function compareExecutionsNewestFirst(a, b) {
|
|
19035
|
+
if (a.startedAt !== b.startedAt) return b.startedAt - a.startedAt;
|
|
19036
|
+
return compareCodePoints(b.executionId, a.executionId);
|
|
19037
|
+
}
|
|
19038
|
+
function compareRetentionPositions(a, b) {
|
|
19039
|
+
if (a.time !== b.time) return a.time - b.time;
|
|
19040
|
+
return compareCodePoints(a.id, b.id);
|
|
19041
|
+
}
|
|
19042
|
+
function compareCodePoints(a, b) {
|
|
19043
|
+
const len = Math.min(a.length, b.length);
|
|
19044
|
+
for (let i = 0; i < len; i++) {
|
|
19045
|
+
const x = a.charCodeAt(i);
|
|
19046
|
+
const y = b.charCodeAt(i);
|
|
19047
|
+
if (x !== y) return codePointRank(x) - codePointRank(y);
|
|
19048
|
+
}
|
|
19049
|
+
return a.length - b.length;
|
|
19050
|
+
}
|
|
19051
|
+
function codePointRank(unit) {
|
|
19052
|
+
if (unit >= 57344) return unit - 2048;
|
|
19053
|
+
if (unit >= 55296) return unit + 8192;
|
|
19054
|
+
return unit;
|
|
19055
|
+
}
|
|
19056
|
+
|
|
18937
19057
|
// src/state/memory.ts
|
|
18938
19058
|
var import_node_fs = require("fs");
|
|
18939
19059
|
var import_node_path = require("path");
|
|
18940
19060
|
var import_node_os = require("os");
|
|
19061
|
+
function compareOldestFirst(a, b) {
|
|
19062
|
+
return compareExecutionsNewestFirst(b, a);
|
|
19063
|
+
}
|
|
19064
|
+
function resolveMaxExecutions(value) {
|
|
19065
|
+
if (value === void 0) return DEFAULT_MEMORY_MAX_EXECUTIONS;
|
|
19066
|
+
if (value === Infinity) return Infinity;
|
|
19067
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
|
|
19068
|
+
throw new AxlError(
|
|
19069
|
+
"INVALID_CONFIG",
|
|
19070
|
+
`MemoryStore: maxExecutions must be a positive integer or Infinity; got ${typeof value === "number" ? value : typeof value}`
|
|
19071
|
+
);
|
|
19072
|
+
}
|
|
19073
|
+
return value;
|
|
19074
|
+
}
|
|
18941
19075
|
var AWAIT_HUMAN_TEMP_DIR = (0, import_node_path.join)((0, import_node_os.tmpdir)(), "axl-memory-store");
|
|
18942
19076
|
var AWAIT_HUMAN_TEMP_FILE = (0, import_node_path.join)(AWAIT_HUMAN_TEMP_DIR, "await-human-state.json");
|
|
18943
19077
|
var MemoryStore = class {
|
|
@@ -18947,9 +19081,32 @@ var MemoryStore = class {
|
|
|
18947
19081
|
decisions = /* @__PURE__ */ new Map();
|
|
18948
19082
|
executionStates = /* @__PURE__ */ new Map();
|
|
18949
19083
|
memories = /* @__PURE__ */ new Map();
|
|
19084
|
+
/**
|
|
19085
|
+
* Iteration order is last-save order: a save deletes and re-inserts its key.
|
|
19086
|
+
* With one retention per store and a clock that does not run backwards, that
|
|
19087
|
+
* is also expiry order, which lets the prune stop at the first live record.
|
|
19088
|
+
*/
|
|
18950
19089
|
executionHistory = /* @__PURE__ */ new Map();
|
|
19090
|
+
/** Every key in `executionHistory`, sorted oldest first in listing order. */
|
|
19091
|
+
executionOrder = [];
|
|
19092
|
+
/**
|
|
19093
|
+
* Iteration order is first-write order (a re-save keeps the key's position
|
|
19094
|
+
* and its expiry), which is also expiry order for the same reason as above.
|
|
19095
|
+
*/
|
|
18951
19096
|
evalHistory = /* @__PURE__ */ new Map();
|
|
18952
|
-
|
|
19097
|
+
ttls;
|
|
19098
|
+
maxExecutions;
|
|
19099
|
+
now;
|
|
19100
|
+
constructor(options) {
|
|
19101
|
+
this.ttls = resolveHistoryTtls("MemoryStore", options?.ttls);
|
|
19102
|
+
this.maxExecutions = resolveMaxExecutions(options?.maxExecutions);
|
|
19103
|
+
if (options?.now !== void 0 && typeof options.now !== "function") {
|
|
19104
|
+
throw new AxlError(
|
|
19105
|
+
"INVALID_CONFIG",
|
|
19106
|
+
"MemoryStore: now must be a function returning epoch milliseconds"
|
|
19107
|
+
);
|
|
19108
|
+
}
|
|
19109
|
+
this.now = options?.now ?? (() => Date.now());
|
|
18953
19110
|
this.loadPersistedState();
|
|
18954
19111
|
}
|
|
18955
19112
|
async saveCheckpoint(executionId, name, data) {
|
|
@@ -19046,23 +19203,43 @@ var MemoryStore = class {
|
|
|
19046
19203
|
}
|
|
19047
19204
|
// ── Execution History ──────────────────────────────────────────────
|
|
19048
19205
|
async saveExecution(execution) {
|
|
19049
|
-
const
|
|
19050
|
-
|
|
19051
|
-
|
|
19206
|
+
const now = this.now();
|
|
19207
|
+
const normalized = structuredClone(
|
|
19208
|
+
normalizeStoredExecution({
|
|
19209
|
+
...execution,
|
|
19210
|
+
eventSchemaVersion: getExecutionEventSchemaVersion(execution)
|
|
19211
|
+
})
|
|
19212
|
+
);
|
|
19213
|
+
this.removeExecutionRecord(normalized.executionId);
|
|
19214
|
+
this.executionHistory.set(normalized.executionId, {
|
|
19215
|
+
record: normalized,
|
|
19216
|
+
expiresAt: expiryFrom(now, this.ttls.executionHistory)
|
|
19052
19217
|
});
|
|
19053
|
-
this.
|
|
19218
|
+
this.insertExecutionOrder(normalized);
|
|
19219
|
+
this.pruneExpiredExecutions(now);
|
|
19220
|
+
this.evictOverflowExecutions();
|
|
19054
19221
|
}
|
|
19055
19222
|
async getExecution(executionId) {
|
|
19056
|
-
const
|
|
19057
|
-
|
|
19058
|
-
|
|
19059
|
-
|
|
19060
|
-
|
|
19061
|
-
|
|
19062
|
-
|
|
19223
|
+
const stored = this.executionHistory.get(executionId);
|
|
19224
|
+
if (!stored || isExpired(stored.expiresAt, this.now())) return null;
|
|
19225
|
+
return normalizeStoredExecution(structuredClone(stored.record));
|
|
19226
|
+
}
|
|
19227
|
+
/** Supports `listExecutions(limit, { cursor })`. */
|
|
19228
|
+
historyPaging = true;
|
|
19229
|
+
async listExecutions(limit, options) {
|
|
19230
|
+
const now = this.now();
|
|
19231
|
+
const max = limit ? limit : Infinity;
|
|
19232
|
+
const result = [];
|
|
19233
|
+
const start = options?.cursor !== void 0 ? this.executionOrderLowerBound(options.cursor) - 1 : this.executionOrder.length - 1;
|
|
19234
|
+
for (let i = start; i >= 0 && result.length < max; i--) {
|
|
19235
|
+
const stored = this.executionHistory.get(this.executionOrder[i].executionId);
|
|
19236
|
+
if (isExpired(stored.expiresAt, now)) continue;
|
|
19237
|
+
result.push(normalizeStoredExecution(structuredClone(stored.record)));
|
|
19238
|
+
}
|
|
19239
|
+
return result;
|
|
19063
19240
|
}
|
|
19064
19241
|
async deleteExecution(executionId) {
|
|
19065
|
-
const removedHistory = this.
|
|
19242
|
+
const removedHistory = this.removeExecutionRecord(executionId);
|
|
19066
19243
|
const removedCheckpoints = this.checkpoints.delete(executionId);
|
|
19067
19244
|
const removedState = this.executionStates.delete(executionId);
|
|
19068
19245
|
const removedStreaming = this.streamingEvents.delete(executionId);
|
|
@@ -19073,26 +19250,193 @@ var MemoryStore = class {
|
|
|
19073
19250
|
return removedHistory || removedCheckpoints || removedState || removedStreaming || removedDecision;
|
|
19074
19251
|
}
|
|
19075
19252
|
// ── Eval History ──────────────────────────────────────────────────
|
|
19253
|
+
/**
|
|
19254
|
+
* Write an eval history row. Retention counts from the row's first write:
|
|
19255
|
+
* re-saving a live row keeps its expiry (and its position in expiry order).
|
|
19256
|
+
* A row that has already expired is replaced as a new one.
|
|
19257
|
+
*/
|
|
19076
19258
|
async saveEvalResult(entry) {
|
|
19077
|
-
this.
|
|
19259
|
+
const now = this.now();
|
|
19260
|
+
const existing = this.evalHistory.get(entry.id);
|
|
19261
|
+
if (existing && !isExpired(existing.expiresAt, now)) {
|
|
19262
|
+
this.evalHistory.set(entry.id, {
|
|
19263
|
+
entry: structuredClone(entry),
|
|
19264
|
+
expiresAt: existing.expiresAt
|
|
19265
|
+
});
|
|
19266
|
+
} else {
|
|
19267
|
+
this.evalHistory.delete(entry.id);
|
|
19268
|
+
this.evalHistory.set(entry.id, {
|
|
19269
|
+
entry: structuredClone(entry),
|
|
19270
|
+
expiresAt: expiryFrom(now, this.ttls.evalHistory)
|
|
19271
|
+
});
|
|
19272
|
+
}
|
|
19273
|
+
this.pruneExpiredEvals(now);
|
|
19078
19274
|
}
|
|
19079
|
-
/** Update-only write: see `StateStore.updateEvalResult`. */
|
|
19275
|
+
/** Update-only write: see `StateStore.updateEvalResult`. Never changes retention. */
|
|
19080
19276
|
async updateEvalResult(entry) {
|
|
19081
|
-
|
|
19082
|
-
|
|
19277
|
+
const existing = this.evalHistory.get(entry.id);
|
|
19278
|
+
if (!existing || isExpired(existing.expiresAt, this.now())) return false;
|
|
19279
|
+
this.evalHistory.set(entry.id, {
|
|
19280
|
+
entry: structuredClone(entry),
|
|
19281
|
+
expiresAt: existing.expiresAt
|
|
19282
|
+
});
|
|
19083
19283
|
return true;
|
|
19084
19284
|
}
|
|
19085
19285
|
async listEvalResults(limit) {
|
|
19086
|
-
const
|
|
19286
|
+
const now = this.now();
|
|
19287
|
+
const sorted = [...this.evalHistory.values()].filter((stored) => !isExpired(stored.expiresAt, now)).map((stored) => stored.entry).sort((a, b) => b.timestamp - a.timestamp);
|
|
19087
19288
|
const result = limit ? sorted.slice(0, limit) : sorted;
|
|
19088
19289
|
return result.map((e) => structuredClone(e));
|
|
19089
19290
|
}
|
|
19090
19291
|
async deleteEvalResult(id) {
|
|
19091
19292
|
return this.evalHistory.delete(id);
|
|
19092
19293
|
}
|
|
19093
|
-
/**
|
|
19294
|
+
/** Existence plus the expiry stored at the row's first write, if any. */
|
|
19094
19295
|
async getEvalRetention(id) {
|
|
19095
|
-
|
|
19296
|
+
const stored = this.evalHistory.get(id);
|
|
19297
|
+
if (!stored || isExpired(stored.expiresAt, this.now())) return { exists: false };
|
|
19298
|
+
return stored.expiresAt === null ? { exists: true } : { exists: true, expiresAt: stored.expiresAt };
|
|
19299
|
+
}
|
|
19300
|
+
// ── History maintenance ───────────────────────────────────────────
|
|
19301
|
+
/**
|
|
19302
|
+
* See `StateStore.applyHistoryRetention`. Every record this store holds was
|
|
19303
|
+
* saved under the retention it was constructed with, so in practice a run
|
|
19304
|
+
* finds nothing to expire or arm; the method exists so the same maintenance
|
|
19305
|
+
* code works against every built-in store. There is no separate index, so
|
|
19306
|
+
* `indexed` and `deadIndexEntries` are always `0`.
|
|
19307
|
+
*
|
|
19308
|
+
* An armed record keeps its place in last-save order, which is also where
|
|
19309
|
+
* its expiry (last save + retention) belongs, so the prune-on-save order
|
|
19310
|
+
* still holds.
|
|
19311
|
+
*/
|
|
19312
|
+
async applyHistoryRetention(options) {
|
|
19313
|
+
const { kind, batchSize, cursor } = resolveHistoryRetentionOptions("MemoryStore", options);
|
|
19314
|
+
const now = this.now();
|
|
19315
|
+
let expired = 0;
|
|
19316
|
+
let armed = 0;
|
|
19317
|
+
let positions;
|
|
19318
|
+
if (kind === "executions") {
|
|
19319
|
+
let start = 0;
|
|
19320
|
+
if (cursor !== void 0) {
|
|
19321
|
+
const key = { startedAt: cursor.time, executionId: cursor.id };
|
|
19322
|
+
start = this.executionOrderLowerBound(key);
|
|
19323
|
+
const at = this.executionOrder[start];
|
|
19324
|
+
if (at !== void 0 && compareOldestFirst(at, key) === 0) start++;
|
|
19325
|
+
}
|
|
19326
|
+
positions = this.executionOrder.slice(start, start + batchSize + 1).map((k) => ({ time: k.startedAt, id: k.executionId }));
|
|
19327
|
+
} else {
|
|
19328
|
+
positions = [...this.evalHistory.values()].map((stored) => ({ time: stored.entry.timestamp, id: stored.entry.id })).filter((p) => cursor === void 0 || compareRetentionPositions(cursor, p) < 0).sort(compareRetentionPositions).slice(0, batchSize + 1);
|
|
19329
|
+
}
|
|
19330
|
+
const batch = positions.slice(0, batchSize);
|
|
19331
|
+
const ttl = kind === "executions" ? this.ttls.executionHistory : this.ttls.evalHistory;
|
|
19332
|
+
if (ttl !== null) {
|
|
19333
|
+
for (const { id } of batch) {
|
|
19334
|
+
if (kind === "executions") {
|
|
19335
|
+
const stored = this.executionHistory.get(id);
|
|
19336
|
+
if (!stored || stored.expiresAt !== null) continue;
|
|
19337
|
+
const decision = retentionForUntimedRecord(executionLastSavedAt(stored.record), ttl, now);
|
|
19338
|
+
if (decision.expired) {
|
|
19339
|
+
this.expireExecutionRecord(id);
|
|
19340
|
+
expired++;
|
|
19341
|
+
} else {
|
|
19342
|
+
stored.expiresAt = decision.expiresAt;
|
|
19343
|
+
armed++;
|
|
19344
|
+
}
|
|
19345
|
+
} else {
|
|
19346
|
+
const stored = this.evalHistory.get(id);
|
|
19347
|
+
if (!stored || stored.expiresAt !== null) continue;
|
|
19348
|
+
const decision = retentionForUntimedRecord(stored.entry.timestamp, ttl, now);
|
|
19349
|
+
if (decision.expired) {
|
|
19350
|
+
this.evalHistory.delete(id);
|
|
19351
|
+
expired++;
|
|
19352
|
+
} else {
|
|
19353
|
+
stored.expiresAt = decision.expiresAt;
|
|
19354
|
+
armed++;
|
|
19355
|
+
}
|
|
19356
|
+
}
|
|
19357
|
+
}
|
|
19358
|
+
}
|
|
19359
|
+
return {
|
|
19360
|
+
expired,
|
|
19361
|
+
armed,
|
|
19362
|
+
indexed: 0,
|
|
19363
|
+
deadIndexEntries: 0,
|
|
19364
|
+
cursor: positions.length > batchSize ? batch[batch.length - 1] : null
|
|
19365
|
+
};
|
|
19366
|
+
}
|
|
19367
|
+
// ── History retention internals ───────────────────────────────────
|
|
19368
|
+
/** Index of the first order key not older than `key` (binary search). */
|
|
19369
|
+
executionOrderLowerBound(key) {
|
|
19370
|
+
let lo = 0;
|
|
19371
|
+
let hi = this.executionOrder.length;
|
|
19372
|
+
while (lo < hi) {
|
|
19373
|
+
const mid = lo + hi >>> 1;
|
|
19374
|
+
if (compareOldestFirst(this.executionOrder[mid], key) < 0) lo = mid + 1;
|
|
19375
|
+
else hi = mid;
|
|
19376
|
+
}
|
|
19377
|
+
return lo;
|
|
19378
|
+
}
|
|
19379
|
+
insertExecutionOrder(record) {
|
|
19380
|
+
const key = { startedAt: record.startedAt, executionId: record.executionId };
|
|
19381
|
+
this.executionOrder.splice(this.executionOrderLowerBound(key), 0, key);
|
|
19382
|
+
}
|
|
19383
|
+
/** Remove a history record and its listing-index key. Returns whether one existed. */
|
|
19384
|
+
removeExecutionRecord(executionId) {
|
|
19385
|
+
const stored = this.executionHistory.get(executionId);
|
|
19386
|
+
if (!stored) return false;
|
|
19387
|
+
this.executionHistory.delete(executionId);
|
|
19388
|
+
const index = this.executionOrderLowerBound(stored.record);
|
|
19389
|
+
if (this.executionOrder[index]?.executionId === executionId) {
|
|
19390
|
+
this.executionOrder.splice(index, 1);
|
|
19391
|
+
}
|
|
19392
|
+
return true;
|
|
19393
|
+
}
|
|
19394
|
+
/**
|
|
19395
|
+
* A run leaving history through retention (expiry or `maxExecutions`) takes
|
|
19396
|
+
* its leftover per-run data with it: checkpoints (kept today when a run
|
|
19397
|
+
* fails), saved execution state and any streaming buffer. Pending decisions
|
|
19398
|
+
* are not touched. The runtime never writes execution state, and a run is
|
|
19399
|
+
* only saved to history once it has finished, so nothing still running
|
|
19400
|
+
* depends on what is removed here.
|
|
19401
|
+
*/
|
|
19402
|
+
expireExecutionRecord(executionId) {
|
|
19403
|
+
this.removeExecutionRecord(executionId);
|
|
19404
|
+
this.checkpoints.delete(executionId);
|
|
19405
|
+
const state = this.executionStates.get(executionId);
|
|
19406
|
+
this.executionStates.delete(executionId);
|
|
19407
|
+
this.streamingEvents.delete(executionId);
|
|
19408
|
+
if (state?.status === "waiting") this.persistAwaitHumanState();
|
|
19409
|
+
}
|
|
19410
|
+
/**
|
|
19411
|
+
* Remove up to `PRUNE_BATCH` expired execution records, oldest save first.
|
|
19412
|
+
* Stops at the first live record: Map order is last-save order, which is
|
|
19413
|
+
* expiry order while the clock moves forward. If the clock steps back, a
|
|
19414
|
+
* few expired records wait for a later save; reads still filter them.
|
|
19415
|
+
*/
|
|
19416
|
+
pruneExpiredExecutions(now) {
|
|
19417
|
+
if (this.ttls.executionHistory === null) return;
|
|
19418
|
+
let removed = 0;
|
|
19419
|
+
for (const [id, stored] of this.executionHistory) {
|
|
19420
|
+
if (removed >= PRUNE_BATCH || !isExpired(stored.expiresAt, now)) break;
|
|
19421
|
+
this.expireExecutionRecord(id);
|
|
19422
|
+
removed++;
|
|
19423
|
+
}
|
|
19424
|
+
}
|
|
19425
|
+
/** Evict the oldest records in listing order until the count bound holds. */
|
|
19426
|
+
evictOverflowExecutions() {
|
|
19427
|
+
while (this.executionHistory.size > this.maxExecutions) {
|
|
19428
|
+
this.expireExecutionRecord(this.executionOrder[0].executionId);
|
|
19429
|
+
}
|
|
19430
|
+
}
|
|
19431
|
+
/** Remove up to `PRUNE_BATCH` expired eval rows, oldest first write first. */
|
|
19432
|
+
pruneExpiredEvals(now) {
|
|
19433
|
+
if (this.ttls.evalHistory === null) return;
|
|
19434
|
+
let removed = 0;
|
|
19435
|
+
for (const [id, stored] of this.evalHistory) {
|
|
19436
|
+
if (removed >= PRUNE_BATCH || !isExpired(stored.expiresAt, now)) break;
|
|
19437
|
+
this.evalHistory.delete(id);
|
|
19438
|
+
removed++;
|
|
19439
|
+
}
|
|
19096
19440
|
}
|
|
19097
19441
|
// ── Sessions (Studio introspection) ─────────────────────────────────
|
|
19098
19442
|
async listSessions() {
|
|
@@ -19170,6 +19514,7 @@ var MemoryStore = class {
|
|
|
19170
19514
|
};
|
|
19171
19515
|
|
|
19172
19516
|
// src/state/sqlite.ts
|
|
19517
|
+
var LIVE = "(expires_at IS NULL OR expires_at > ?)";
|
|
19173
19518
|
function safeJsonParse(data) {
|
|
19174
19519
|
try {
|
|
19175
19520
|
return JSON.parse(data);
|
|
@@ -19206,7 +19551,17 @@ function rowToExecutionInfo(row) {
|
|
|
19206
19551
|
}
|
|
19207
19552
|
var SQLiteStore = class {
|
|
19208
19553
|
db;
|
|
19209
|
-
|
|
19554
|
+
ttls;
|
|
19555
|
+
now;
|
|
19556
|
+
constructor(filePath, options) {
|
|
19557
|
+
this.ttls = resolveHistoryTtls("SQLiteStore", options?.ttls);
|
|
19558
|
+
if (options?.now !== void 0 && typeof options.now !== "function") {
|
|
19559
|
+
throw new AxlError(
|
|
19560
|
+
"INVALID_CONFIG",
|
|
19561
|
+
"SQLiteStore: now must be a function returning epoch milliseconds"
|
|
19562
|
+
);
|
|
19563
|
+
}
|
|
19564
|
+
this.now = options?.now ?? (() => Date.now());
|
|
19210
19565
|
let BetterSqlite3;
|
|
19211
19566
|
try {
|
|
19212
19567
|
BetterSqlite3 = require("better-sqlite3");
|
|
@@ -19219,6 +19574,7 @@ var SQLiteStore = class {
|
|
|
19219
19574
|
this.db.pragma("journal_mode = WAL");
|
|
19220
19575
|
this.migrate();
|
|
19221
19576
|
this.initTables();
|
|
19577
|
+
if (options?.ttls?.executionHistory === void 0) warnExecutionRetentionUnset("SQLiteStore");
|
|
19222
19578
|
}
|
|
19223
19579
|
/**
|
|
19224
19580
|
* Idempotent schema migration. Tracks version via `PRAGMA user_version`
|
|
@@ -19246,6 +19602,11 @@ var SQLiteStore = class {
|
|
|
19246
19602
|
* Existing rows remain `NULL`, the documented v1 sentinel.
|
|
19247
19603
|
* - v5 (observation completeness): add `execution_history.observation`.
|
|
19248
19604
|
* Existing rows remain `NULL`, meaning they predate the contract.
|
|
19605
|
+
* - v6 (history retention): add nullable `expires_at` (epoch ms) to
|
|
19606
|
+
* `execution_history` and `eval_history`. Existing rows remain `NULL`
|
|
19607
|
+
* (never expire), so an upgrade deletes nothing. The single-column
|
|
19608
|
+
* `started_at` index is replaced by `(started_at, execution_id)`, which
|
|
19609
|
+
* serves the tie-safe listing order.
|
|
19249
19610
|
*
|
|
19250
19611
|
* Applied BEFORE `initTables()` so the subsequent `CREATE TABLE
|
|
19251
19612
|
* IF NOT EXISTS` runs against the post-migration column name. Fresh
|
|
@@ -19263,7 +19624,7 @@ var SQLiteStore = class {
|
|
|
19263
19624
|
* transactional re-read costs nothing.
|
|
19264
19625
|
*/
|
|
19265
19626
|
migrate() {
|
|
19266
|
-
const TARGET_VERSION =
|
|
19627
|
+
const TARGET_VERSION = 6;
|
|
19267
19628
|
const current = this.db.pragma("user_version", { simple: true });
|
|
19268
19629
|
if (current >= TARGET_VERSION) return;
|
|
19269
19630
|
this.db.exec("BEGIN IMMEDIATE");
|
|
@@ -19303,6 +19664,15 @@ var SQLiteStore = class {
|
|
|
19303
19664
|
this.db.exec("ALTER TABLE execution_history ADD COLUMN observation TEXT");
|
|
19304
19665
|
}
|
|
19305
19666
|
}
|
|
19667
|
+
if (committed < 6) {
|
|
19668
|
+
for (const table of ["execution_history", "eval_history"]) {
|
|
19669
|
+
const cols = this.db.pragma(`table_info(${table})`);
|
|
19670
|
+
if (cols.length > 0 && !cols.some((c) => c.name === "expires_at")) {
|
|
19671
|
+
this.db.exec(`ALTER TABLE ${table} ADD COLUMN expires_at INTEGER`);
|
|
19672
|
+
}
|
|
19673
|
+
}
|
|
19674
|
+
this.db.exec("DROP INDEX IF EXISTS idx_exec_history_started");
|
|
19675
|
+
}
|
|
19306
19676
|
this.db.pragma(`user_version = ${TARGET_VERSION}`);
|
|
19307
19677
|
this.db.exec("COMMIT");
|
|
19308
19678
|
} catch (err) {
|
|
@@ -19368,18 +19738,28 @@ var SQLiteStore = class {
|
|
|
19368
19738
|
events TEXT NOT NULL,
|
|
19369
19739
|
metadata TEXT,
|
|
19370
19740
|
event_schema_version INTEGER,
|
|
19371
|
-
observation TEXT
|
|
19741
|
+
observation TEXT,
|
|
19742
|
+
expires_at INTEGER
|
|
19372
19743
|
);
|
|
19373
19744
|
|
|
19374
19745
|
CREATE TABLE IF NOT EXISTS eval_history (
|
|
19375
19746
|
id TEXT PRIMARY KEY,
|
|
19376
19747
|
eval_name TEXT NOT NULL,
|
|
19377
19748
|
timestamp INTEGER NOT NULL,
|
|
19378
|
-
data TEXT NOT NULL
|
|
19749
|
+
data TEXT NOT NULL,
|
|
19750
|
+
expires_at INTEGER
|
|
19379
19751
|
);
|
|
19380
19752
|
|
|
19381
|
-
CREATE INDEX IF NOT EXISTS
|
|
19753
|
+
CREATE INDEX IF NOT EXISTS idx_exec_history_started_id
|
|
19754
|
+
ON execution_history (started_at DESC, execution_id DESC);
|
|
19755
|
+
CREATE INDEX IF NOT EXISTS idx_exec_history_expires ON execution_history (expires_at);
|
|
19382
19756
|
CREATE INDEX IF NOT EXISTS idx_eval_history_timestamp ON eval_history (timestamp DESC);
|
|
19757
|
+
CREATE INDEX IF NOT EXISTS idx_eval_history_expires ON eval_history (expires_at);
|
|
19758
|
+
|
|
19759
|
+
-- Superseded by idx_exec_history_started_id in v6. Dropped here as well as in
|
|
19760
|
+
-- migrate(): a pre-v6 process reopening a v6 file re-creates it, and
|
|
19761
|
+
-- migrate() never runs again once user_version is 6.
|
|
19762
|
+
DROP INDEX IF EXISTS idx_exec_history_started;
|
|
19383
19763
|
`);
|
|
19384
19764
|
}
|
|
19385
19765
|
// ── Checkpoints ────────────────────────────────────────────────────────
|
|
@@ -19492,30 +19872,71 @@ var SQLiteStore = class {
|
|
|
19492
19872
|
...execution,
|
|
19493
19873
|
eventSchemaVersion: getExecutionEventSchemaVersion(execution)
|
|
19494
19874
|
});
|
|
19875
|
+
const now = this.now();
|
|
19876
|
+
this.db.transaction(() => {
|
|
19877
|
+
this.db.prepare(
|
|
19878
|
+
"INSERT OR REPLACE INTO execution_history (execution_id, workflow, status, total_cost, started_at, completed_at, duration, error, events, metadata, event_schema_version, observation, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
|
19879
|
+
).run(
|
|
19880
|
+
normalized.executionId,
|
|
19881
|
+
normalized.workflow,
|
|
19882
|
+
normalized.status,
|
|
19883
|
+
normalized.totalCost,
|
|
19884
|
+
normalized.startedAt,
|
|
19885
|
+
normalized.completedAt ?? null,
|
|
19886
|
+
normalized.duration,
|
|
19887
|
+
normalized.error ?? null,
|
|
19888
|
+
JSON.stringify(normalized.events),
|
|
19889
|
+
normalized.metadata !== void 0 ? JSON.stringify(normalized.metadata) : null,
|
|
19890
|
+
normalized.eventSchemaVersion,
|
|
19891
|
+
normalized.observation !== void 0 ? JSON.stringify(normalized.observation) : null,
|
|
19892
|
+
expiryFrom(now, this.ttls.executionHistory)
|
|
19893
|
+
);
|
|
19894
|
+
this.pruneExpiredExecutions(now);
|
|
19895
|
+
})();
|
|
19896
|
+
}
|
|
19897
|
+
/**
|
|
19898
|
+
* Remove up to `PRUNE_BATCH` expired execution-history rows, together with
|
|
19899
|
+
* each run's leftover checkpoints and saved execution state, inside the
|
|
19900
|
+
* caller's transaction. Pending decisions are not touched. The runtime
|
|
19901
|
+
* never writes execution state, and a run is only saved to history once it
|
|
19902
|
+
* has finished, so nothing still running depends on what is removed here.
|
|
19903
|
+
*/
|
|
19904
|
+
pruneExpiredExecutions(now) {
|
|
19905
|
+
const ids = this.db.prepare(
|
|
19906
|
+
"SELECT execution_id FROM execution_history WHERE expires_at <= ? ORDER BY expires_at LIMIT ?"
|
|
19907
|
+
).all(now, PRUNE_BATCH).map((row) => row.execution_id);
|
|
19908
|
+
if (ids.length === 0) return;
|
|
19909
|
+
const idList = JSON.stringify(ids);
|
|
19910
|
+
for (const table of ["execution_history", "checkpoints", "execution_state"]) {
|
|
19911
|
+
this.db.prepare(`DELETE FROM ${table} WHERE execution_id IN (SELECT value FROM json_each(?))`).run(idList);
|
|
19912
|
+
}
|
|
19913
|
+
}
|
|
19914
|
+
/** Remove up to `PRUNE_BATCH` expired eval-history rows (caller's transaction). */
|
|
19915
|
+
pruneExpiredEvals(now) {
|
|
19495
19916
|
this.db.prepare(
|
|
19496
|
-
"
|
|
19497
|
-
).run(
|
|
19498
|
-
normalized.executionId,
|
|
19499
|
-
normalized.workflow,
|
|
19500
|
-
normalized.status,
|
|
19501
|
-
normalized.totalCost,
|
|
19502
|
-
normalized.startedAt,
|
|
19503
|
-
normalized.completedAt ?? null,
|
|
19504
|
-
normalized.duration,
|
|
19505
|
-
normalized.error ?? null,
|
|
19506
|
-
JSON.stringify(normalized.events),
|
|
19507
|
-
normalized.metadata !== void 0 ? JSON.stringify(normalized.metadata) : null,
|
|
19508
|
-
normalized.eventSchemaVersion,
|
|
19509
|
-
normalized.observation !== void 0 ? JSON.stringify(normalized.observation) : null
|
|
19510
|
-
);
|
|
19917
|
+
"DELETE FROM eval_history WHERE rowid IN (SELECT rowid FROM eval_history WHERE expires_at <= ? ORDER BY expires_at LIMIT ?)"
|
|
19918
|
+
).run(now, PRUNE_BATCH);
|
|
19511
19919
|
}
|
|
19512
19920
|
async getExecution(executionId) {
|
|
19513
|
-
const row = this.db.prepare(
|
|
19921
|
+
const row = this.db.prepare(`SELECT * FROM execution_history WHERE execution_id = ? AND ${LIVE}`).get(executionId, this.now());
|
|
19514
19922
|
return row ? rowToExecutionInfo(row) : null;
|
|
19515
19923
|
}
|
|
19516
|
-
|
|
19517
|
-
|
|
19518
|
-
|
|
19924
|
+
/** Supports `listExecutions(limit, { cursor })`. */
|
|
19925
|
+
historyPaging = true;
|
|
19926
|
+
async listExecutions(limit, options) {
|
|
19927
|
+
const cursor = options?.cursor;
|
|
19928
|
+
const params = [this.now()];
|
|
19929
|
+
let sql = `SELECT * FROM execution_history WHERE ${LIVE}`;
|
|
19930
|
+
if (cursor !== void 0) {
|
|
19931
|
+
sql += " AND (started_at < ? OR (started_at = ? AND execution_id < ?))";
|
|
19932
|
+
params.push(cursor.startedAt, cursor.startedAt, cursor.executionId);
|
|
19933
|
+
}
|
|
19934
|
+
sql += " ORDER BY started_at DESC, execution_id DESC";
|
|
19935
|
+
if (limit) {
|
|
19936
|
+
sql += " LIMIT ?";
|
|
19937
|
+
params.push(limit);
|
|
19938
|
+
}
|
|
19939
|
+
const rows = this.db.prepare(sql).all(...params);
|
|
19519
19940
|
return rows.map(rowToExecutionInfo);
|
|
19520
19941
|
}
|
|
19521
19942
|
async deleteExecution(executionId) {
|
|
@@ -19529,24 +19950,38 @@ var SQLiteStore = class {
|
|
|
19529
19950
|
return tx();
|
|
19530
19951
|
}
|
|
19531
19952
|
// ── Eval History ────────────────────────────────────────────────────────
|
|
19953
|
+
/**
|
|
19954
|
+
* Write an eval history row. Retention counts from the row's first write:
|
|
19955
|
+
* re-saving a live row keeps its `expires_at` (including none, for a row
|
|
19956
|
+
* written without retention). A row that has already expired is replaced
|
|
19957
|
+
* as a new one.
|
|
19958
|
+
*/
|
|
19532
19959
|
async saveEvalResult(entry) {
|
|
19533
|
-
this.
|
|
19534
|
-
|
|
19535
|
-
|
|
19960
|
+
const now = this.now();
|
|
19961
|
+
this.db.transaction(() => {
|
|
19962
|
+
const existing = this.db.prepare(`SELECT expires_at FROM eval_history WHERE id = ? AND ${LIVE}`).get(entry.id, now);
|
|
19963
|
+
const expiresAt = existing ? existing.expires_at : expiryFrom(now, this.ttls.evalHistory);
|
|
19964
|
+
this.db.prepare(
|
|
19965
|
+
"INSERT OR REPLACE INTO eval_history (id, eval_name, timestamp, data, expires_at) VALUES (?, ?, ?, ?, ?)"
|
|
19966
|
+
).run(entry.id, entry.eval, entry.timestamp, JSON.stringify(entry.data), expiresAt);
|
|
19967
|
+
this.pruneExpiredEvals(now);
|
|
19968
|
+
})();
|
|
19536
19969
|
}
|
|
19537
19970
|
/**
|
|
19538
19971
|
* Update-only write: see `StateStore.updateEvalResult`. `UPDATE ... WHERE id`
|
|
19539
|
-
* touches nothing when the row is gone, where `INSERT OR REPLACE`
|
|
19540
|
-
* resurrect it
|
|
19541
|
-
* permanent.
|
|
19972
|
+
* touches nothing when the row is gone or expired, where `INSERT OR REPLACE`
|
|
19973
|
+
* would resurrect it. `expires_at` is left as it was.
|
|
19542
19974
|
*/
|
|
19543
19975
|
async updateEvalResult(entry) {
|
|
19544
|
-
const result = this.db.prepare(
|
|
19976
|
+
const result = this.db.prepare(
|
|
19977
|
+
`UPDATE eval_history SET eval_name = ?, timestamp = ?, data = ? WHERE id = ? AND ${LIVE}`
|
|
19978
|
+
).run(entry.eval, entry.timestamp, JSON.stringify(entry.data), entry.id, this.now());
|
|
19545
19979
|
return result.changes > 0;
|
|
19546
19980
|
}
|
|
19547
19981
|
async listEvalResults(limit) {
|
|
19548
|
-
const
|
|
19549
|
-
const
|
|
19982
|
+
const base = `SELECT * FROM eval_history WHERE ${LIVE} ORDER BY timestamp DESC`;
|
|
19983
|
+
const now = this.now();
|
|
19984
|
+
const rows = limit ? this.db.prepare(`${base} LIMIT ?`).all(now, limit) : this.db.prepare(base).all(now);
|
|
19550
19985
|
return rows.map((r) => ({
|
|
19551
19986
|
id: r.id,
|
|
19552
19987
|
eval: r.eval_name,
|
|
@@ -19558,10 +19993,75 @@ var SQLiteStore = class {
|
|
|
19558
19993
|
const result = this.db.prepare("DELETE FROM eval_history WHERE id = ?").run(id);
|
|
19559
19994
|
return result.changes > 0;
|
|
19560
19995
|
}
|
|
19561
|
-
/**
|
|
19996
|
+
/** Existence plus the expiry stored at the row's first write, if any. */
|
|
19562
19997
|
async getEvalRetention(id) {
|
|
19563
|
-
const row = this.db.prepare(
|
|
19564
|
-
return { exists:
|
|
19998
|
+
const row = this.db.prepare(`SELECT expires_at FROM eval_history WHERE id = ? AND ${LIVE}`).get(id, this.now());
|
|
19999
|
+
if (!row) return { exists: false };
|
|
20000
|
+
return row.expires_at === null ? { exists: true } : { exists: true, expiresAt: row.expires_at };
|
|
20001
|
+
}
|
|
20002
|
+
// ── History maintenance ─────────────────────────────────────────────────
|
|
20003
|
+
/**
|
|
20004
|
+
* See `StateStore.applyHistoryRetention`. One batch runs in one immediate
|
|
20005
|
+
* transaction, so no other writer can change a row between this call
|
|
20006
|
+
* reading it and acting on it. Only rows whose `expires_at` is `NULL` are
|
|
20007
|
+
* touched. An expired execution takes its checkpoints and saved execution
|
|
20008
|
+
* state with it, as a normal expiry does. There is no separate index, so
|
|
20009
|
+
* `indexed` and `deadIndexEntries` are always `0`.
|
|
20010
|
+
*/
|
|
20011
|
+
async applyHistoryRetention(options) {
|
|
20012
|
+
const { kind, batchSize, cursor } = resolveHistoryRetentionOptions("SQLiteStore", options);
|
|
20013
|
+
const ttl = kind === "executions" ? this.ttls.executionHistory : this.ttls.evalHistory;
|
|
20014
|
+
const table = kind === "executions" ? { name: "execution_history", id: "execution_id", time: "started_at" } : { name: "eval_history", id: "id", time: "timestamp" };
|
|
20015
|
+
const lastSaved = kind === "executions" ? "completed_at" : "NULL";
|
|
20016
|
+
const now = this.now();
|
|
20017
|
+
return this.db.transaction(() => {
|
|
20018
|
+
const params = [];
|
|
20019
|
+
let sql = `SELECT ${table.id} AS id, ${table.time} AS time, ${lastSaved} AS last_saved, expires_at FROM ${table.name}`;
|
|
20020
|
+
if (cursor !== void 0) {
|
|
20021
|
+
sql += ` WHERE ${table.time} > ? OR (${table.time} = ? AND ${table.id} > ?)`;
|
|
20022
|
+
params.push(cursor.time, cursor.time, cursor.id);
|
|
20023
|
+
}
|
|
20024
|
+
sql += ` ORDER BY ${table.time}, ${table.id} LIMIT ?`;
|
|
20025
|
+
params.push(batchSize + 1);
|
|
20026
|
+
const rows = this.db.prepare(sql).all(...params);
|
|
20027
|
+
const batch = rows.slice(0, batchSize);
|
|
20028
|
+
const toExpire = [];
|
|
20029
|
+
let armed = 0;
|
|
20030
|
+
if (ttl !== null) {
|
|
20031
|
+
const arm = this.db.prepare(
|
|
20032
|
+
`UPDATE ${table.name} SET expires_at = ? WHERE ${table.id} = ? AND expires_at IS NULL`
|
|
20033
|
+
);
|
|
20034
|
+
for (const row of batch) {
|
|
20035
|
+
if (row.expires_at !== null) continue;
|
|
20036
|
+
const lastSavedAt = kind === "executions" ? executionLastSavedAt({ startedAt: row.time, completedAt: row.last_saved }) : row.time;
|
|
20037
|
+
const decision = retentionForUntimedRecord(lastSavedAt, ttl, now);
|
|
20038
|
+
if (decision.expired) toExpire.push(row.id);
|
|
20039
|
+
else armed += arm.run(decision.expiresAt, row.id).changes;
|
|
20040
|
+
}
|
|
20041
|
+
}
|
|
20042
|
+
let expired = 0;
|
|
20043
|
+
if (toExpire.length > 0) {
|
|
20044
|
+
const idList = JSON.stringify(toExpire);
|
|
20045
|
+
expired = this.db.prepare(
|
|
20046
|
+
`DELETE FROM ${table.name} WHERE expires_at IS NULL AND ${table.id} IN (SELECT value FROM json_each(?))`
|
|
20047
|
+
).run(idList).changes;
|
|
20048
|
+
if (kind === "executions") {
|
|
20049
|
+
for (const side of ["checkpoints", "execution_state"]) {
|
|
20050
|
+
this.db.prepare(
|
|
20051
|
+
`DELETE FROM ${side} WHERE execution_id IN (SELECT value FROM json_each(?))`
|
|
20052
|
+
).run(idList);
|
|
20053
|
+
}
|
|
20054
|
+
}
|
|
20055
|
+
}
|
|
20056
|
+
const last = batch[batch.length - 1];
|
|
20057
|
+
return {
|
|
20058
|
+
expired,
|
|
20059
|
+
armed,
|
|
20060
|
+
indexed: 0,
|
|
20061
|
+
deadIndexEntries: 0,
|
|
20062
|
+
cursor: rows.length > batchSize ? { time: last.time, id: last.id } : null
|
|
20063
|
+
};
|
|
20064
|
+
}).immediate();
|
|
19565
20065
|
}
|
|
19566
20066
|
// ── Sessions (Studio introspection) ────────────────────────────────────
|
|
19567
20067
|
async listSessions() {
|
|
@@ -20901,6 +21401,9 @@ function hashInput(input) {
|
|
|
20901
21401
|
}
|
|
20902
21402
|
var DEFAULT_MAX_EVENTS_PER_EXECUTION = 5e4;
|
|
20903
21403
|
var DEFAULT_STREAMING_BATCH_SIZE = 100;
|
|
21404
|
+
var DEFAULT_RECENT_EXECUTIONS_CACHE_SIZE = 1e3;
|
|
21405
|
+
var LARGE_EXECUTION_LISTING = 1e3;
|
|
21406
|
+
var EXECUTION_SCAN_PAGE_SIZE = 100;
|
|
20904
21407
|
var DEFAULT_STREAMING_BATCH_INTERVAL = 1e3;
|
|
20905
21408
|
var DEFAULT_ARTIFACT_SWEEP_MS = 6e4;
|
|
20906
21409
|
var DEFAULT_ARTIFACT_LEASE_MS = 3e5;
|
|
@@ -21113,6 +21616,78 @@ function assertNoRemovedObservationCallbacks(options) {
|
|
|
21113
21616
|
`runtime.createContext() no longer accepts ${removed.join(", ")}. Observe ctx.events or use runtime.stream() instead. See docs/migration/stream-first-observation.md.`
|
|
21114
21617
|
);
|
|
21115
21618
|
}
|
|
21619
|
+
var RecentRunsCache = class {
|
|
21620
|
+
constructor(capacity) {
|
|
21621
|
+
this.capacity = capacity;
|
|
21622
|
+
}
|
|
21623
|
+
entries = /* @__PURE__ */ new Map();
|
|
21624
|
+
get size() {
|
|
21625
|
+
return this.entries.size;
|
|
21626
|
+
}
|
|
21627
|
+
get(executionId) {
|
|
21628
|
+
const entry = this.entries.get(executionId);
|
|
21629
|
+
if (entry) {
|
|
21630
|
+
this.entries.delete(executionId);
|
|
21631
|
+
this.entries.set(executionId, entry);
|
|
21632
|
+
}
|
|
21633
|
+
return entry;
|
|
21634
|
+
}
|
|
21635
|
+
set(executionId, execution) {
|
|
21636
|
+
this.entries.delete(executionId);
|
|
21637
|
+
this.entries.set(executionId, execution);
|
|
21638
|
+
while (this.entries.size > this.capacity) {
|
|
21639
|
+
const oldest = this.entries.keys().next().value;
|
|
21640
|
+
this.entries.delete(oldest);
|
|
21641
|
+
}
|
|
21642
|
+
}
|
|
21643
|
+
delete(executionId) {
|
|
21644
|
+
return this.entries.delete(executionId);
|
|
21645
|
+
}
|
|
21646
|
+
values() {
|
|
21647
|
+
return [...this.entries.values()];
|
|
21648
|
+
}
|
|
21649
|
+
};
|
|
21650
|
+
function resolveRecentExecutionsCacheSize(requested) {
|
|
21651
|
+
if (requested === void 0) return DEFAULT_RECENT_EXECUTIONS_CACHE_SIZE;
|
|
21652
|
+
if (typeof requested !== "number" || !Number.isInteger(requested) || requested < 0) {
|
|
21653
|
+
throw new AxlError(
|
|
21654
|
+
"INVALID_CONFIG",
|
|
21655
|
+
`config.state.recentExecutionsCacheSize must be a non-negative integer; got ${String(requested)}`
|
|
21656
|
+
);
|
|
21657
|
+
}
|
|
21658
|
+
return requested;
|
|
21659
|
+
}
|
|
21660
|
+
function parseGetExecutionsOptions(options) {
|
|
21661
|
+
if (options === void 0) return {};
|
|
21662
|
+
if (options === null || typeof options !== "object") {
|
|
21663
|
+
throw new AxlError(
|
|
21664
|
+
"INVALID_ARGUMENT",
|
|
21665
|
+
`getExecutions() options must be an object; got ${options === null ? "null" : typeof options}`
|
|
21666
|
+
);
|
|
21667
|
+
}
|
|
21668
|
+
const { limit, cursor } = options;
|
|
21669
|
+
if (limit !== void 0 && limit !== Infinity && !(typeof limit === "number" && Number.isInteger(limit) && limit > 0)) {
|
|
21670
|
+
throw new AxlError(
|
|
21671
|
+
"INVALID_ARGUMENT",
|
|
21672
|
+
`getExecutions() limit must be a positive integer; got ${String(limit)}`
|
|
21673
|
+
);
|
|
21674
|
+
}
|
|
21675
|
+
let parsedCursor;
|
|
21676
|
+
if (cursor !== void 0) {
|
|
21677
|
+
const candidate = cursor;
|
|
21678
|
+
if (candidate === null || typeof candidate !== "object" || typeof candidate.startedAt !== "number" || !Number.isFinite(candidate.startedAt) || typeof candidate.executionId !== "string") {
|
|
21679
|
+
throw new AxlError(
|
|
21680
|
+
"INVALID_ARGUMENT",
|
|
21681
|
+
"getExecutions() cursor must be { startedAt: number; executionId: string }, built from the last item of the previous page"
|
|
21682
|
+
);
|
|
21683
|
+
}
|
|
21684
|
+
parsedCursor = { startedAt: candidate.startedAt, executionId: candidate.executionId };
|
|
21685
|
+
}
|
|
21686
|
+
return {
|
|
21687
|
+
...limit === void 0 || limit === Infinity ? {} : { limit },
|
|
21688
|
+
...parsedCursor ? { cursor: parsedCursor } : {}
|
|
21689
|
+
};
|
|
21690
|
+
}
|
|
21116
21691
|
var costScopeStorage = new import_node_async_hooks4.AsyncLocalStorage();
|
|
21117
21692
|
function forwardAbortSignal(external, internal) {
|
|
21118
21693
|
if (!external) return () => {
|
|
@@ -21159,8 +21734,6 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
|
|
|
21159
21734
|
mcpManager;
|
|
21160
21735
|
memoryManager;
|
|
21161
21736
|
spanManager = new NoopSpanManager();
|
|
21162
|
-
historicalExecutions = /* @__PURE__ */ new Map();
|
|
21163
|
-
historicalExecutionsLoadPromise = null;
|
|
21164
21737
|
/** Per-session in-process serialization. Each entry is a chain of pending
|
|
21165
21738
|
* work for that session id — `Session.send` / `Session.stream` await the
|
|
21166
21739
|
* prior entry before reading history, so concurrent calls produce a
|
|
@@ -21196,12 +21769,34 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
|
|
|
21196
21769
|
* a delete from resurrecting the (intentionally-removed) row. Entries are
|
|
21197
21770
|
* removed once the would-be persist has been short-circuited. */
|
|
21198
21771
|
pendingDeletedExecutions = /* @__PURE__ */ new Set();
|
|
21199
|
-
/** In-flight `persistExecution` chains (save → finalize)
|
|
21200
|
-
* awaits these before closing the StateStore so a workflow whose abort
|
|
21772
|
+
/** In-flight `persistExecution` chains (save → finalize) and crash-
|
|
21773
|
+
* recovery saves. `shutdown()` awaits these before closing the StateStore so a workflow whose abort
|
|
21201
21774
|
* triggers a persist-during-shutdown isn't racing a closed connection.
|
|
21202
21775
|
* The Set is mutated by the chain's own `.finally` so entries clear as
|
|
21203
21776
|
* soon as the work settles. */
|
|
21204
21777
|
persistInflight = /* @__PURE__ */ new Set();
|
|
21778
|
+
/** Finished runs whose history save has not settled, keyed by execution
|
|
21779
|
+
* id. Reads serve the snapshot until the save lands, so a run is visible
|
|
21780
|
+
* the moment it finishes (read-your-writes). `deleteExecution()` awaits
|
|
21781
|
+
* the entry before deleting from the store, so a save that lands late
|
|
21782
|
+
* cannot re-create a deleted run. Each entry removes itself when its save
|
|
21783
|
+
* settles (success or failure), but only if it is still the current entry
|
|
21784
|
+
* for that id, so the map holds only in-flight saves. */
|
|
21785
|
+
pendingSaves = /* @__PURE__ */ new Map();
|
|
21786
|
+
/** Recently finished runs, kept only when the store cannot save, list, or
|
|
21787
|
+
* fetch execution history itself (and the cache size is not `0`).
|
|
21788
|
+
* `undefined` for every built-in store: execution history is read through
|
|
21789
|
+
* the store on every call and never cached here. */
|
|
21790
|
+
recentRuns;
|
|
21791
|
+
/** Execution ids `recoverIncompleteStreams()` is working on, each with a
|
|
21792
|
+
* promise that settles when recovery is done with that id.
|
|
21793
|
+
* `deleteExecution()` awaits it, so a recovered record cannot land after
|
|
21794
|
+
* the delete. */
|
|
21795
|
+
recoveringExecutions = /* @__PURE__ */ new Map();
|
|
21796
|
+
/** Execution ids with a `deleteExecution()` in progress, with a count of
|
|
21797
|
+
* concurrent deletes. Recovery skips them: their store sweep removes the
|
|
21798
|
+
* streaming buffer it would otherwise turn back into a record. */
|
|
21799
|
+
deletingExecutions = /* @__PURE__ */ new Map();
|
|
21205
21800
|
/** Resolved diagnostic artifact backend, when `diagnostics.artifacts` is set. */
|
|
21206
21801
|
artifactStore;
|
|
21207
21802
|
artifactLeaseMs = DEFAULT_ARTIFACT_LEASE_MS;
|
|
@@ -21235,6 +21830,13 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
|
|
|
21235
21830
|
this.providerRegistry = new ProviderRegistry();
|
|
21236
21831
|
this.transcriptionProviderRegistry = new TranscriptionProviderRegistry();
|
|
21237
21832
|
this.stateStore = this.createStateStore();
|
|
21833
|
+
const recentRunsSize = resolveRecentExecutionsCacheSize(
|
|
21834
|
+
this.config.state?.recentExecutionsCacheSize
|
|
21835
|
+
);
|
|
21836
|
+
const storeKeepsHistory = Boolean(
|
|
21837
|
+
this.stateStore.saveExecution && this.stateStore.listExecutions
|
|
21838
|
+
);
|
|
21839
|
+
this.recentRuns = !storeKeepsHistory && recentRunsSize > 0 ? new RecentRunsCache(recentRunsSize) : void 0;
|
|
21238
21840
|
const requested = this.config.state?.maxEventsPerExecution;
|
|
21239
21841
|
if (requested === void 0) {
|
|
21240
21842
|
this.maxEventsPerExecution = DEFAULT_MAX_EVENTS_PER_EXECUTION;
|
|
@@ -21640,11 +22242,15 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
|
|
|
21640
22242
|
const storeOption = this.config.state?.store ?? "memory";
|
|
21641
22243
|
if (typeof storeOption !== "string") return storeOption;
|
|
21642
22244
|
switch (storeOption) {
|
|
21643
|
-
case "sqlite":
|
|
21644
|
-
|
|
22245
|
+
case "sqlite": {
|
|
22246
|
+
const create = () => new SQLiteStore(this.config.state?.sqlite?.path ?? "./data/axl.db", {
|
|
22247
|
+
ttls: this.config.state?.sqlite?.ttls
|
|
22248
|
+
});
|
|
22249
|
+
return this.config.diagnostics?.silent ? withDiagnosticsSilenced(create) : create();
|
|
22250
|
+
}
|
|
21645
22251
|
case "memory":
|
|
21646
22252
|
default:
|
|
21647
|
-
return new MemoryStore();
|
|
22253
|
+
return new MemoryStore(this.config.state?.memory);
|
|
21648
22254
|
}
|
|
21649
22255
|
}
|
|
21650
22256
|
/**
|
|
@@ -21732,13 +22338,15 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
|
|
|
21732
22338
|
}
|
|
21733
22339
|
/**
|
|
21734
22340
|
* Persist a completed/failed execution to the state store (fire-and-forget)
|
|
21735
|
-
* and
|
|
22341
|
+
* and remove it from the active executions map. Until the save settles the
|
|
22342
|
+
* run is served from `pendingSaves`; afterwards from the store (or, for a
|
|
22343
|
+
* store that cannot keep history, from `recentRuns`).
|
|
21736
22344
|
*
|
|
21737
22345
|
* Resurrection guard: if `runtime.deleteExecution(id)` was called while
|
|
21738
22346
|
* this execution was still running, the id will be in
|
|
21739
|
-
* `pendingDeletedExecutions`. In that case we skip the save AND
|
|
21740
|
-
*
|
|
21741
|
-
* the buffer doesn't outlive the delete.
|
|
22347
|
+
* `pendingDeletedExecutions`. In that case we skip the save AND every
|
|
22348
|
+
* in-process record of the run, and finalize the streaming buffer (if any)
|
|
22349
|
+
* so the buffer doesn't outlive the delete.
|
|
21742
22350
|
*/
|
|
21743
22351
|
persistExecution(execInfo) {
|
|
21744
22352
|
const id = execInfo.executionId;
|
|
@@ -21769,14 +22377,19 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
|
|
|
21769
22377
|
}).catch(() => {
|
|
21770
22378
|
}).then(() => void 0);
|
|
21771
22379
|
this.persistInflight.add(chain);
|
|
21772
|
-
|
|
22380
|
+
const pending = { snapshot, settled: chain };
|
|
22381
|
+
this.pendingSaves.set(id, pending);
|
|
22382
|
+
chain.finally(() => {
|
|
22383
|
+
this.persistInflight.delete(chain);
|
|
22384
|
+
if (this.pendingSaves.get(id) === pending) this.pendingSaves.delete(id);
|
|
22385
|
+
});
|
|
21773
22386
|
} else if (this.streamingFlusher) {
|
|
21774
22387
|
const chain = this.streamingFlusher.finalize(id).catch(() => {
|
|
21775
22388
|
});
|
|
21776
22389
|
this.persistInflight.add(chain);
|
|
21777
22390
|
chain.finally(() => this.persistInflight.delete(chain));
|
|
21778
22391
|
}
|
|
21779
|
-
this.
|
|
22392
|
+
this.recentRuns?.set(id, snapshot);
|
|
21780
22393
|
this.executions.delete(id);
|
|
21781
22394
|
}
|
|
21782
22395
|
/** Register a workflow with the runtime. */
|
|
@@ -21902,7 +22515,6 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
|
|
|
21902
22515
|
}
|
|
21903
22516
|
return result;
|
|
21904
22517
|
}
|
|
21905
|
-
/** Get all execution info (running + completed + historical). */
|
|
21906
22518
|
/** Normalize an execution loaded from a `StateStore` so the
|
|
21907
22519
|
* historical event union holds for downstream consumers
|
|
21908
22520
|
* (Studio aggregators, REST routes, redaction). Custom or legacy stores
|
|
@@ -21922,27 +22534,78 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
|
|
|
21922
22534
|
}
|
|
21923
22535
|
return normalizeStoredExecution(exec);
|
|
21924
22536
|
}
|
|
21925
|
-
|
|
21926
|
-
|
|
21927
|
-
|
|
21928
|
-
|
|
21929
|
-
|
|
21930
|
-
|
|
21931
|
-
|
|
21932
|
-
|
|
21933
|
-
|
|
21934
|
-
|
|
21935
|
-
|
|
21936
|
-
|
|
21937
|
-
|
|
21938
|
-
|
|
21939
|
-
|
|
21940
|
-
|
|
21941
|
-
|
|
21942
|
-
|
|
21943
|
-
|
|
22537
|
+
/**
|
|
22538
|
+
* List executions, newest first: running runs, runs whose save is still in
|
|
22539
|
+
* flight, and the history the state store holds. Ordered by `startedAt`,
|
|
22540
|
+
* then `executionId`, both descending.
|
|
22541
|
+
*
|
|
22542
|
+
* With no options, returns every retained run. Pass `limit` for one page,
|
|
22543
|
+
* and `cursor` (built from the last item of the previous page) for the
|
|
22544
|
+
* page after it. Each call reads through the store, so runs finished or
|
|
22545
|
+
* deleted by other runtimes sharing the store are reflected immediately.
|
|
22546
|
+
* A call without `limit` that returns more than 1 000 runs warns once per
|
|
22547
|
+
* process (silenced by `diagnostics.silent`); pass `limit: Infinity` to ask
|
|
22548
|
+
* for everything without the warning.
|
|
22549
|
+
*
|
|
22550
|
+
* @throws {AxlError} `INVALID_ARGUMENT` for a `limit` that is not a positive
|
|
22551
|
+
* integer (`Infinity` means no limit) or a malformed `cursor`.
|
|
22552
|
+
*/
|
|
22553
|
+
async getExecutions(options) {
|
|
22554
|
+
const { limit, cursor } = parseGetExecutionsOptions(options);
|
|
22555
|
+
const isAfterCursor = (exec) => cursor === void 0 || compareExecutionsNewestFirst(exec, cursor) > 0;
|
|
22556
|
+
const live = new Map(this.executions);
|
|
22557
|
+
for (const [id, { snapshot }] of this.pendingSaves) {
|
|
22558
|
+
if (!live.has(id)) live.set(id, snapshot);
|
|
22559
|
+
}
|
|
22560
|
+
const recent = this.recentRunsServeListing() ? this.recentRuns.values() : [];
|
|
22561
|
+
const stored = await this.listStoredExecutions({
|
|
22562
|
+
limit,
|
|
22563
|
+
cursor,
|
|
22564
|
+
overlap: live.size + recent.length
|
|
22565
|
+
});
|
|
22566
|
+
const merged = /* @__PURE__ */ new Map();
|
|
22567
|
+
for (const [id, exec] of live) {
|
|
22568
|
+
if (isAfterCursor(exec)) merged.set(id, exec);
|
|
22569
|
+
}
|
|
22570
|
+
for (const exec of stored) {
|
|
22571
|
+
const id = exec.executionId;
|
|
22572
|
+
if (!live.has(id) && !merged.has(id)) merged.set(id, exec);
|
|
22573
|
+
}
|
|
22574
|
+
for (const exec of recent) {
|
|
22575
|
+
const id = exec.executionId;
|
|
22576
|
+
if (!live.has(id) && !merged.has(id) && isAfterCursor(exec)) merged.set(id, exec);
|
|
22577
|
+
}
|
|
22578
|
+
const sorted = [...merged.values()].sort(compareExecutionsNewestFirst);
|
|
22579
|
+
if (limit !== void 0) return sorted.slice(0, limit);
|
|
22580
|
+
if (options?.limit === void 0 && sorted.length > LARGE_EXECUTION_LISTING) {
|
|
22581
|
+
warnDiagnosticOnce(
|
|
22582
|
+
"large_execution_listing",
|
|
22583
|
+
`runtime.getExecutions() without a limit returned ${sorted.length} runs and reads the whole history on every call; pass { limit, cursor } to read it a page at a time.`,
|
|
22584
|
+
this.config.diagnostics?.silent,
|
|
22585
|
+
"docs/migration/execution-history-retention.md#page-through-large-histories"
|
|
22586
|
+
);
|
|
21944
22587
|
}
|
|
21945
|
-
return
|
|
22588
|
+
return sorted;
|
|
22589
|
+
}
|
|
22590
|
+
/**
|
|
22591
|
+
* Read one page of execution history from the store: the records strictly
|
|
22592
|
+
* after `cursor`, newest first, at least `limit + overlap` of them when the
|
|
22593
|
+
* store holds that many (all of them when `limit` is unset).
|
|
22594
|
+
*
|
|
22595
|
+
* Only a store that declares `historyPaging` is handed the cursor. Any
|
|
22596
|
+
* other store is listed in full and paged here: filtering the output of a
|
|
22597
|
+
* store that ignored the cursor would return its first page again, and an
|
|
22598
|
+
* empty page after that.
|
|
22599
|
+
*/
|
|
22600
|
+
async listStoredExecutions(page) {
|
|
22601
|
+
const store = this.stateStore;
|
|
22602
|
+
if (!store.listExecutions) return [];
|
|
22603
|
+
const wanted = page.limit === void 0 ? void 0 : page.limit + page.overlap;
|
|
22604
|
+
const rows = store.historyPaging === true ? await store.listExecutions(wanted, { cursor: page.cursor }) : await store.listExecutions();
|
|
22605
|
+
const cursor = page.cursor;
|
|
22606
|
+
const after = cursor === void 0 ? rows : rows.filter((row) => compareExecutionsNewestFirst(row, cursor) > 0);
|
|
22607
|
+
const window = wanted === void 0 ? after : [...after].sort(compareExecutionsNewestFirst).slice(0, wanted);
|
|
22608
|
+
return window.map((row) => this.normalizeStoredExecution(row));
|
|
21946
22609
|
}
|
|
21947
22610
|
/**
|
|
21948
22611
|
* Create a WorkflowContext for ad-hoc use (evals, tool testing, prototyping).
|
|
@@ -22373,20 +23036,25 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
|
|
|
22373
23036
|
* Recover executions whose process died mid-run when `state.persist:
|
|
22374
23037
|
* 'streaming'` was configured. Synthesizes a partial `ExecutionInfo`
|
|
22375
23038
|
* from the streaming buffer for each — `status: 'failed'`, error
|
|
22376
|
-
* `'process terminated'` —
|
|
22377
|
-
* they show up in `getExecutions()
|
|
22378
|
-
*
|
|
23039
|
+
* `'process terminated'` — persists them via the store's
|
|
23040
|
+
* `saveExecution` (so they show up in `getExecutions()`), and finalizes
|
|
23041
|
+
* the streaming buffer.
|
|
22379
23042
|
*
|
|
22380
23043
|
* Idempotent — if the streaming buffer is already gone (or
|
|
22381
23044
|
* `listStreamingExecutions` is empty), this is a no-op. Returns the
|
|
22382
23045
|
* list of recovered `ExecutionInfo`s for caller introspection.
|
|
22383
23046
|
*
|
|
22384
|
-
*
|
|
22385
|
-
*
|
|
22386
|
-
*
|
|
22387
|
-
*
|
|
22388
|
-
*
|
|
22389
|
-
*
|
|
23047
|
+
* A run whose final record already exists is never recovered: for each
|
|
23048
|
+
* buffered id this asks the store's `getExecution(id)` and, when a record
|
|
23049
|
+
* is there, only drops the leftover buffer. It does not list history, so
|
|
23050
|
+
* its cost does not grow with the size of the store. A store without
|
|
23051
|
+
* `getExecution` cannot be checked, and every buffered run is recovered.
|
|
23052
|
+
*
|
|
23053
|
+
* Wire this into process startup, before the process accepts new work:
|
|
23054
|
+
* a run live in another process looks the same as a crashed one.
|
|
23055
|
+
* `deleteExecution(id)` called while recovery is working on that id waits
|
|
23056
|
+
* for recovery to finish with it, so the recovered record cannot outlive
|
|
23057
|
+
* the delete.
|
|
22390
23058
|
*/
|
|
22391
23059
|
async recoverIncompleteStreams() {
|
|
22392
23060
|
if (!this.stateStore.listStreamingExecutions || !this.stateStore.getStreamingEvents) {
|
|
@@ -22394,73 +23062,119 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
|
|
|
22394
23062
|
}
|
|
22395
23063
|
const ids = await this.stateStore.listStreamingExecutions();
|
|
22396
23064
|
if (ids.length === 0) return [];
|
|
22397
|
-
await this.getExecutions();
|
|
22398
23065
|
const recovered = [];
|
|
22399
23066
|
for (const id of ids) {
|
|
22400
23067
|
if (this.executions.has(id) || this.streamableExecutionIds.has(id)) {
|
|
22401
23068
|
continue;
|
|
22402
23069
|
}
|
|
22403
|
-
|
|
22404
|
-
|
|
22405
|
-
|
|
22406
|
-
|
|
22407
|
-
|
|
22408
|
-
|
|
22409
|
-
|
|
22410
|
-
|
|
22411
|
-
|
|
22412
|
-
|
|
22413
|
-
|
|
22414
|
-
|
|
22415
|
-
|
|
22416
|
-
|
|
22417
|
-
}
|
|
23070
|
+
if (this.deletingExecutions.has(id) || this.recoveringExecutions.has(id)) continue;
|
|
23071
|
+
let finishRecovery;
|
|
23072
|
+
this.recoveringExecutions.set(
|
|
23073
|
+
id,
|
|
23074
|
+
new Promise((resolve2) => {
|
|
23075
|
+
finishRecovery = resolve2;
|
|
23076
|
+
})
|
|
23077
|
+
);
|
|
23078
|
+
try {
|
|
23079
|
+
const result = await this.recoverStreamingExecution(id);
|
|
23080
|
+
if (result) recovered.push(result);
|
|
23081
|
+
} finally {
|
|
23082
|
+
this.recoveringExecutions.delete(id);
|
|
23083
|
+
finishRecovery();
|
|
22418
23084
|
}
|
|
22419
|
-
|
|
22420
|
-
|
|
22421
|
-
|
|
22422
|
-
|
|
22423
|
-
|
|
22424
|
-
|
|
22425
|
-
|
|
22426
|
-
|
|
22427
|
-
|
|
22428
|
-
|
|
22429
|
-
|
|
22430
|
-
|
|
22431
|
-
|
|
22432
|
-
|
|
22433
|
-
|
|
22434
|
-
|
|
22435
|
-
|
|
22436
|
-
|
|
22437
|
-
|
|
22438
|
-
|
|
22439
|
-
|
|
22440
|
-
|
|
22441
|
-
|
|
22442
|
-
|
|
22443
|
-
|
|
22444
|
-
|
|
22445
|
-
|
|
22446
|
-
|
|
22447
|
-
|
|
23085
|
+
}
|
|
23086
|
+
return recovered;
|
|
23087
|
+
}
|
|
23088
|
+
/** Recover one buffered execution. Returns the synthesized record, or
|
|
23089
|
+
* `undefined` when there was nothing to recover. */
|
|
23090
|
+
async recoverStreamingExecution(id) {
|
|
23091
|
+
if (!this.stateStore.getStreamingEvents) return void 0;
|
|
23092
|
+
const existing = await this.stateStore.getExecution?.(id);
|
|
23093
|
+
if (existing) {
|
|
23094
|
+
await this.stateStore.finalizeStreamingEvents?.(id);
|
|
23095
|
+
return void 0;
|
|
23096
|
+
}
|
|
23097
|
+
const events = await this.stateStore.getStreamingEvents(id);
|
|
23098
|
+
if (events.length === 0) {
|
|
23099
|
+
await this.stateStore.finalizeStreamingEvents?.(id);
|
|
23100
|
+
return void 0;
|
|
23101
|
+
}
|
|
23102
|
+
const eventSchemaVersion = getEventSchemaVersion(events[0]);
|
|
23103
|
+
for (const event of events) {
|
|
23104
|
+
if (getEventSchemaVersion(event) !== eventSchemaVersion) {
|
|
23105
|
+
throw new Error(`Streaming buffer ${id} contains mixed event schema versions`);
|
|
23106
|
+
}
|
|
23107
|
+
}
|
|
23108
|
+
const boundedEvents = boundRecoveredEvents(events, this.maxEventsPerExecution);
|
|
23109
|
+
const workflowStartEvent = boundedEvents.find((e) => e.type === "workflow_start");
|
|
23110
|
+
const lastEvent = boundedEvents[boundedEvents.length - 1];
|
|
23111
|
+
const startedAt = workflowStartEvent?.timestamp ?? boundedEvents[0]?.timestamp ?? Date.now();
|
|
23112
|
+
const completedAt = lastEvent.timestamp ?? startedAt;
|
|
23113
|
+
const workflowName = workflowStartEvent?.workflow ?? RECOVERED_UNKNOWN_WORKFLOW;
|
|
23114
|
+
const totalCost = boundedEvents.reduce((sum, e) => sum + eventCostContribution(e), 0);
|
|
23115
|
+
const unpriced = boundedEvents.some(isUnpricedLeaf);
|
|
23116
|
+
const executionBase = {
|
|
23117
|
+
executionId: id,
|
|
23118
|
+
workflow: workflowName,
|
|
23119
|
+
status: "failed",
|
|
23120
|
+
events: boundedEvents,
|
|
23121
|
+
totalCost,
|
|
23122
|
+
unpriced,
|
|
23123
|
+
startedAt,
|
|
23124
|
+
completedAt,
|
|
23125
|
+
duration: completedAt - startedAt,
|
|
23126
|
+
error: "process terminated (recovered from streaming buffer)"
|
|
23127
|
+
};
|
|
23128
|
+
const synthesized = eventSchemaVersion === 2 ? {
|
|
23129
|
+
...executionBase,
|
|
23130
|
+
eventSchemaVersion: 2,
|
|
23131
|
+
events: boundedEvents,
|
|
23132
|
+
observation: { complete: false, reason: "process_interrupted" }
|
|
23133
|
+
} : {
|
|
23134
|
+
...executionBase,
|
|
23135
|
+
eventSchemaVersion: 1,
|
|
23136
|
+
events: boundedEvents
|
|
23137
|
+
};
|
|
23138
|
+
const saveExecution = this.stateStore.saveExecution;
|
|
23139
|
+
if (saveExecution) {
|
|
23140
|
+
const save = (async () => saveExecution.call(this.stateStore, synthesized))();
|
|
23141
|
+
const pending = {
|
|
23142
|
+
snapshot: synthesized,
|
|
23143
|
+
settled: save.then(
|
|
23144
|
+
() => void 0,
|
|
23145
|
+
() => void 0
|
|
23146
|
+
)
|
|
22448
23147
|
};
|
|
22449
|
-
|
|
22450
|
-
|
|
22451
|
-
|
|
22452
|
-
|
|
22453
|
-
|
|
22454
|
-
|
|
22455
|
-
)
|
|
22456
|
-
|
|
22457
|
-
|
|
23148
|
+
this.pendingSaves.set(id, pending);
|
|
23149
|
+
this.persistInflight.add(pending.settled);
|
|
23150
|
+
try {
|
|
23151
|
+
await save;
|
|
23152
|
+
} catch (err) {
|
|
23153
|
+
console.error(
|
|
23154
|
+
`[axl] recoverIncompleteStreams: failed to save synthesized execution ${id}: ${err instanceof Error ? err.message : String(err)}. Streaming buffer left in place for next recovery attempt.`
|
|
23155
|
+
);
|
|
23156
|
+
return void 0;
|
|
23157
|
+
} finally {
|
|
23158
|
+
this.persistInflight.delete(pending.settled);
|
|
23159
|
+
if (this.pendingSaves.get(id) === pending) this.pendingSaves.delete(id);
|
|
22458
23160
|
}
|
|
22459
|
-
this.historicalExecutions.set(id, synthesized);
|
|
22460
|
-
await this.stateStore.finalizeStreamingEvents?.(id);
|
|
22461
|
-
recovered.push(synthesized);
|
|
22462
23161
|
}
|
|
22463
|
-
|
|
23162
|
+
this.recentRuns?.set(id, synthesized);
|
|
23163
|
+
const finalize = Promise.resolve(this.stateStore.finalizeStreamingEvents?.(id)).then(
|
|
23164
|
+
() => void 0,
|
|
23165
|
+
(err) => {
|
|
23166
|
+
console.error(
|
|
23167
|
+
`[axl] recoverIncompleteStreams: saved execution ${id} but failed to drop its streaming buffer: ${err instanceof Error ? err.message : String(err)}. The next recovery removes it.`
|
|
23168
|
+
);
|
|
23169
|
+
}
|
|
23170
|
+
);
|
|
23171
|
+
this.persistInflight.add(finalize);
|
|
23172
|
+
try {
|
|
23173
|
+
await finalize;
|
|
23174
|
+
} finally {
|
|
23175
|
+
this.persistInflight.delete(finalize);
|
|
23176
|
+
}
|
|
23177
|
+
return synthesized;
|
|
22464
23178
|
}
|
|
22465
23179
|
/** Abort a running execution by its ID. */
|
|
22466
23180
|
abort(executionId) {
|
|
@@ -22470,21 +23184,57 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
|
|
|
22470
23184
|
this.abortControllers.delete(executionId);
|
|
22471
23185
|
}
|
|
22472
23186
|
}
|
|
22473
|
-
/**
|
|
23187
|
+
/**
|
|
23188
|
+
* Get execution details by ID. Looks in order at running runs, runs whose
|
|
23189
|
+
* save is still in flight, the state store (its `getExecution`, or a scan
|
|
23190
|
+
* of its listing when it has `listExecutions` but no `getExecution`), and
|
|
23191
|
+
* (only for a store that cannot list or save history) the recent-runs
|
|
23192
|
+
* cache. Nothing read from the store is cached, so a run deleted or
|
|
23193
|
+
* expired elsewhere is gone on the next call, and every run
|
|
23194
|
+
* `getExecutions()` lists can be fetched here.
|
|
23195
|
+
*/
|
|
22474
23196
|
async getExecution(executionId) {
|
|
22475
|
-
const
|
|
22476
|
-
if (
|
|
22477
|
-
const
|
|
22478
|
-
if (
|
|
22479
|
-
|
|
22480
|
-
|
|
22481
|
-
|
|
22482
|
-
|
|
22483
|
-
|
|
22484
|
-
|
|
22485
|
-
|
|
23197
|
+
const active = this.executions.get(executionId);
|
|
23198
|
+
if (active) return active;
|
|
23199
|
+
const pending = this.pendingSaves.get(executionId);
|
|
23200
|
+
if (pending) return pending.snapshot;
|
|
23201
|
+
const stored = this.stateStore.getExecution ? await this.stateStore.getExecution(executionId) : await this.findInStoreListing(executionId);
|
|
23202
|
+
if (stored) return this.normalizeStoredExecution(stored);
|
|
23203
|
+
return this.recentRunsServeReads() ? this.recentRuns.get(executionId) : void 0;
|
|
23204
|
+
}
|
|
23205
|
+
/**
|
|
23206
|
+
* By-id read for a store that lists but cannot fetch by id. A paging store
|
|
23207
|
+
* is scanned page by page with the cursor, stopping at the match; any
|
|
23208
|
+
* other store is listed once in full. Returns `null` when the store cannot
|
|
23209
|
+
* list or does not hold the run.
|
|
23210
|
+
*/
|
|
23211
|
+
async findInStoreListing(executionId) {
|
|
23212
|
+
const store = this.stateStore;
|
|
23213
|
+
if (!store.listExecutions) return null;
|
|
23214
|
+
if (store.historyPaging !== true) {
|
|
23215
|
+
return (await store.listExecutions()).find((e) => e.executionId === executionId) ?? null;
|
|
22486
23216
|
}
|
|
22487
|
-
|
|
23217
|
+
let cursor;
|
|
23218
|
+
for (; ; ) {
|
|
23219
|
+
const page = await store.listExecutions(EXECUTION_SCAN_PAGE_SIZE, { cursor });
|
|
23220
|
+
const found = page.find((e) => e.executionId === executionId);
|
|
23221
|
+
if (found) return found;
|
|
23222
|
+
if (page.length === 0) return null;
|
|
23223
|
+
const last = [...page].sort(compareExecutionsNewestFirst)[page.length - 1];
|
|
23224
|
+
if (cursor && compareExecutionsNewestFirst(last, cursor) <= 0) return null;
|
|
23225
|
+
cursor = { startedAt: last.startedAt, executionId: last.executionId };
|
|
23226
|
+
}
|
|
23227
|
+
}
|
|
23228
|
+
/** The recent-runs cache stands in for listing when the store cannot list
|
|
23229
|
+
* runs, or never receives them because it cannot save. */
|
|
23230
|
+
recentRunsServeListing() {
|
|
23231
|
+
return this.recentRuns !== void 0 && (!this.stateStore.saveExecution || !this.stateStore.listExecutions);
|
|
23232
|
+
}
|
|
23233
|
+
/** The recent-runs cache stands in for by-id reads when the store cannot
|
|
23234
|
+
* fetch or list runs (it exists only then), or never receives them because
|
|
23235
|
+
* it cannot save. */
|
|
23236
|
+
recentRunsServeReads() {
|
|
23237
|
+
return this.recentRuns !== void 0 && (!this.stateStore.saveExecution || !this.stateStore.getExecution);
|
|
22488
23238
|
}
|
|
22489
23239
|
/** Save an eval result to history. */
|
|
22490
23240
|
async saveEvalResult(entry) {
|
|
@@ -22517,11 +23267,16 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
|
|
|
22517
23267
|
}
|
|
22518
23268
|
/**
|
|
22519
23269
|
* Delete an execution from history by id. Removes from the active map (if
|
|
22520
|
-
* still running), the
|
|
23270
|
+
* still running), the recent-runs cache, the configured StateStore, AND
|
|
22521
23271
|
* any execution-scoped side state — pending awaitHuman decisions,
|
|
22522
23272
|
* resolver maps, streaming buffer registration, in-flight abort controller.
|
|
22523
|
-
* Returns true if
|
|
22524
|
-
*
|
|
23273
|
+
* Returns true if the run was running, had a save in flight, or was
|
|
23274
|
+
* removed from the recent-runs cache or the store.
|
|
23275
|
+
*
|
|
23276
|
+
* If the run has already finished but its history save has not landed
|
|
23277
|
+
* yet, this method waits for that save (whether it succeeds or fails)
|
|
23278
|
+
* before deleting, so the save cannot re-create the deleted record. It
|
|
23279
|
+
* likewise waits for `recoverIncompleteStreams()` to finish with the id.
|
|
22525
23280
|
*
|
|
22526
23281
|
* In-flight handling: if the execution is still running, this method
|
|
22527
23282
|
* **aborts** it (via the registered abort controller) AND marks the id for
|
|
@@ -22546,7 +23301,18 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
|
|
|
22546
23301
|
* (`defaultTtl` / `ttls.executionHistory`).
|
|
22547
23302
|
*/
|
|
22548
23303
|
async deleteExecution(id) {
|
|
23304
|
+
this.deletingExecutions.set(id, (this.deletingExecutions.get(id) ?? 0) + 1);
|
|
23305
|
+
try {
|
|
23306
|
+
return await this.deleteExecutionWhileMarked(id);
|
|
23307
|
+
} finally {
|
|
23308
|
+
const remaining = (this.deletingExecutions.get(id) ?? 1) - 1;
|
|
23309
|
+
if (remaining > 0) this.deletingExecutions.set(id, remaining);
|
|
23310
|
+
else this.deletingExecutions.delete(id);
|
|
23311
|
+
}
|
|
23312
|
+
}
|
|
23313
|
+
async deleteExecutionWhileMarked(id) {
|
|
22549
23314
|
const wasActive = this.executions.has(id);
|
|
23315
|
+
const hadPendingSave = this.pendingSaves.has(id);
|
|
22550
23316
|
const pendingDecisionResolver = this.pendingDecisionResolvers.get(id);
|
|
22551
23317
|
const hadPendingDecision = pendingDecisionResolver !== void 0;
|
|
22552
23318
|
if (wasActive) {
|
|
@@ -22564,22 +23330,25 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
|
|
|
22564
23330
|
this.pendingDecisionResolvers.delete(id);
|
|
22565
23331
|
}
|
|
22566
23332
|
}
|
|
23333
|
+
await this.pendingSaves.get(id)?.settled;
|
|
23334
|
+
await this.recoveringExecutions.get(id);
|
|
22567
23335
|
const existing = await this.getExecution(id);
|
|
22568
23336
|
const workflow2 = existing?.workflow;
|
|
22569
23337
|
const removedFromActive = this.executions.delete(id);
|
|
22570
|
-
const
|
|
23338
|
+
const removedFromRecent = this.recentRuns?.delete(id) ?? false;
|
|
22571
23339
|
let removedFromStore = false;
|
|
22572
23340
|
if (this.stateStore.deleteExecution) {
|
|
22573
23341
|
removedFromStore = await this.stateStore.deleteExecution(id);
|
|
22574
23342
|
}
|
|
23343
|
+
const removed = removedFromActive || hadPendingSave || removedFromRecent || removedFromStore;
|
|
22575
23344
|
this.emit("execution_deleted", {
|
|
22576
23345
|
executionId: id,
|
|
22577
23346
|
workflow: workflow2,
|
|
22578
23347
|
wasActive,
|
|
22579
23348
|
hadPendingDecision,
|
|
22580
|
-
removed
|
|
23349
|
+
removed
|
|
22581
23350
|
});
|
|
22582
|
-
return
|
|
23351
|
+
return removed;
|
|
22583
23352
|
}
|
|
22584
23353
|
/**
|
|
22585
23354
|
* Delete an eval history entry by id. Removes from in-memory cache and
|
|
@@ -23997,12 +24766,57 @@ async function* stringStreamFromEvents(source, opts) {
|
|
|
23997
24766
|
}
|
|
23998
24767
|
|
|
23999
24768
|
// src/state/redis.ts
|
|
24769
|
+
var PRUNE_HISTORY_INDEX_SCRIPT = `
|
|
24770
|
+
local now = tonumber(ARGV[1])
|
|
24771
|
+
local removed = 0
|
|
24772
|
+
for i = 2, #ARGV do
|
|
24773
|
+
local id = ARGV[i]
|
|
24774
|
+
local pttl = redis.call('PTTL', KEYS[i + 2])
|
|
24775
|
+
if pttl == -2 then
|
|
24776
|
+
redis.call('SREM', KEYS[1], id)
|
|
24777
|
+
redis.call('ZREM', KEYS[2], id)
|
|
24778
|
+
redis.call('ZREM', KEYS[3], id)
|
|
24779
|
+
removed = removed + 1
|
|
24780
|
+
elseif pttl == -1 then
|
|
24781
|
+
redis.call('ZREM', KEYS[3], id)
|
|
24782
|
+
else
|
|
24783
|
+
redis.call('ZADD', KEYS[3], string.format('%d', now + pttl), id)
|
|
24784
|
+
end
|
|
24785
|
+
end
|
|
24786
|
+
return removed
|
|
24787
|
+
`;
|
|
24788
|
+
function compareMembers(a, b) {
|
|
24789
|
+
return Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"));
|
|
24790
|
+
}
|
|
24791
|
+
function compareNewestFirst(a, b) {
|
|
24792
|
+
if (a.score !== b.score) return b.score - a.score;
|
|
24793
|
+
return compareMembers(b.id, a.id);
|
|
24794
|
+
}
|
|
24795
|
+
function warnIfRedisExecutionRetentionUnset(opts) {
|
|
24796
|
+
if (opts.ttls?.executionHistory === void 0 && typeof opts.defaultTtl !== "number") {
|
|
24797
|
+
warnExecutionRetentionUnset("RedisStore");
|
|
24798
|
+
}
|
|
24799
|
+
}
|
|
24000
24800
|
var RedisStore = class _RedisStore {
|
|
24001
24801
|
constructor(client, keyPrefix, ttls) {
|
|
24002
24802
|
this.client = client;
|
|
24003
24803
|
this.keyPrefix = keyPrefix;
|
|
24004
24804
|
this.ttls = ttls;
|
|
24005
24805
|
}
|
|
24806
|
+
/**
|
|
24807
|
+
* History kinds with a prune running in this process. A save skips its
|
|
24808
|
+
* prune while another one of the same kind is in flight: the running one
|
|
24809
|
+
* already covers the oldest expired entries. Created lazily so the
|
|
24810
|
+
* instance needs no constructor state.
|
|
24811
|
+
*/
|
|
24812
|
+
prunesInFlight;
|
|
24813
|
+
/**
|
|
24814
|
+
* Supports `listExecutions(limit, { cursor })`. A getter rather than a
|
|
24815
|
+
* field so it lives on the prototype.
|
|
24816
|
+
*/
|
|
24817
|
+
get historyPaging() {
|
|
24818
|
+
return true;
|
|
24819
|
+
}
|
|
24006
24820
|
/**
|
|
24007
24821
|
* Resolve the TTL for a given storage category. Returns `undefined` when
|
|
24008
24822
|
* no TTL applies (category is `null` in resolved config, the value is
|
|
@@ -24081,6 +24895,7 @@ var RedisStore = class _RedisStore {
|
|
|
24081
24895
|
streamingEvents: resolveStreamingEvents(opts.ttls?.streamingEvents)
|
|
24082
24896
|
};
|
|
24083
24897
|
const store = new _RedisStore(client, keyPrefix, effectiveTtls);
|
|
24898
|
+
warnIfRedisExecutionRetentionUnset(opts);
|
|
24084
24899
|
if (!opts.skipMigration) {
|
|
24085
24900
|
await store.backfillExecutionIndex();
|
|
24086
24901
|
await store.backfillEvalIndex();
|
|
@@ -24192,13 +25007,9 @@ var RedisStore = class _RedisStore {
|
|
|
24192
25007
|
execHistoryKey(executionId) {
|
|
24193
25008
|
return `${this.keyPrefix}exec-history:${executionId}`;
|
|
24194
25009
|
}
|
|
24195
|
-
//
|
|
24196
|
-
//
|
|
24197
|
-
//
|
|
24198
|
-
// The slow-path fallback (`listHistoryByZset`) reads from the SET when
|
|
24199
|
-
// `zsetCard < setCard`, so SET-based eviction would race with the fallback
|
|
24200
|
-
// and surface dead IDs whose data has been TTL'd away. ZSET eviction +
|
|
24201
|
-
// dual-write `SREM` is the clean path; doc the assumption then.
|
|
25010
|
+
// The legacy id set and the sorted set below always change together (save,
|
|
25011
|
+
// delete, prune), so the slow-path guard `ZCARD >= SCARD` in
|
|
25012
|
+
// `listHistoryByZset` and backfill stays meaningful.
|
|
24202
25013
|
execHistorySetKey() {
|
|
24203
25014
|
return `${this.keyPrefix}exec-history-ids`;
|
|
24204
25015
|
}
|
|
@@ -24221,6 +25032,36 @@ var RedisStore = class _RedisStore {
|
|
|
24221
25032
|
evalHistoryZsetKey() {
|
|
24222
25033
|
return `${this.keyPrefix}eval-history-z`;
|
|
24223
25034
|
}
|
|
25035
|
+
/** Keys and blob parsing for one kind of history. See {@link HistoryIndex}. */
|
|
25036
|
+
historyIndex(kind) {
|
|
25037
|
+
if (kind === "execution") {
|
|
25038
|
+
return {
|
|
25039
|
+
kind,
|
|
25040
|
+
label: "execution-history",
|
|
25041
|
+
ids: this.execHistorySetKey(),
|
|
25042
|
+
time: this.execHistoryZsetKey(),
|
|
25043
|
+
expiry: `${this.keyPrefix}exec-history-exp`,
|
|
25044
|
+
dataKey: (id) => this.execHistoryKey(id),
|
|
25045
|
+
lastSavedAtOf: (raw) => {
|
|
25046
|
+
const record = JSON.parse(raw);
|
|
25047
|
+
return typeof record.startedAt === "number" ? executionLastSavedAt(record) : null;
|
|
25048
|
+
}
|
|
25049
|
+
};
|
|
25050
|
+
}
|
|
25051
|
+
return {
|
|
25052
|
+
kind,
|
|
25053
|
+
label: "eval-history",
|
|
25054
|
+
ids: this.evalHistorySetKey(),
|
|
25055
|
+
time: this.evalHistoryZsetKey(),
|
|
25056
|
+
expiry: `${this.keyPrefix}eval-history-exp`,
|
|
25057
|
+
dataKey: (id) => this.evalHistoryKey(id),
|
|
25058
|
+
// Eval retention counts from the first write, which is the row's timestamp.
|
|
25059
|
+
lastSavedAtOf: (raw) => {
|
|
25060
|
+
const timestamp = JSON.parse(raw).timestamp;
|
|
25061
|
+
return typeof timestamp === "number" ? timestamp : null;
|
|
25062
|
+
}
|
|
25063
|
+
};
|
|
25064
|
+
}
|
|
24224
25065
|
memoryKey(scope) {
|
|
24225
25066
|
return `${this.keyPrefix}memory:${scope}`;
|
|
24226
25067
|
}
|
|
@@ -24392,31 +25233,35 @@ var RedisStore = class _RedisStore {
|
|
|
24392
25233
|
async saveExecution(execution) {
|
|
24393
25234
|
const ttl = this.ttlFor("executionHistory");
|
|
24394
25235
|
const setOptions = ttl !== void 0 ? { EX: ttl } : void 0;
|
|
25236
|
+
const index = this.historyIndex("execution");
|
|
25237
|
+
const id = execution.executionId;
|
|
24395
25238
|
const storedExecution = normalizeStoredExecution({
|
|
24396
25239
|
...execution,
|
|
24397
25240
|
eventSchemaVersion: getExecutionEventSchemaVersion(execution)
|
|
24398
25241
|
});
|
|
24399
|
-
|
|
24400
|
-
|
|
24401
|
-
|
|
24402
|
-
|
|
25242
|
+
const tx = this.client.multi().sAdd(index.ids, id).set(index.dataKey(id), JSON.stringify(storedExecution), setOptions).zAdd(index.time, { score: execution.startedAt, value: id });
|
|
25243
|
+
if (ttl !== void 0) tx.zAdd(index.expiry, { score: Date.now() + ttl * 1e3, value: id });
|
|
25244
|
+
else tx.zRem(index.expiry, id);
|
|
25245
|
+
await tx.exec();
|
|
25246
|
+
await this.pruneHistoryAfterSave(index);
|
|
24403
25247
|
}
|
|
24404
25248
|
async getExecution(executionId) {
|
|
24405
25249
|
const raw = await this.client.get(this.execHistoryKey(executionId));
|
|
24406
25250
|
return raw ? normalizeStoredExecution(JSON.parse(raw)) : null;
|
|
24407
25251
|
}
|
|
24408
|
-
async listExecutions(limit) {
|
|
25252
|
+
async listExecutions(limit, options) {
|
|
25253
|
+
const cursor = options?.cursor;
|
|
24409
25254
|
const executions = await this.listHistoryByZset(
|
|
24410
|
-
this.
|
|
24411
|
-
|
|
24412
|
-
|
|
24413
|
-
|
|
24414
|
-
limit
|
|
25255
|
+
this.historyIndex("execution"),
|
|
25256
|
+
(e) => ({ score: e.startedAt, id: e.executionId }),
|
|
25257
|
+
limit,
|
|
25258
|
+
cursor !== void 0 ? { score: cursor.startedAt, id: cursor.executionId } : void 0
|
|
24415
25259
|
);
|
|
24416
25260
|
return executions.map(normalizeStoredExecution);
|
|
24417
25261
|
}
|
|
24418
25262
|
async deleteExecution(executionId) {
|
|
24419
|
-
const
|
|
25263
|
+
const index = this.historyIndex("execution");
|
|
25264
|
+
const results = await this.client.multi().sRem(index.ids, executionId).del(index.dataKey(executionId)).zRem(index.time, executionId).zRem(index.expiry, executionId).del(this.checkpointKey(executionId)).del(this.executionStateKey(executionId)).sRem(this.pendingExecSetKey(), executionId).del(this.streamingEventsKey(executionId)).sRem(this.streamingIdsKey(), executionId).hDel(this.decisionsKey(), executionId).exec();
|
|
24420
25265
|
return results.some((result) => typeof result === "number" && result > 0);
|
|
24421
25266
|
}
|
|
24422
25267
|
// ── Eval History ────────────────────────────────────────────────────
|
|
@@ -24441,18 +25286,31 @@ var RedisStore = class _RedisStore {
|
|
|
24441
25286
|
* lifetime, so the race costs at most one window's length, never the window
|
|
24442
25287
|
* itself. It also carries no Redis version floor, where `KEEPTTL` needs 6.0.
|
|
24443
25288
|
*
|
|
24444
|
-
*
|
|
25289
|
+
* As with `saveExecution`, a timed row is also scored in the expiry sorted
|
|
25290
|
+
* set (by the expiry it carries, so a re-save keeps its score), an untimed
|
|
25291
|
+
* one is taken out of it, and a bounded prune follows the save.
|
|
24445
25292
|
*/
|
|
24446
25293
|
async saveEvalResult(entry) {
|
|
24447
25294
|
const ttl = this.ttlFor("evalHistory");
|
|
24448
|
-
const
|
|
25295
|
+
const index = this.historyIndex("eval");
|
|
25296
|
+
const key = index.dataKey(entry.id);
|
|
24449
25297
|
let setOptions;
|
|
25298
|
+
let expiresAt;
|
|
24450
25299
|
if (ttl !== void 0) {
|
|
24451
25300
|
const remaining = await this.client.pTTL(key);
|
|
24452
|
-
if (remaining === -2)
|
|
24453
|
-
|
|
25301
|
+
if (remaining === -2) {
|
|
25302
|
+
setOptions = { EX: ttl };
|
|
25303
|
+
expiresAt = Date.now() + ttl * 1e3;
|
|
25304
|
+
} else if (remaining > 0) {
|
|
25305
|
+
setOptions = { PX: remaining };
|
|
25306
|
+
expiresAt = Date.now() + remaining;
|
|
25307
|
+
}
|
|
24454
25308
|
}
|
|
24455
|
-
|
|
25309
|
+
const tx = this.client.multi().sAdd(index.ids, entry.id).set(key, JSON.stringify(entry), setOptions).zAdd(index.time, { score: entry.timestamp, value: entry.id });
|
|
25310
|
+
if (expiresAt !== void 0) tx.zAdd(index.expiry, { score: expiresAt, value: entry.id });
|
|
25311
|
+
else tx.zRem(index.expiry, entry.id);
|
|
25312
|
+
await tx.exec();
|
|
25313
|
+
await this.pruneHistoryAfterSave(index);
|
|
24456
25314
|
}
|
|
24457
25315
|
/**
|
|
24458
25316
|
* Update-only write: see `StateStore.updateEvalResult`.
|
|
@@ -24492,15 +25350,14 @@ var RedisStore = class _RedisStore {
|
|
|
24492
25350
|
}
|
|
24493
25351
|
async listEvalResults(limit) {
|
|
24494
25352
|
return this.listHistoryByZset(
|
|
24495
|
-
this.
|
|
24496
|
-
|
|
24497
|
-
(id) => this.evalHistoryKey(id),
|
|
24498
|
-
(a, b) => b.timestamp - a.timestamp,
|
|
25353
|
+
this.historyIndex("eval"),
|
|
25354
|
+
(e) => ({ score: e.timestamp, id: e.id }),
|
|
24499
25355
|
limit
|
|
24500
25356
|
);
|
|
24501
25357
|
}
|
|
24502
25358
|
async deleteEvalResult(id) {
|
|
24503
|
-
const
|
|
25359
|
+
const index = this.historyIndex("eval");
|
|
25360
|
+
const [, deletedCount] = await this.client.multi().sRem(index.ids, id).del(index.dataKey(id)).zRem(index.time, id).zRem(index.expiry, id).exec();
|
|
24504
25361
|
const deleted = typeof deletedCount === "number" ? deletedCount : 0;
|
|
24505
25362
|
return deleted > 0;
|
|
24506
25363
|
}
|
|
@@ -24524,50 +25381,291 @@ var RedisStore = class _RedisStore {
|
|
|
24524
25381
|
return { exists: true, expiresAt: Date.now() + ttl };
|
|
24525
25382
|
}
|
|
24526
25383
|
/**
|
|
24527
|
-
* Shared list-by-sorted-set implementation
|
|
24528
|
-
*
|
|
24529
|
-
*
|
|
24530
|
-
*
|
|
24531
|
-
*
|
|
25384
|
+
* Shared list-by-sorted-set implementation, newest first by
|
|
25385
|
+
* `(score DESC, member DESC)`, starting strictly after `cursor` when given.
|
|
25386
|
+
*
|
|
25387
|
+
* Fast path (the time sorted set is at least as large as the id set): walk
|
|
25388
|
+
* the sorted set in chunks and MGET each chunk's blobs. Index entries whose
|
|
25389
|
+
* blob is gone (expired, or deleted before the prune caught up) are skipped,
|
|
25390
|
+
* and the walk keeps reading until it has `limit` live records or the set
|
|
25391
|
+
* runs out, however many dead entries are in the way. Each chunk restarts
|
|
25392
|
+
* at the last member read, by score and member, rather than at an offset,
|
|
25393
|
+
* so entries added or removed meanwhile cannot shift the walk.
|
|
25394
|
+
*
|
|
25395
|
+
* Slow path (the time sorted set is behind the id set: backfill has not
|
|
25396
|
+
* run): read every id with GET, then sort and apply the cursor in process.
|
|
24532
25397
|
*/
|
|
24533
|
-
async listHistoryByZset(
|
|
24534
|
-
const setSize = await this.client.sCard(
|
|
25398
|
+
async listHistoryByZset(index, positionOf, limit, cursor) {
|
|
25399
|
+
const setSize = await this.client.sCard(index.ids);
|
|
24535
25400
|
if (setSize === 0) return [];
|
|
24536
|
-
const zsetSize = await this.client.zCard(
|
|
24537
|
-
|
|
24538
|
-
|
|
24539
|
-
const
|
|
25401
|
+
const zsetSize = await this.client.zCard(index.time);
|
|
25402
|
+
const max = limit ? limit : void 0;
|
|
25403
|
+
if (zsetSize < setSize) {
|
|
25404
|
+
const ids = await this.client.sMembers(index.ids);
|
|
25405
|
+
const entries = [];
|
|
25406
|
+
for (const id of ids) {
|
|
25407
|
+
const raw = await this.client.get(index.dataKey(id));
|
|
25408
|
+
if (raw == null) continue;
|
|
25409
|
+
try {
|
|
25410
|
+
entries.push(JSON.parse(raw));
|
|
25411
|
+
} catch {
|
|
25412
|
+
}
|
|
25413
|
+
}
|
|
25414
|
+
const ordered = entries.filter((e) => cursor === void 0 || compareNewestFirst(cursor, positionOf(e)) < 0).sort((a, b) => compareNewestFirst(positionOf(a), positionOf(b)));
|
|
25415
|
+
return max !== void 0 ? ordered.slice(0, max) : ordered;
|
|
25416
|
+
}
|
|
25417
|
+
const results = [];
|
|
25418
|
+
const seen = /* @__PURE__ */ new Set();
|
|
25419
|
+
let bound = cursor;
|
|
25420
|
+
let rangeMax = cursor !== void 0 ? cursor.score : "+inf";
|
|
25421
|
+
let anchor;
|
|
25422
|
+
let grow = 1;
|
|
25423
|
+
const read = (offset, count2) => (
|
|
25424
|
+
// ZRANGE BY SCORE REV: start is the highest score, stop the lowest.
|
|
25425
|
+
this.client.zRangeWithScores(index.time, rangeMax, "-inf", {
|
|
24540
25426
|
BY: "SCORE",
|
|
24541
25427
|
REV: true,
|
|
24542
|
-
...
|
|
25428
|
+
...count2 !== void 0 ? { LIMIT: { offset, count: count2 } } : {}
|
|
25429
|
+
})
|
|
25430
|
+
);
|
|
25431
|
+
for (; ; ) {
|
|
25432
|
+
const remaining = max !== void 0 ? max - results.length : void 0;
|
|
25433
|
+
const want = remaining !== void 0 ? Math.max(remaining * 2, remaining + 5) * grow : void 0;
|
|
25434
|
+
let offset = 0;
|
|
25435
|
+
let count2 = want;
|
|
25436
|
+
let members;
|
|
25437
|
+
if (anchor !== void 0 && want !== void 0) {
|
|
25438
|
+
offset = anchor.pos;
|
|
25439
|
+
count2 = want + 1;
|
|
25440
|
+
members = await read(offset, count2);
|
|
25441
|
+
if (members[0]?.value !== anchor.id) {
|
|
25442
|
+
offset = 0;
|
|
25443
|
+
count2 = anchor.pos + 1 + want;
|
|
25444
|
+
members = await read(offset, count2);
|
|
25445
|
+
}
|
|
25446
|
+
} else {
|
|
25447
|
+
members = await read(offset, count2);
|
|
25448
|
+
}
|
|
25449
|
+
const fresh = members.filter(
|
|
25450
|
+
(m) => !seen.has(m.value) && (bound === void 0 || compareNewestFirst(bound, { score: m.score, id: m.value }) < 0)
|
|
25451
|
+
);
|
|
25452
|
+
if (fresh.length > 0) {
|
|
25453
|
+
const raws = await this.client.mGet(fresh.map((m) => index.dataKey(m.value)));
|
|
25454
|
+
for (let i = 0; i < fresh.length; i++) {
|
|
25455
|
+
seen.add(fresh[i].value);
|
|
25456
|
+
const raw = raws[i];
|
|
25457
|
+
if (raw == null) continue;
|
|
25458
|
+
try {
|
|
25459
|
+
results.push(JSON.parse(raw));
|
|
25460
|
+
} catch {
|
|
25461
|
+
continue;
|
|
25462
|
+
}
|
|
25463
|
+
if (max !== void 0 && results.length >= max) return results;
|
|
25464
|
+
}
|
|
25465
|
+
const lastFresh = fresh[fresh.length - 1];
|
|
25466
|
+
bound = { score: lastFresh.score, id: lastFresh.value };
|
|
25467
|
+
grow = 1;
|
|
25468
|
+
} else {
|
|
25469
|
+
grow *= 2;
|
|
25470
|
+
}
|
|
25471
|
+
if (count2 === void 0 || members.length < count2) return results;
|
|
25472
|
+
const last = members[members.length - 1];
|
|
25473
|
+
if (last.score === rangeMax) {
|
|
25474
|
+
anchor = { id: last.value, score: last.score, pos: offset + members.length - 1 };
|
|
25475
|
+
} else {
|
|
25476
|
+
rangeMax = last.score;
|
|
25477
|
+
const before = members.filter((m) => m.score === last.score).length - 1;
|
|
25478
|
+
anchor = { id: last.value, score: last.score, pos: before };
|
|
25479
|
+
}
|
|
25480
|
+
}
|
|
25481
|
+
}
|
|
25482
|
+
// ── History index cleanup ──────────────────────────────────────────────
|
|
25483
|
+
//
|
|
25484
|
+
// Index members cannot carry a TTL, so when a history record expires its
|
|
25485
|
+
// entries in the id set and the time sorted set stay behind. Every history
|
|
25486
|
+
// save runs one bounded prune of its kind: it reads up to PRUNE_BATCH ids
|
|
25487
|
+
// whose expiry has passed from the expiry sorted set, then runs
|
|
25488
|
+
// PRUNE_HISTORY_INDEX_SCRIPT over them. The script checks each record's blob
|
|
25489
|
+
// and updates the indexes in one atomic step, so a save or delete landing
|
|
25490
|
+
// between the read and the script cannot be undone by it: a record saved
|
|
25491
|
+
// again is seen live and stays listed, and a deleted one is already gone.
|
|
25492
|
+
// The script only removes index entries and re-scores expiry entries; it
|
|
25493
|
+
// never adds a listing entry or writes a blob, so it cannot bring back
|
|
25494
|
+
// deleted data. Several instances pruning at once is safe.
|
|
25495
|
+
/**
|
|
25496
|
+
* Run one prune of `index`'s kind after a save. A prune failure does not
|
|
25497
|
+
* fail the save, whose MULTI already committed the record: it is reported
|
|
25498
|
+
* once per process and the next save of that kind tries again. That also
|
|
25499
|
+
* covers a Redis user whose ACL denies `EVAL`.
|
|
25500
|
+
*/
|
|
25501
|
+
async pruneHistoryAfterSave(index) {
|
|
25502
|
+
const inFlight = this.prunesInFlight ??= /* @__PURE__ */ new Set();
|
|
25503
|
+
if (inFlight.has(index.kind)) return;
|
|
25504
|
+
inFlight.add(index.kind);
|
|
25505
|
+
try {
|
|
25506
|
+
await this.pruneHistory(index);
|
|
25507
|
+
} catch (err) {
|
|
25508
|
+
warnDiagnosticOnce(
|
|
25509
|
+
`redis_history_prune_failed\0${index.kind}`,
|
|
25510
|
+
`RedisStore: removing expired ${index.label} index entries failed (${err instanceof Error ? err.message : String(err)}). The record was saved; the next save retries the cleanup. Listings stay correct meanwhile. The cleanup runs a Lua script, so the Redis user needs permission to run EVAL.`,
|
|
25511
|
+
void 0,
|
|
25512
|
+
"docs/api-reference.md#redisstore-options"
|
|
25513
|
+
);
|
|
25514
|
+
} finally {
|
|
25515
|
+
inFlight.delete(index.kind);
|
|
25516
|
+
}
|
|
25517
|
+
}
|
|
25518
|
+
async pruneHistory(index) {
|
|
25519
|
+
const candidates = await this.client.zRange(index.expiry, "-inf", Date.now(), {
|
|
25520
|
+
BY: "SCORE",
|
|
25521
|
+
LIMIT: { offset: 0, count: PRUNE_BATCH }
|
|
25522
|
+
});
|
|
25523
|
+
if (candidates.length > 0) await this.runPruneScript(index, candidates);
|
|
25524
|
+
}
|
|
25525
|
+
/**
|
|
25526
|
+
* Run {@link PRUNE_HISTORY_INDEX_SCRIPT} over `ids`, at most PRUNE_BATCH
|
|
25527
|
+
* per call so one script never blocks the server for long.
|
|
25528
|
+
*/
|
|
25529
|
+
async runPruneScript(index, ids) {
|
|
25530
|
+
for (let start = 0; start < ids.length; start += PRUNE_BATCH) {
|
|
25531
|
+
const chunk = ids.slice(start, start + PRUNE_BATCH);
|
|
25532
|
+
await this.client.eval(PRUNE_HISTORY_INDEX_SCRIPT, {
|
|
25533
|
+
keys: [index.ids, index.time, index.expiry, ...chunk.map((id) => index.dataKey(id))],
|
|
25534
|
+
arguments: [String(Date.now()), ...chunk]
|
|
24543
25535
|
});
|
|
24544
|
-
|
|
24545
|
-
|
|
24546
|
-
|
|
24547
|
-
|
|
24548
|
-
|
|
24549
|
-
|
|
25536
|
+
}
|
|
25537
|
+
}
|
|
25538
|
+
// ── History maintenance ────────────────────────────────────────────────
|
|
25539
|
+
/**
|
|
25540
|
+
* See `StateStore.applyHistoryRetention`. One call:
|
|
25541
|
+
*
|
|
25542
|
+
* 1. On the first call of a walk (no cursor), backfills the time sorted
|
|
25543
|
+
* set from the id set if it is behind (see `backfillExecutionIndex`).
|
|
25544
|
+
* 2. Reads the next `batchSize` ids from the time sorted set, oldest first
|
|
25545
|
+
* by `(score, member)`, strictly after the cursor. The read restarts at
|
|
25546
|
+
* the cursor's position by value, never by offset.
|
|
25547
|
+
* 3. MGETs their blobs and reads each live blob's PTTL.
|
|
25548
|
+
* 4. In one MULTI, for each live blob:
|
|
25549
|
+
* - a TTL (`PTTL > 0`): `ZADD NX` to the expiry set at now + PTTL. A
|
|
25550
|
+
* record already in the expiry set keeps its entry (`indexed` counts
|
|
25551
|
+
* only new entries).
|
|
25552
|
+
* - no TTL (`-1`), with a retention configured: `PEXPIREAT … NX` at last
|
|
25553
|
+
* save + retention. `NX` applies only to a key that still has no TTL,
|
|
25554
|
+
* so a record re-saved meanwhile keeps the fresh window its save gave
|
|
25555
|
+
* it. A window that has already passed uses a timestamp in the past,
|
|
25556
|
+
* which makes Redis delete the key, again only if it is still untimed.
|
|
25557
|
+
* 5. Adds each newly armed record to the expiry set (`ZADD NX`).
|
|
25558
|
+
* 6. Runs the prune script (`PRUNE_HISTORY_INDEX_SCRIPT`) over ids whose
|
|
25559
|
+
* blob is gone and ids just deleted in step 4. It checks each blob
|
|
25560
|
+
* atomically, so a record re-saved at any point stays listed and a
|
|
25561
|
+
* deleted one is never re-indexed.
|
|
25562
|
+
*
|
|
25563
|
+
* Blobs are never written. A record deleted while the call runs can leave
|
|
25564
|
+
* an entry in the expiry set only; the next prune removes it.
|
|
25565
|
+
*/
|
|
25566
|
+
async applyHistoryRetention(options) {
|
|
25567
|
+
const { kind, batchSize, cursor } = resolveHistoryRetentionOptions("RedisStore", options);
|
|
25568
|
+
const index = this.historyIndex(kind === "executions" ? "execution" : "eval");
|
|
25569
|
+
const ttl = this.ttlFor(kind === "executions" ? "executionHistory" : "evalHistory");
|
|
25570
|
+
if (cursor === void 0) {
|
|
25571
|
+
if (kind === "executions") await this.backfillExecutionIndex();
|
|
25572
|
+
else await this.backfillEvalIndex();
|
|
25573
|
+
}
|
|
25574
|
+
const positions = await this.readRetentionBatch(index, cursor, batchSize + 1);
|
|
25575
|
+
const batch = positions.slice(0, batchSize);
|
|
25576
|
+
const result = {
|
|
25577
|
+
expired: 0,
|
|
25578
|
+
armed: 0,
|
|
25579
|
+
indexed: 0,
|
|
25580
|
+
deadIndexEntries: 0,
|
|
25581
|
+
cursor: positions.length > batchSize ? { time: batch[batch.length - 1].score, id: batch[batch.length - 1].value } : null
|
|
25582
|
+
};
|
|
25583
|
+
if (batch.length === 0) return result;
|
|
25584
|
+
const ids = batch.map((m) => m.value);
|
|
25585
|
+
const raws = await this.client.mGet(ids.map((id) => index.dataKey(id)));
|
|
25586
|
+
const dead = ids.filter((_, i) => raws[i] == null);
|
|
25587
|
+
const present = ids.flatMap((id, i) => {
|
|
25588
|
+
const raw = raws[i];
|
|
25589
|
+
return raw == null ? [] : [{ id, raw }];
|
|
25590
|
+
});
|
|
25591
|
+
const pttls = await Promise.all(present.map((r) => this.client.pTTL(index.dataKey(r.id))));
|
|
25592
|
+
const now = Date.now();
|
|
25593
|
+
const tx = this.client.multi();
|
|
25594
|
+
const queued = [];
|
|
25595
|
+
for (let i = 0; i < present.length; i++) {
|
|
25596
|
+
const { id, raw } = present[i];
|
|
25597
|
+
const pttl = pttls[i];
|
|
25598
|
+
if (pttl === -2) {
|
|
25599
|
+
dead.push(id);
|
|
25600
|
+
} else if (pttl > 0) {
|
|
25601
|
+
tx.zAdd(index.expiry, { score: now + pttl, value: id }, { condition: "NX" });
|
|
25602
|
+
queued.push({ action: "index", id });
|
|
25603
|
+
} else if (pttl === -1 && ttl !== void 0) {
|
|
25604
|
+
let lastSavedAt;
|
|
24550
25605
|
try {
|
|
24551
|
-
|
|
25606
|
+
lastSavedAt = index.lastSavedAtOf(raw);
|
|
24552
25607
|
} catch {
|
|
25608
|
+
lastSavedAt = null;
|
|
25609
|
+
}
|
|
25610
|
+
if (lastSavedAt === null) continue;
|
|
25611
|
+
const decision = retentionForUntimedRecord(lastSavedAt, ttl, now);
|
|
25612
|
+
if (decision.expired) {
|
|
25613
|
+
tx.pExpireAt(index.dataKey(id), 1, "NX");
|
|
25614
|
+
queued.push({ action: "expire", id });
|
|
25615
|
+
} else {
|
|
25616
|
+
tx.pExpireAt(index.dataKey(id), decision.expiresAt, "NX");
|
|
25617
|
+
queued.push({ action: "arm", id, expiresAt: decision.expiresAt });
|
|
24553
25618
|
}
|
|
24554
|
-
if (limit !== void 0 && entries2.length >= limit) break;
|
|
24555
25619
|
}
|
|
24556
|
-
return entries2;
|
|
24557
25620
|
}
|
|
24558
|
-
const
|
|
24559
|
-
|
|
24560
|
-
|
|
24561
|
-
|
|
24562
|
-
|
|
24563
|
-
|
|
24564
|
-
|
|
24565
|
-
|
|
24566
|
-
|
|
25621
|
+
const expiredIds = [];
|
|
25622
|
+
const armed = [];
|
|
25623
|
+
if (queued.length > 0) {
|
|
25624
|
+
const replies = await tx.exec();
|
|
25625
|
+
for (let i = 0; i < queued.length; i++) {
|
|
25626
|
+
if (Number(replies[i]) !== 1) continue;
|
|
25627
|
+
const entry = queued[i];
|
|
25628
|
+
if (entry.action === "index") result.indexed++;
|
|
25629
|
+
else if (entry.action === "expire") expiredIds.push(entry.id);
|
|
25630
|
+
else armed.push(entry);
|
|
24567
25631
|
}
|
|
24568
25632
|
}
|
|
24569
|
-
|
|
24570
|
-
|
|
25633
|
+
if (armed.length > 0) {
|
|
25634
|
+
const indexTx = this.client.multi();
|
|
25635
|
+
for (const { id, expiresAt } of armed) {
|
|
25636
|
+
indexTx.zAdd(index.expiry, { score: expiresAt, value: id }, { condition: "NX" });
|
|
25637
|
+
}
|
|
25638
|
+
await indexTx.exec();
|
|
25639
|
+
}
|
|
25640
|
+
result.armed = armed.length;
|
|
25641
|
+
result.expired = expiredIds.length;
|
|
25642
|
+
result.deadIndexEntries = dead.length;
|
|
25643
|
+
if (dead.length > 0 || expiredIds.length > 0) {
|
|
25644
|
+
await this.runPruneScript(index, [...dead, ...expiredIds]);
|
|
25645
|
+
}
|
|
25646
|
+
return result;
|
|
25647
|
+
}
|
|
25648
|
+
/**
|
|
25649
|
+
* Up to `count` members of the time sorted set strictly after `cursor`,
|
|
25650
|
+
* oldest first by `(score, member)`. The members sharing the cursor's score
|
|
25651
|
+
* are read in full and filtered by member; the rest start just above that
|
|
25652
|
+
* score. Neither read uses an offset, so entries removed or added
|
|
25653
|
+
* meanwhile cannot shift the walk.
|
|
25654
|
+
*/
|
|
25655
|
+
async readRetentionBatch(index, cursor, count2) {
|
|
25656
|
+
if (cursor === void 0) {
|
|
25657
|
+
return this.client.zRangeWithScores(index.time, "-inf", "+inf", {
|
|
25658
|
+
BY: "SCORE",
|
|
25659
|
+
LIMIT: { offset: 0, count: count2 }
|
|
25660
|
+
});
|
|
25661
|
+
}
|
|
25662
|
+
const ties = (await this.client.zRangeWithScores(index.time, cursor.time, cursor.time, { BY: "SCORE" })).filter((m) => compareMembers(m.value, cursor.id) > 0);
|
|
25663
|
+
if (ties.length >= count2) return ties.slice(0, count2);
|
|
25664
|
+
const rest = await this.client.zRangeWithScores(index.time, `(${cursor.time}`, "+inf", {
|
|
25665
|
+
BY: "SCORE",
|
|
25666
|
+
LIMIT: { offset: 0, count: count2 - ties.length }
|
|
25667
|
+
});
|
|
25668
|
+
return [...ties, ...rest];
|
|
24571
25669
|
}
|
|
24572
25670
|
// ── Sessions (Studio introspection) ────────────────────────────────────
|
|
24573
25671
|
async listSessions() {
|