@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.js
CHANGED
|
@@ -348,7 +348,7 @@ var sharedGuard = typeof installedGuard === "object" && installedGuard !== null
|
|
|
348
348
|
var buildPath = typeof __AXL_BUILD_PATH__ === "string" ? __AXL_BUILD_PATH__ : "<source>";
|
|
349
349
|
var thisCopy = {
|
|
350
350
|
path: buildPath.startsWith("file:") ? fileURLToPath(buildPath) : buildPath,
|
|
351
|
-
version: true ? "0.
|
|
351
|
+
version: true ? "0.27.0" : "unknown"
|
|
352
352
|
};
|
|
353
353
|
function warnCrossCopy(other, joined) {
|
|
354
354
|
if (other === thisCopy) return;
|
|
@@ -12615,8 +12615,17 @@ function detectDroppedRefinements(schema) {
|
|
|
12615
12615
|
var DOCS_URL = "docs/observability.md#schema-diagnostics";
|
|
12616
12616
|
var MAX_WARNED_KEYS = 2048;
|
|
12617
12617
|
var warnedDiagnosticKeys = /* @__PURE__ */ new Set();
|
|
12618
|
+
var silencedDepth = 0;
|
|
12619
|
+
function withDiagnosticsSilenced(fn) {
|
|
12620
|
+
silencedDepth++;
|
|
12621
|
+
try {
|
|
12622
|
+
return fn();
|
|
12623
|
+
} finally {
|
|
12624
|
+
silencedDepth--;
|
|
12625
|
+
}
|
|
12626
|
+
}
|
|
12618
12627
|
function warnDiagnosticOnce(key, message, silent, docs = DOCS_URL) {
|
|
12619
|
-
if (silent || process.env.AXL_DIAGNOSTICS_SILENT === "true") return;
|
|
12628
|
+
if (silent || silencedDepth > 0 || process.env.AXL_DIAGNOSTICS_SILENT === "true") return;
|
|
12620
12629
|
if (warnedDiagnosticKeys.has(key)) return;
|
|
12621
12630
|
if (warnedDiagnosticKeys.size >= MAX_WARNED_KEYS) warnedDiagnosticKeys.clear();
|
|
12622
12631
|
warnedDiagnosticKeys.add(key);
|
|
@@ -18680,10 +18689,135 @@ import { AsyncLocalStorage as AsyncLocalStorage4 } from "async_hooks";
|
|
|
18680
18689
|
import { appendFileSync, mkdirSync as mkdirSync2 } from "fs";
|
|
18681
18690
|
import { dirname } from "path";
|
|
18682
18691
|
|
|
18692
|
+
// src/state/retention.ts
|
|
18693
|
+
var PRUNE_BATCH = 100;
|
|
18694
|
+
var DEFAULT_MEMORY_MAX_EXECUTIONS = 1e3;
|
|
18695
|
+
function warnExecutionRetentionUnset(storeName) {
|
|
18696
|
+
warnDiagnosticOnce(
|
|
18697
|
+
`execution_retention_unset\0${storeName}`,
|
|
18698
|
+
`${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.`,
|
|
18699
|
+
void 0,
|
|
18700
|
+
"docs/migration/execution-history-retention.md#silencing-the-retention-warning"
|
|
18701
|
+
);
|
|
18702
|
+
}
|
|
18703
|
+
function resolveTtl(storeName, category, value) {
|
|
18704
|
+
if (value === void 0 || value === null) return null;
|
|
18705
|
+
if (typeof value !== "number") {
|
|
18706
|
+
throw new AxlError(
|
|
18707
|
+
"INVALID_CONFIG",
|
|
18708
|
+
`${storeName}: ttls.${category} must be a positive number of seconds or null; got ${typeof value}`
|
|
18709
|
+
);
|
|
18710
|
+
}
|
|
18711
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
18712
|
+
throw new AxlError(
|
|
18713
|
+
"INVALID_CONFIG",
|
|
18714
|
+
`${storeName}: ttls.${category} must be a positive number of seconds or null; got ${value}`
|
|
18715
|
+
);
|
|
18716
|
+
}
|
|
18717
|
+
return value;
|
|
18718
|
+
}
|
|
18719
|
+
function resolveHistoryTtls(storeName, ttls) {
|
|
18720
|
+
if (ttls !== void 0 && (ttls === null || typeof ttls !== "object")) {
|
|
18721
|
+
throw new AxlError(
|
|
18722
|
+
"INVALID_CONFIG",
|
|
18723
|
+
`${storeName}: ttls must be an object; got ${ttls === null ? "null" : typeof ttls}`
|
|
18724
|
+
);
|
|
18725
|
+
}
|
|
18726
|
+
return {
|
|
18727
|
+
executionHistory: resolveTtl(storeName, "executionHistory", ttls?.executionHistory),
|
|
18728
|
+
evalHistory: resolveTtl(storeName, "evalHistory", ttls?.evalHistory)
|
|
18729
|
+
};
|
|
18730
|
+
}
|
|
18731
|
+
function expiryFrom(now, ttlSeconds) {
|
|
18732
|
+
return ttlSeconds === null ? null : now + Math.round(ttlSeconds * 1e3);
|
|
18733
|
+
}
|
|
18734
|
+
function isExpired(expiresAt, now) {
|
|
18735
|
+
return expiresAt !== null && now >= expiresAt;
|
|
18736
|
+
}
|
|
18737
|
+
var DEFAULT_RETENTION_BATCH_SIZE = 500;
|
|
18738
|
+
function resolveHistoryRetentionOptions(storeName, options) {
|
|
18739
|
+
const method = `${storeName}.applyHistoryRetention()`;
|
|
18740
|
+
if (options === null || typeof options !== "object") {
|
|
18741
|
+
throw new AxlError("INVALID_ARGUMENT", `${method} options must be an object`);
|
|
18742
|
+
}
|
|
18743
|
+
const { kind, batchSize, cursor } = options;
|
|
18744
|
+
if (kind !== "executions" && kind !== "evals") {
|
|
18745
|
+
throw new AxlError(
|
|
18746
|
+
"INVALID_ARGUMENT",
|
|
18747
|
+
`${method} kind must be 'executions' or 'evals'; got ${String(kind)}`
|
|
18748
|
+
);
|
|
18749
|
+
}
|
|
18750
|
+
if (batchSize !== void 0 && !(typeof batchSize === "number" && Number.isSafeInteger(batchSize) && batchSize > 0)) {
|
|
18751
|
+
throw new AxlError(
|
|
18752
|
+
"INVALID_ARGUMENT",
|
|
18753
|
+
`${method} batchSize must be a positive integer; got ${String(batchSize)}`
|
|
18754
|
+
);
|
|
18755
|
+
}
|
|
18756
|
+
let parsedCursor;
|
|
18757
|
+
if (cursor !== void 0 && cursor !== null) {
|
|
18758
|
+
const candidate = cursor;
|
|
18759
|
+
if (typeof candidate !== "object" || typeof candidate.time !== "number" || !Number.isFinite(candidate.time) || typeof candidate.id !== "string") {
|
|
18760
|
+
throw new AxlError(
|
|
18761
|
+
"INVALID_ARGUMENT",
|
|
18762
|
+
`${method} cursor must be the { time, id } cursor returned by the previous call`
|
|
18763
|
+
);
|
|
18764
|
+
}
|
|
18765
|
+
parsedCursor = { time: candidate.time, id: candidate.id };
|
|
18766
|
+
}
|
|
18767
|
+
return {
|
|
18768
|
+
kind,
|
|
18769
|
+
batchSize: batchSize ?? DEFAULT_RETENTION_BATCH_SIZE,
|
|
18770
|
+
cursor: parsedCursor
|
|
18771
|
+
};
|
|
18772
|
+
}
|
|
18773
|
+
function retentionForUntimedRecord(lastSavedAt, ttlSeconds, now) {
|
|
18774
|
+
const expiresAt = lastSavedAt + Math.round(ttlSeconds * 1e3);
|
|
18775
|
+
return isExpired(expiresAt, now) ? { expired: true } : { expired: false, expiresAt };
|
|
18776
|
+
}
|
|
18777
|
+
function executionLastSavedAt(record) {
|
|
18778
|
+
return typeof record.completedAt === "number" && Number.isFinite(record.completedAt) ? record.completedAt : record.startedAt;
|
|
18779
|
+
}
|
|
18780
|
+
function compareExecutionsNewestFirst(a, b) {
|
|
18781
|
+
if (a.startedAt !== b.startedAt) return b.startedAt - a.startedAt;
|
|
18782
|
+
return compareCodePoints(b.executionId, a.executionId);
|
|
18783
|
+
}
|
|
18784
|
+
function compareRetentionPositions(a, b) {
|
|
18785
|
+
if (a.time !== b.time) return a.time - b.time;
|
|
18786
|
+
return compareCodePoints(a.id, b.id);
|
|
18787
|
+
}
|
|
18788
|
+
function compareCodePoints(a, b) {
|
|
18789
|
+
const len = Math.min(a.length, b.length);
|
|
18790
|
+
for (let i = 0; i < len; i++) {
|
|
18791
|
+
const x = a.charCodeAt(i);
|
|
18792
|
+
const y = b.charCodeAt(i);
|
|
18793
|
+
if (x !== y) return codePointRank(x) - codePointRank(y);
|
|
18794
|
+
}
|
|
18795
|
+
return a.length - b.length;
|
|
18796
|
+
}
|
|
18797
|
+
function codePointRank(unit) {
|
|
18798
|
+
if (unit >= 57344) return unit - 2048;
|
|
18799
|
+
if (unit >= 55296) return unit + 8192;
|
|
18800
|
+
return unit;
|
|
18801
|
+
}
|
|
18802
|
+
|
|
18683
18803
|
// src/state/memory.ts
|
|
18684
18804
|
import { writeFileSync, readFileSync, existsSync, mkdirSync, unlinkSync } from "fs";
|
|
18685
18805
|
import { join } from "path";
|
|
18686
18806
|
import { tmpdir } from "os";
|
|
18807
|
+
function compareOldestFirst(a, b) {
|
|
18808
|
+
return compareExecutionsNewestFirst(b, a);
|
|
18809
|
+
}
|
|
18810
|
+
function resolveMaxExecutions(value) {
|
|
18811
|
+
if (value === void 0) return DEFAULT_MEMORY_MAX_EXECUTIONS;
|
|
18812
|
+
if (value === Infinity) return Infinity;
|
|
18813
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
|
|
18814
|
+
throw new AxlError(
|
|
18815
|
+
"INVALID_CONFIG",
|
|
18816
|
+
`MemoryStore: maxExecutions must be a positive integer or Infinity; got ${typeof value === "number" ? value : typeof value}`
|
|
18817
|
+
);
|
|
18818
|
+
}
|
|
18819
|
+
return value;
|
|
18820
|
+
}
|
|
18687
18821
|
var AWAIT_HUMAN_TEMP_DIR = join(tmpdir(), "axl-memory-store");
|
|
18688
18822
|
var AWAIT_HUMAN_TEMP_FILE = join(AWAIT_HUMAN_TEMP_DIR, "await-human-state.json");
|
|
18689
18823
|
var MemoryStore = class {
|
|
@@ -18693,9 +18827,32 @@ var MemoryStore = class {
|
|
|
18693
18827
|
decisions = /* @__PURE__ */ new Map();
|
|
18694
18828
|
executionStates = /* @__PURE__ */ new Map();
|
|
18695
18829
|
memories = /* @__PURE__ */ new Map();
|
|
18830
|
+
/**
|
|
18831
|
+
* Iteration order is last-save order: a save deletes and re-inserts its key.
|
|
18832
|
+
* With one retention per store and a clock that does not run backwards, that
|
|
18833
|
+
* is also expiry order, which lets the prune stop at the first live record.
|
|
18834
|
+
*/
|
|
18696
18835
|
executionHistory = /* @__PURE__ */ new Map();
|
|
18836
|
+
/** Every key in `executionHistory`, sorted oldest first in listing order. */
|
|
18837
|
+
executionOrder = [];
|
|
18838
|
+
/**
|
|
18839
|
+
* Iteration order is first-write order (a re-save keeps the key's position
|
|
18840
|
+
* and its expiry), which is also expiry order for the same reason as above.
|
|
18841
|
+
*/
|
|
18697
18842
|
evalHistory = /* @__PURE__ */ new Map();
|
|
18698
|
-
|
|
18843
|
+
ttls;
|
|
18844
|
+
maxExecutions;
|
|
18845
|
+
now;
|
|
18846
|
+
constructor(options) {
|
|
18847
|
+
this.ttls = resolveHistoryTtls("MemoryStore", options?.ttls);
|
|
18848
|
+
this.maxExecutions = resolveMaxExecutions(options?.maxExecutions);
|
|
18849
|
+
if (options?.now !== void 0 && typeof options.now !== "function") {
|
|
18850
|
+
throw new AxlError(
|
|
18851
|
+
"INVALID_CONFIG",
|
|
18852
|
+
"MemoryStore: now must be a function returning epoch milliseconds"
|
|
18853
|
+
);
|
|
18854
|
+
}
|
|
18855
|
+
this.now = options?.now ?? (() => Date.now());
|
|
18699
18856
|
this.loadPersistedState();
|
|
18700
18857
|
}
|
|
18701
18858
|
async saveCheckpoint(executionId, name, data) {
|
|
@@ -18792,23 +18949,43 @@ var MemoryStore = class {
|
|
|
18792
18949
|
}
|
|
18793
18950
|
// ── Execution History ──────────────────────────────────────────────
|
|
18794
18951
|
async saveExecution(execution) {
|
|
18795
|
-
const
|
|
18796
|
-
|
|
18797
|
-
|
|
18952
|
+
const now = this.now();
|
|
18953
|
+
const normalized = structuredClone(
|
|
18954
|
+
normalizeStoredExecution({
|
|
18955
|
+
...execution,
|
|
18956
|
+
eventSchemaVersion: getExecutionEventSchemaVersion(execution)
|
|
18957
|
+
})
|
|
18958
|
+
);
|
|
18959
|
+
this.removeExecutionRecord(normalized.executionId);
|
|
18960
|
+
this.executionHistory.set(normalized.executionId, {
|
|
18961
|
+
record: normalized,
|
|
18962
|
+
expiresAt: expiryFrom(now, this.ttls.executionHistory)
|
|
18798
18963
|
});
|
|
18799
|
-
this.
|
|
18964
|
+
this.insertExecutionOrder(normalized);
|
|
18965
|
+
this.pruneExpiredExecutions(now);
|
|
18966
|
+
this.evictOverflowExecutions();
|
|
18800
18967
|
}
|
|
18801
18968
|
async getExecution(executionId) {
|
|
18802
|
-
const
|
|
18803
|
-
|
|
18804
|
-
|
|
18805
|
-
|
|
18806
|
-
|
|
18807
|
-
|
|
18808
|
-
|
|
18969
|
+
const stored = this.executionHistory.get(executionId);
|
|
18970
|
+
if (!stored || isExpired(stored.expiresAt, this.now())) return null;
|
|
18971
|
+
return normalizeStoredExecution(structuredClone(stored.record));
|
|
18972
|
+
}
|
|
18973
|
+
/** Supports `listExecutions(limit, { cursor })`. */
|
|
18974
|
+
historyPaging = true;
|
|
18975
|
+
async listExecutions(limit, options) {
|
|
18976
|
+
const now = this.now();
|
|
18977
|
+
const max = limit ? limit : Infinity;
|
|
18978
|
+
const result = [];
|
|
18979
|
+
const start = options?.cursor !== void 0 ? this.executionOrderLowerBound(options.cursor) - 1 : this.executionOrder.length - 1;
|
|
18980
|
+
for (let i = start; i >= 0 && result.length < max; i--) {
|
|
18981
|
+
const stored = this.executionHistory.get(this.executionOrder[i].executionId);
|
|
18982
|
+
if (isExpired(stored.expiresAt, now)) continue;
|
|
18983
|
+
result.push(normalizeStoredExecution(structuredClone(stored.record)));
|
|
18984
|
+
}
|
|
18985
|
+
return result;
|
|
18809
18986
|
}
|
|
18810
18987
|
async deleteExecution(executionId) {
|
|
18811
|
-
const removedHistory = this.
|
|
18988
|
+
const removedHistory = this.removeExecutionRecord(executionId);
|
|
18812
18989
|
const removedCheckpoints = this.checkpoints.delete(executionId);
|
|
18813
18990
|
const removedState = this.executionStates.delete(executionId);
|
|
18814
18991
|
const removedStreaming = this.streamingEvents.delete(executionId);
|
|
@@ -18819,26 +18996,193 @@ var MemoryStore = class {
|
|
|
18819
18996
|
return removedHistory || removedCheckpoints || removedState || removedStreaming || removedDecision;
|
|
18820
18997
|
}
|
|
18821
18998
|
// ── Eval History ──────────────────────────────────────────────────
|
|
18999
|
+
/**
|
|
19000
|
+
* Write an eval history row. Retention counts from the row's first write:
|
|
19001
|
+
* re-saving a live row keeps its expiry (and its position in expiry order).
|
|
19002
|
+
* A row that has already expired is replaced as a new one.
|
|
19003
|
+
*/
|
|
18822
19004
|
async saveEvalResult(entry) {
|
|
18823
|
-
this.
|
|
19005
|
+
const now = this.now();
|
|
19006
|
+
const existing = this.evalHistory.get(entry.id);
|
|
19007
|
+
if (existing && !isExpired(existing.expiresAt, now)) {
|
|
19008
|
+
this.evalHistory.set(entry.id, {
|
|
19009
|
+
entry: structuredClone(entry),
|
|
19010
|
+
expiresAt: existing.expiresAt
|
|
19011
|
+
});
|
|
19012
|
+
} else {
|
|
19013
|
+
this.evalHistory.delete(entry.id);
|
|
19014
|
+
this.evalHistory.set(entry.id, {
|
|
19015
|
+
entry: structuredClone(entry),
|
|
19016
|
+
expiresAt: expiryFrom(now, this.ttls.evalHistory)
|
|
19017
|
+
});
|
|
19018
|
+
}
|
|
19019
|
+
this.pruneExpiredEvals(now);
|
|
18824
19020
|
}
|
|
18825
|
-
/** Update-only write: see `StateStore.updateEvalResult`. */
|
|
19021
|
+
/** Update-only write: see `StateStore.updateEvalResult`. Never changes retention. */
|
|
18826
19022
|
async updateEvalResult(entry) {
|
|
18827
|
-
|
|
18828
|
-
|
|
19023
|
+
const existing = this.evalHistory.get(entry.id);
|
|
19024
|
+
if (!existing || isExpired(existing.expiresAt, this.now())) return false;
|
|
19025
|
+
this.evalHistory.set(entry.id, {
|
|
19026
|
+
entry: structuredClone(entry),
|
|
19027
|
+
expiresAt: existing.expiresAt
|
|
19028
|
+
});
|
|
18829
19029
|
return true;
|
|
18830
19030
|
}
|
|
18831
19031
|
async listEvalResults(limit) {
|
|
18832
|
-
const
|
|
19032
|
+
const now = this.now();
|
|
19033
|
+
const sorted = [...this.evalHistory.values()].filter((stored) => !isExpired(stored.expiresAt, now)).map((stored) => stored.entry).sort((a, b) => b.timestamp - a.timestamp);
|
|
18833
19034
|
const result = limit ? sorted.slice(0, limit) : sorted;
|
|
18834
19035
|
return result.map((e) => structuredClone(e));
|
|
18835
19036
|
}
|
|
18836
19037
|
async deleteEvalResult(id) {
|
|
18837
19038
|
return this.evalHistory.delete(id);
|
|
18838
19039
|
}
|
|
18839
|
-
/**
|
|
19040
|
+
/** Existence plus the expiry stored at the row's first write, if any. */
|
|
18840
19041
|
async getEvalRetention(id) {
|
|
18841
|
-
|
|
19042
|
+
const stored = this.evalHistory.get(id);
|
|
19043
|
+
if (!stored || isExpired(stored.expiresAt, this.now())) return { exists: false };
|
|
19044
|
+
return stored.expiresAt === null ? { exists: true } : { exists: true, expiresAt: stored.expiresAt };
|
|
19045
|
+
}
|
|
19046
|
+
// ── History maintenance ───────────────────────────────────────────
|
|
19047
|
+
/**
|
|
19048
|
+
* See `StateStore.applyHistoryRetention`. Every record this store holds was
|
|
19049
|
+
* saved under the retention it was constructed with, so in practice a run
|
|
19050
|
+
* finds nothing to expire or arm; the method exists so the same maintenance
|
|
19051
|
+
* code works against every built-in store. There is no separate index, so
|
|
19052
|
+
* `indexed` and `deadIndexEntries` are always `0`.
|
|
19053
|
+
*
|
|
19054
|
+
* An armed record keeps its place in last-save order, which is also where
|
|
19055
|
+
* its expiry (last save + retention) belongs, so the prune-on-save order
|
|
19056
|
+
* still holds.
|
|
19057
|
+
*/
|
|
19058
|
+
async applyHistoryRetention(options) {
|
|
19059
|
+
const { kind, batchSize, cursor } = resolveHistoryRetentionOptions("MemoryStore", options);
|
|
19060
|
+
const now = this.now();
|
|
19061
|
+
let expired = 0;
|
|
19062
|
+
let armed = 0;
|
|
19063
|
+
let positions;
|
|
19064
|
+
if (kind === "executions") {
|
|
19065
|
+
let start = 0;
|
|
19066
|
+
if (cursor !== void 0) {
|
|
19067
|
+
const key = { startedAt: cursor.time, executionId: cursor.id };
|
|
19068
|
+
start = this.executionOrderLowerBound(key);
|
|
19069
|
+
const at = this.executionOrder[start];
|
|
19070
|
+
if (at !== void 0 && compareOldestFirst(at, key) === 0) start++;
|
|
19071
|
+
}
|
|
19072
|
+
positions = this.executionOrder.slice(start, start + batchSize + 1).map((k) => ({ time: k.startedAt, id: k.executionId }));
|
|
19073
|
+
} else {
|
|
19074
|
+
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);
|
|
19075
|
+
}
|
|
19076
|
+
const batch = positions.slice(0, batchSize);
|
|
19077
|
+
const ttl = kind === "executions" ? this.ttls.executionHistory : this.ttls.evalHistory;
|
|
19078
|
+
if (ttl !== null) {
|
|
19079
|
+
for (const { id } of batch) {
|
|
19080
|
+
if (kind === "executions") {
|
|
19081
|
+
const stored = this.executionHistory.get(id);
|
|
19082
|
+
if (!stored || stored.expiresAt !== null) continue;
|
|
19083
|
+
const decision = retentionForUntimedRecord(executionLastSavedAt(stored.record), ttl, now);
|
|
19084
|
+
if (decision.expired) {
|
|
19085
|
+
this.expireExecutionRecord(id);
|
|
19086
|
+
expired++;
|
|
19087
|
+
} else {
|
|
19088
|
+
stored.expiresAt = decision.expiresAt;
|
|
19089
|
+
armed++;
|
|
19090
|
+
}
|
|
19091
|
+
} else {
|
|
19092
|
+
const stored = this.evalHistory.get(id);
|
|
19093
|
+
if (!stored || stored.expiresAt !== null) continue;
|
|
19094
|
+
const decision = retentionForUntimedRecord(stored.entry.timestamp, ttl, now);
|
|
19095
|
+
if (decision.expired) {
|
|
19096
|
+
this.evalHistory.delete(id);
|
|
19097
|
+
expired++;
|
|
19098
|
+
} else {
|
|
19099
|
+
stored.expiresAt = decision.expiresAt;
|
|
19100
|
+
armed++;
|
|
19101
|
+
}
|
|
19102
|
+
}
|
|
19103
|
+
}
|
|
19104
|
+
}
|
|
19105
|
+
return {
|
|
19106
|
+
expired,
|
|
19107
|
+
armed,
|
|
19108
|
+
indexed: 0,
|
|
19109
|
+
deadIndexEntries: 0,
|
|
19110
|
+
cursor: positions.length > batchSize ? batch[batch.length - 1] : null
|
|
19111
|
+
};
|
|
19112
|
+
}
|
|
19113
|
+
// ── History retention internals ───────────────────────────────────
|
|
19114
|
+
/** Index of the first order key not older than `key` (binary search). */
|
|
19115
|
+
executionOrderLowerBound(key) {
|
|
19116
|
+
let lo = 0;
|
|
19117
|
+
let hi = this.executionOrder.length;
|
|
19118
|
+
while (lo < hi) {
|
|
19119
|
+
const mid = lo + hi >>> 1;
|
|
19120
|
+
if (compareOldestFirst(this.executionOrder[mid], key) < 0) lo = mid + 1;
|
|
19121
|
+
else hi = mid;
|
|
19122
|
+
}
|
|
19123
|
+
return lo;
|
|
19124
|
+
}
|
|
19125
|
+
insertExecutionOrder(record) {
|
|
19126
|
+
const key = { startedAt: record.startedAt, executionId: record.executionId };
|
|
19127
|
+
this.executionOrder.splice(this.executionOrderLowerBound(key), 0, key);
|
|
19128
|
+
}
|
|
19129
|
+
/** Remove a history record and its listing-index key. Returns whether one existed. */
|
|
19130
|
+
removeExecutionRecord(executionId) {
|
|
19131
|
+
const stored = this.executionHistory.get(executionId);
|
|
19132
|
+
if (!stored) return false;
|
|
19133
|
+
this.executionHistory.delete(executionId);
|
|
19134
|
+
const index = this.executionOrderLowerBound(stored.record);
|
|
19135
|
+
if (this.executionOrder[index]?.executionId === executionId) {
|
|
19136
|
+
this.executionOrder.splice(index, 1);
|
|
19137
|
+
}
|
|
19138
|
+
return true;
|
|
19139
|
+
}
|
|
19140
|
+
/**
|
|
19141
|
+
* A run leaving history through retention (expiry or `maxExecutions`) takes
|
|
19142
|
+
* its leftover per-run data with it: checkpoints (kept today when a run
|
|
19143
|
+
* fails), saved execution state and any streaming buffer. Pending decisions
|
|
19144
|
+
* are not touched. The runtime never writes execution state, and a run is
|
|
19145
|
+
* only saved to history once it has finished, so nothing still running
|
|
19146
|
+
* depends on what is removed here.
|
|
19147
|
+
*/
|
|
19148
|
+
expireExecutionRecord(executionId) {
|
|
19149
|
+
this.removeExecutionRecord(executionId);
|
|
19150
|
+
this.checkpoints.delete(executionId);
|
|
19151
|
+
const state = this.executionStates.get(executionId);
|
|
19152
|
+
this.executionStates.delete(executionId);
|
|
19153
|
+
this.streamingEvents.delete(executionId);
|
|
19154
|
+
if (state?.status === "waiting") this.persistAwaitHumanState();
|
|
19155
|
+
}
|
|
19156
|
+
/**
|
|
19157
|
+
* Remove up to `PRUNE_BATCH` expired execution records, oldest save first.
|
|
19158
|
+
* Stops at the first live record: Map order is last-save order, which is
|
|
19159
|
+
* expiry order while the clock moves forward. If the clock steps back, a
|
|
19160
|
+
* few expired records wait for a later save; reads still filter them.
|
|
19161
|
+
*/
|
|
19162
|
+
pruneExpiredExecutions(now) {
|
|
19163
|
+
if (this.ttls.executionHistory === null) return;
|
|
19164
|
+
let removed = 0;
|
|
19165
|
+
for (const [id, stored] of this.executionHistory) {
|
|
19166
|
+
if (removed >= PRUNE_BATCH || !isExpired(stored.expiresAt, now)) break;
|
|
19167
|
+
this.expireExecutionRecord(id);
|
|
19168
|
+
removed++;
|
|
19169
|
+
}
|
|
19170
|
+
}
|
|
19171
|
+
/** Evict the oldest records in listing order until the count bound holds. */
|
|
19172
|
+
evictOverflowExecutions() {
|
|
19173
|
+
while (this.executionHistory.size > this.maxExecutions) {
|
|
19174
|
+
this.expireExecutionRecord(this.executionOrder[0].executionId);
|
|
19175
|
+
}
|
|
19176
|
+
}
|
|
19177
|
+
/** Remove up to `PRUNE_BATCH` expired eval rows, oldest first write first. */
|
|
19178
|
+
pruneExpiredEvals(now) {
|
|
19179
|
+
if (this.ttls.evalHistory === null) return;
|
|
19180
|
+
let removed = 0;
|
|
19181
|
+
for (const [id, stored] of this.evalHistory) {
|
|
19182
|
+
if (removed >= PRUNE_BATCH || !isExpired(stored.expiresAt, now)) break;
|
|
19183
|
+
this.evalHistory.delete(id);
|
|
19184
|
+
removed++;
|
|
19185
|
+
}
|
|
18842
19186
|
}
|
|
18843
19187
|
// ── Sessions (Studio introspection) ─────────────────────────────────
|
|
18844
19188
|
async listSessions() {
|
|
@@ -18916,6 +19260,7 @@ var MemoryStore = class {
|
|
|
18916
19260
|
};
|
|
18917
19261
|
|
|
18918
19262
|
// src/state/sqlite.ts
|
|
19263
|
+
var LIVE = "(expires_at IS NULL OR expires_at > ?)";
|
|
18919
19264
|
function safeJsonParse(data) {
|
|
18920
19265
|
try {
|
|
18921
19266
|
return JSON.parse(data);
|
|
@@ -18952,7 +19297,17 @@ function rowToExecutionInfo(row) {
|
|
|
18952
19297
|
}
|
|
18953
19298
|
var SQLiteStore = class {
|
|
18954
19299
|
db;
|
|
18955
|
-
|
|
19300
|
+
ttls;
|
|
19301
|
+
now;
|
|
19302
|
+
constructor(filePath, options) {
|
|
19303
|
+
this.ttls = resolveHistoryTtls("SQLiteStore", options?.ttls);
|
|
19304
|
+
if (options?.now !== void 0 && typeof options.now !== "function") {
|
|
19305
|
+
throw new AxlError(
|
|
19306
|
+
"INVALID_CONFIG",
|
|
19307
|
+
"SQLiteStore: now must be a function returning epoch milliseconds"
|
|
19308
|
+
);
|
|
19309
|
+
}
|
|
19310
|
+
this.now = options?.now ?? (() => Date.now());
|
|
18956
19311
|
let BetterSqlite3;
|
|
18957
19312
|
try {
|
|
18958
19313
|
BetterSqlite3 = __require("better-sqlite3");
|
|
@@ -18965,6 +19320,7 @@ var SQLiteStore = class {
|
|
|
18965
19320
|
this.db.pragma("journal_mode = WAL");
|
|
18966
19321
|
this.migrate();
|
|
18967
19322
|
this.initTables();
|
|
19323
|
+
if (options?.ttls?.executionHistory === void 0) warnExecutionRetentionUnset("SQLiteStore");
|
|
18968
19324
|
}
|
|
18969
19325
|
/**
|
|
18970
19326
|
* Idempotent schema migration. Tracks version via `PRAGMA user_version`
|
|
@@ -18992,6 +19348,11 @@ var SQLiteStore = class {
|
|
|
18992
19348
|
* Existing rows remain `NULL`, the documented v1 sentinel.
|
|
18993
19349
|
* - v5 (observation completeness): add `execution_history.observation`.
|
|
18994
19350
|
* Existing rows remain `NULL`, meaning they predate the contract.
|
|
19351
|
+
* - v6 (history retention): add nullable `expires_at` (epoch ms) to
|
|
19352
|
+
* `execution_history` and `eval_history`. Existing rows remain `NULL`
|
|
19353
|
+
* (never expire), so an upgrade deletes nothing. The single-column
|
|
19354
|
+
* `started_at` index is replaced by `(started_at, execution_id)`, which
|
|
19355
|
+
* serves the tie-safe listing order.
|
|
18995
19356
|
*
|
|
18996
19357
|
* Applied BEFORE `initTables()` so the subsequent `CREATE TABLE
|
|
18997
19358
|
* IF NOT EXISTS` runs against the post-migration column name. Fresh
|
|
@@ -19009,7 +19370,7 @@ var SQLiteStore = class {
|
|
|
19009
19370
|
* transactional re-read costs nothing.
|
|
19010
19371
|
*/
|
|
19011
19372
|
migrate() {
|
|
19012
|
-
const TARGET_VERSION =
|
|
19373
|
+
const TARGET_VERSION = 6;
|
|
19013
19374
|
const current = this.db.pragma("user_version", { simple: true });
|
|
19014
19375
|
if (current >= TARGET_VERSION) return;
|
|
19015
19376
|
this.db.exec("BEGIN IMMEDIATE");
|
|
@@ -19049,6 +19410,15 @@ var SQLiteStore = class {
|
|
|
19049
19410
|
this.db.exec("ALTER TABLE execution_history ADD COLUMN observation TEXT");
|
|
19050
19411
|
}
|
|
19051
19412
|
}
|
|
19413
|
+
if (committed < 6) {
|
|
19414
|
+
for (const table of ["execution_history", "eval_history"]) {
|
|
19415
|
+
const cols = this.db.pragma(`table_info(${table})`);
|
|
19416
|
+
if (cols.length > 0 && !cols.some((c) => c.name === "expires_at")) {
|
|
19417
|
+
this.db.exec(`ALTER TABLE ${table} ADD COLUMN expires_at INTEGER`);
|
|
19418
|
+
}
|
|
19419
|
+
}
|
|
19420
|
+
this.db.exec("DROP INDEX IF EXISTS idx_exec_history_started");
|
|
19421
|
+
}
|
|
19052
19422
|
this.db.pragma(`user_version = ${TARGET_VERSION}`);
|
|
19053
19423
|
this.db.exec("COMMIT");
|
|
19054
19424
|
} catch (err) {
|
|
@@ -19114,18 +19484,28 @@ var SQLiteStore = class {
|
|
|
19114
19484
|
events TEXT NOT NULL,
|
|
19115
19485
|
metadata TEXT,
|
|
19116
19486
|
event_schema_version INTEGER,
|
|
19117
|
-
observation TEXT
|
|
19487
|
+
observation TEXT,
|
|
19488
|
+
expires_at INTEGER
|
|
19118
19489
|
);
|
|
19119
19490
|
|
|
19120
19491
|
CREATE TABLE IF NOT EXISTS eval_history (
|
|
19121
19492
|
id TEXT PRIMARY KEY,
|
|
19122
19493
|
eval_name TEXT NOT NULL,
|
|
19123
19494
|
timestamp INTEGER NOT NULL,
|
|
19124
|
-
data TEXT NOT NULL
|
|
19495
|
+
data TEXT NOT NULL,
|
|
19496
|
+
expires_at INTEGER
|
|
19125
19497
|
);
|
|
19126
19498
|
|
|
19127
|
-
CREATE INDEX IF NOT EXISTS
|
|
19499
|
+
CREATE INDEX IF NOT EXISTS idx_exec_history_started_id
|
|
19500
|
+
ON execution_history (started_at DESC, execution_id DESC);
|
|
19501
|
+
CREATE INDEX IF NOT EXISTS idx_exec_history_expires ON execution_history (expires_at);
|
|
19128
19502
|
CREATE INDEX IF NOT EXISTS idx_eval_history_timestamp ON eval_history (timestamp DESC);
|
|
19503
|
+
CREATE INDEX IF NOT EXISTS idx_eval_history_expires ON eval_history (expires_at);
|
|
19504
|
+
|
|
19505
|
+
-- Superseded by idx_exec_history_started_id in v6. Dropped here as well as in
|
|
19506
|
+
-- migrate(): a pre-v6 process reopening a v6 file re-creates it, and
|
|
19507
|
+
-- migrate() never runs again once user_version is 6.
|
|
19508
|
+
DROP INDEX IF EXISTS idx_exec_history_started;
|
|
19129
19509
|
`);
|
|
19130
19510
|
}
|
|
19131
19511
|
// ── Checkpoints ────────────────────────────────────────────────────────
|
|
@@ -19238,30 +19618,71 @@ var SQLiteStore = class {
|
|
|
19238
19618
|
...execution,
|
|
19239
19619
|
eventSchemaVersion: getExecutionEventSchemaVersion(execution)
|
|
19240
19620
|
});
|
|
19621
|
+
const now = this.now();
|
|
19622
|
+
this.db.transaction(() => {
|
|
19623
|
+
this.db.prepare(
|
|
19624
|
+
"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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
|
19625
|
+
).run(
|
|
19626
|
+
normalized.executionId,
|
|
19627
|
+
normalized.workflow,
|
|
19628
|
+
normalized.status,
|
|
19629
|
+
normalized.totalCost,
|
|
19630
|
+
normalized.startedAt,
|
|
19631
|
+
normalized.completedAt ?? null,
|
|
19632
|
+
normalized.duration,
|
|
19633
|
+
normalized.error ?? null,
|
|
19634
|
+
JSON.stringify(normalized.events),
|
|
19635
|
+
normalized.metadata !== void 0 ? JSON.stringify(normalized.metadata) : null,
|
|
19636
|
+
normalized.eventSchemaVersion,
|
|
19637
|
+
normalized.observation !== void 0 ? JSON.stringify(normalized.observation) : null,
|
|
19638
|
+
expiryFrom(now, this.ttls.executionHistory)
|
|
19639
|
+
);
|
|
19640
|
+
this.pruneExpiredExecutions(now);
|
|
19641
|
+
})();
|
|
19642
|
+
}
|
|
19643
|
+
/**
|
|
19644
|
+
* Remove up to `PRUNE_BATCH` expired execution-history rows, together with
|
|
19645
|
+
* each run's leftover checkpoints and saved execution state, inside the
|
|
19646
|
+
* caller's transaction. Pending decisions are not touched. The runtime
|
|
19647
|
+
* never writes execution state, and a run is only saved to history once it
|
|
19648
|
+
* has finished, so nothing still running depends on what is removed here.
|
|
19649
|
+
*/
|
|
19650
|
+
pruneExpiredExecutions(now) {
|
|
19651
|
+
const ids = this.db.prepare(
|
|
19652
|
+
"SELECT execution_id FROM execution_history WHERE expires_at <= ? ORDER BY expires_at LIMIT ?"
|
|
19653
|
+
).all(now, PRUNE_BATCH).map((row) => row.execution_id);
|
|
19654
|
+
if (ids.length === 0) return;
|
|
19655
|
+
const idList = JSON.stringify(ids);
|
|
19656
|
+
for (const table of ["execution_history", "checkpoints", "execution_state"]) {
|
|
19657
|
+
this.db.prepare(`DELETE FROM ${table} WHERE execution_id IN (SELECT value FROM json_each(?))`).run(idList);
|
|
19658
|
+
}
|
|
19659
|
+
}
|
|
19660
|
+
/** Remove up to `PRUNE_BATCH` expired eval-history rows (caller's transaction). */
|
|
19661
|
+
pruneExpiredEvals(now) {
|
|
19241
19662
|
this.db.prepare(
|
|
19242
|
-
"
|
|
19243
|
-
).run(
|
|
19244
|
-
normalized.executionId,
|
|
19245
|
-
normalized.workflow,
|
|
19246
|
-
normalized.status,
|
|
19247
|
-
normalized.totalCost,
|
|
19248
|
-
normalized.startedAt,
|
|
19249
|
-
normalized.completedAt ?? null,
|
|
19250
|
-
normalized.duration,
|
|
19251
|
-
normalized.error ?? null,
|
|
19252
|
-
JSON.stringify(normalized.events),
|
|
19253
|
-
normalized.metadata !== void 0 ? JSON.stringify(normalized.metadata) : null,
|
|
19254
|
-
normalized.eventSchemaVersion,
|
|
19255
|
-
normalized.observation !== void 0 ? JSON.stringify(normalized.observation) : null
|
|
19256
|
-
);
|
|
19663
|
+
"DELETE FROM eval_history WHERE rowid IN (SELECT rowid FROM eval_history WHERE expires_at <= ? ORDER BY expires_at LIMIT ?)"
|
|
19664
|
+
).run(now, PRUNE_BATCH);
|
|
19257
19665
|
}
|
|
19258
19666
|
async getExecution(executionId) {
|
|
19259
|
-
const row = this.db.prepare(
|
|
19667
|
+
const row = this.db.prepare(`SELECT * FROM execution_history WHERE execution_id = ? AND ${LIVE}`).get(executionId, this.now());
|
|
19260
19668
|
return row ? rowToExecutionInfo(row) : null;
|
|
19261
19669
|
}
|
|
19262
|
-
|
|
19263
|
-
|
|
19264
|
-
|
|
19670
|
+
/** Supports `listExecutions(limit, { cursor })`. */
|
|
19671
|
+
historyPaging = true;
|
|
19672
|
+
async listExecutions(limit, options) {
|
|
19673
|
+
const cursor = options?.cursor;
|
|
19674
|
+
const params = [this.now()];
|
|
19675
|
+
let sql = `SELECT * FROM execution_history WHERE ${LIVE}`;
|
|
19676
|
+
if (cursor !== void 0) {
|
|
19677
|
+
sql += " AND (started_at < ? OR (started_at = ? AND execution_id < ?))";
|
|
19678
|
+
params.push(cursor.startedAt, cursor.startedAt, cursor.executionId);
|
|
19679
|
+
}
|
|
19680
|
+
sql += " ORDER BY started_at DESC, execution_id DESC";
|
|
19681
|
+
if (limit) {
|
|
19682
|
+
sql += " LIMIT ?";
|
|
19683
|
+
params.push(limit);
|
|
19684
|
+
}
|
|
19685
|
+
const rows = this.db.prepare(sql).all(...params);
|
|
19265
19686
|
return rows.map(rowToExecutionInfo);
|
|
19266
19687
|
}
|
|
19267
19688
|
async deleteExecution(executionId) {
|
|
@@ -19275,24 +19696,38 @@ var SQLiteStore = class {
|
|
|
19275
19696
|
return tx();
|
|
19276
19697
|
}
|
|
19277
19698
|
// ── Eval History ────────────────────────────────────────────────────────
|
|
19699
|
+
/**
|
|
19700
|
+
* Write an eval history row. Retention counts from the row's first write:
|
|
19701
|
+
* re-saving a live row keeps its `expires_at` (including none, for a row
|
|
19702
|
+
* written without retention). A row that has already expired is replaced
|
|
19703
|
+
* as a new one.
|
|
19704
|
+
*/
|
|
19278
19705
|
async saveEvalResult(entry) {
|
|
19279
|
-
this.
|
|
19280
|
-
|
|
19281
|
-
|
|
19706
|
+
const now = this.now();
|
|
19707
|
+
this.db.transaction(() => {
|
|
19708
|
+
const existing = this.db.prepare(`SELECT expires_at FROM eval_history WHERE id = ? AND ${LIVE}`).get(entry.id, now);
|
|
19709
|
+
const expiresAt = existing ? existing.expires_at : expiryFrom(now, this.ttls.evalHistory);
|
|
19710
|
+
this.db.prepare(
|
|
19711
|
+
"INSERT OR REPLACE INTO eval_history (id, eval_name, timestamp, data, expires_at) VALUES (?, ?, ?, ?, ?)"
|
|
19712
|
+
).run(entry.id, entry.eval, entry.timestamp, JSON.stringify(entry.data), expiresAt);
|
|
19713
|
+
this.pruneExpiredEvals(now);
|
|
19714
|
+
})();
|
|
19282
19715
|
}
|
|
19283
19716
|
/**
|
|
19284
19717
|
* Update-only write: see `StateStore.updateEvalResult`. `UPDATE ... WHERE id`
|
|
19285
|
-
* touches nothing when the row is gone, where `INSERT OR REPLACE`
|
|
19286
|
-
* resurrect it
|
|
19287
|
-
* permanent.
|
|
19718
|
+
* touches nothing when the row is gone or expired, where `INSERT OR REPLACE`
|
|
19719
|
+
* would resurrect it. `expires_at` is left as it was.
|
|
19288
19720
|
*/
|
|
19289
19721
|
async updateEvalResult(entry) {
|
|
19290
|
-
const result = this.db.prepare(
|
|
19722
|
+
const result = this.db.prepare(
|
|
19723
|
+
`UPDATE eval_history SET eval_name = ?, timestamp = ?, data = ? WHERE id = ? AND ${LIVE}`
|
|
19724
|
+
).run(entry.eval, entry.timestamp, JSON.stringify(entry.data), entry.id, this.now());
|
|
19291
19725
|
return result.changes > 0;
|
|
19292
19726
|
}
|
|
19293
19727
|
async listEvalResults(limit) {
|
|
19294
|
-
const
|
|
19295
|
-
const
|
|
19728
|
+
const base = `SELECT * FROM eval_history WHERE ${LIVE} ORDER BY timestamp DESC`;
|
|
19729
|
+
const now = this.now();
|
|
19730
|
+
const rows = limit ? this.db.prepare(`${base} LIMIT ?`).all(now, limit) : this.db.prepare(base).all(now);
|
|
19296
19731
|
return rows.map((r) => ({
|
|
19297
19732
|
id: r.id,
|
|
19298
19733
|
eval: r.eval_name,
|
|
@@ -19304,10 +19739,75 @@ var SQLiteStore = class {
|
|
|
19304
19739
|
const result = this.db.prepare("DELETE FROM eval_history WHERE id = ?").run(id);
|
|
19305
19740
|
return result.changes > 0;
|
|
19306
19741
|
}
|
|
19307
|
-
/**
|
|
19742
|
+
/** Existence plus the expiry stored at the row's first write, if any. */
|
|
19308
19743
|
async getEvalRetention(id) {
|
|
19309
|
-
const row = this.db.prepare(
|
|
19310
|
-
return { exists:
|
|
19744
|
+
const row = this.db.prepare(`SELECT expires_at FROM eval_history WHERE id = ? AND ${LIVE}`).get(id, this.now());
|
|
19745
|
+
if (!row) return { exists: false };
|
|
19746
|
+
return row.expires_at === null ? { exists: true } : { exists: true, expiresAt: row.expires_at };
|
|
19747
|
+
}
|
|
19748
|
+
// ── History maintenance ─────────────────────────────────────────────────
|
|
19749
|
+
/**
|
|
19750
|
+
* See `StateStore.applyHistoryRetention`. One batch runs in one immediate
|
|
19751
|
+
* transaction, so no other writer can change a row between this call
|
|
19752
|
+
* reading it and acting on it. Only rows whose `expires_at` is `NULL` are
|
|
19753
|
+
* touched. An expired execution takes its checkpoints and saved execution
|
|
19754
|
+
* state with it, as a normal expiry does. There is no separate index, so
|
|
19755
|
+
* `indexed` and `deadIndexEntries` are always `0`.
|
|
19756
|
+
*/
|
|
19757
|
+
async applyHistoryRetention(options) {
|
|
19758
|
+
const { kind, batchSize, cursor } = resolveHistoryRetentionOptions("SQLiteStore", options);
|
|
19759
|
+
const ttl = kind === "executions" ? this.ttls.executionHistory : this.ttls.evalHistory;
|
|
19760
|
+
const table = kind === "executions" ? { name: "execution_history", id: "execution_id", time: "started_at" } : { name: "eval_history", id: "id", time: "timestamp" };
|
|
19761
|
+
const lastSaved = kind === "executions" ? "completed_at" : "NULL";
|
|
19762
|
+
const now = this.now();
|
|
19763
|
+
return this.db.transaction(() => {
|
|
19764
|
+
const params = [];
|
|
19765
|
+
let sql = `SELECT ${table.id} AS id, ${table.time} AS time, ${lastSaved} AS last_saved, expires_at FROM ${table.name}`;
|
|
19766
|
+
if (cursor !== void 0) {
|
|
19767
|
+
sql += ` WHERE ${table.time} > ? OR (${table.time} = ? AND ${table.id} > ?)`;
|
|
19768
|
+
params.push(cursor.time, cursor.time, cursor.id);
|
|
19769
|
+
}
|
|
19770
|
+
sql += ` ORDER BY ${table.time}, ${table.id} LIMIT ?`;
|
|
19771
|
+
params.push(batchSize + 1);
|
|
19772
|
+
const rows = this.db.prepare(sql).all(...params);
|
|
19773
|
+
const batch = rows.slice(0, batchSize);
|
|
19774
|
+
const toExpire = [];
|
|
19775
|
+
let armed = 0;
|
|
19776
|
+
if (ttl !== null) {
|
|
19777
|
+
const arm = this.db.prepare(
|
|
19778
|
+
`UPDATE ${table.name} SET expires_at = ? WHERE ${table.id} = ? AND expires_at IS NULL`
|
|
19779
|
+
);
|
|
19780
|
+
for (const row of batch) {
|
|
19781
|
+
if (row.expires_at !== null) continue;
|
|
19782
|
+
const lastSavedAt = kind === "executions" ? executionLastSavedAt({ startedAt: row.time, completedAt: row.last_saved }) : row.time;
|
|
19783
|
+
const decision = retentionForUntimedRecord(lastSavedAt, ttl, now);
|
|
19784
|
+
if (decision.expired) toExpire.push(row.id);
|
|
19785
|
+
else armed += arm.run(decision.expiresAt, row.id).changes;
|
|
19786
|
+
}
|
|
19787
|
+
}
|
|
19788
|
+
let expired = 0;
|
|
19789
|
+
if (toExpire.length > 0) {
|
|
19790
|
+
const idList = JSON.stringify(toExpire);
|
|
19791
|
+
expired = this.db.prepare(
|
|
19792
|
+
`DELETE FROM ${table.name} WHERE expires_at IS NULL AND ${table.id} IN (SELECT value FROM json_each(?))`
|
|
19793
|
+
).run(idList).changes;
|
|
19794
|
+
if (kind === "executions") {
|
|
19795
|
+
for (const side of ["checkpoints", "execution_state"]) {
|
|
19796
|
+
this.db.prepare(
|
|
19797
|
+
`DELETE FROM ${side} WHERE execution_id IN (SELECT value FROM json_each(?))`
|
|
19798
|
+
).run(idList);
|
|
19799
|
+
}
|
|
19800
|
+
}
|
|
19801
|
+
}
|
|
19802
|
+
const last = batch[batch.length - 1];
|
|
19803
|
+
return {
|
|
19804
|
+
expired,
|
|
19805
|
+
armed,
|
|
19806
|
+
indexed: 0,
|
|
19807
|
+
deadIndexEntries: 0,
|
|
19808
|
+
cursor: rows.length > batchSize ? { time: last.time, id: last.id } : null
|
|
19809
|
+
};
|
|
19810
|
+
}).immediate();
|
|
19311
19811
|
}
|
|
19312
19812
|
// ── Sessions (Studio introspection) ────────────────────────────────────
|
|
19313
19813
|
async listSessions() {
|
|
@@ -20656,6 +21156,9 @@ function hashInput(input) {
|
|
|
20656
21156
|
}
|
|
20657
21157
|
var DEFAULT_MAX_EVENTS_PER_EXECUTION = 5e4;
|
|
20658
21158
|
var DEFAULT_STREAMING_BATCH_SIZE = 100;
|
|
21159
|
+
var DEFAULT_RECENT_EXECUTIONS_CACHE_SIZE = 1e3;
|
|
21160
|
+
var LARGE_EXECUTION_LISTING = 1e3;
|
|
21161
|
+
var EXECUTION_SCAN_PAGE_SIZE = 100;
|
|
20659
21162
|
var DEFAULT_STREAMING_BATCH_INTERVAL = 1e3;
|
|
20660
21163
|
var DEFAULT_ARTIFACT_SWEEP_MS = 6e4;
|
|
20661
21164
|
var DEFAULT_ARTIFACT_LEASE_MS = 3e5;
|
|
@@ -20868,6 +21371,78 @@ function assertNoRemovedObservationCallbacks(options) {
|
|
|
20868
21371
|
`runtime.createContext() no longer accepts ${removed.join(", ")}. Observe ctx.events or use runtime.stream() instead. See docs/migration/stream-first-observation.md.`
|
|
20869
21372
|
);
|
|
20870
21373
|
}
|
|
21374
|
+
var RecentRunsCache = class {
|
|
21375
|
+
constructor(capacity) {
|
|
21376
|
+
this.capacity = capacity;
|
|
21377
|
+
}
|
|
21378
|
+
entries = /* @__PURE__ */ new Map();
|
|
21379
|
+
get size() {
|
|
21380
|
+
return this.entries.size;
|
|
21381
|
+
}
|
|
21382
|
+
get(executionId) {
|
|
21383
|
+
const entry = this.entries.get(executionId);
|
|
21384
|
+
if (entry) {
|
|
21385
|
+
this.entries.delete(executionId);
|
|
21386
|
+
this.entries.set(executionId, entry);
|
|
21387
|
+
}
|
|
21388
|
+
return entry;
|
|
21389
|
+
}
|
|
21390
|
+
set(executionId, execution) {
|
|
21391
|
+
this.entries.delete(executionId);
|
|
21392
|
+
this.entries.set(executionId, execution);
|
|
21393
|
+
while (this.entries.size > this.capacity) {
|
|
21394
|
+
const oldest = this.entries.keys().next().value;
|
|
21395
|
+
this.entries.delete(oldest);
|
|
21396
|
+
}
|
|
21397
|
+
}
|
|
21398
|
+
delete(executionId) {
|
|
21399
|
+
return this.entries.delete(executionId);
|
|
21400
|
+
}
|
|
21401
|
+
values() {
|
|
21402
|
+
return [...this.entries.values()];
|
|
21403
|
+
}
|
|
21404
|
+
};
|
|
21405
|
+
function resolveRecentExecutionsCacheSize(requested) {
|
|
21406
|
+
if (requested === void 0) return DEFAULT_RECENT_EXECUTIONS_CACHE_SIZE;
|
|
21407
|
+
if (typeof requested !== "number" || !Number.isInteger(requested) || requested < 0) {
|
|
21408
|
+
throw new AxlError(
|
|
21409
|
+
"INVALID_CONFIG",
|
|
21410
|
+
`config.state.recentExecutionsCacheSize must be a non-negative integer; got ${String(requested)}`
|
|
21411
|
+
);
|
|
21412
|
+
}
|
|
21413
|
+
return requested;
|
|
21414
|
+
}
|
|
21415
|
+
function parseGetExecutionsOptions(options) {
|
|
21416
|
+
if (options === void 0) return {};
|
|
21417
|
+
if (options === null || typeof options !== "object") {
|
|
21418
|
+
throw new AxlError(
|
|
21419
|
+
"INVALID_ARGUMENT",
|
|
21420
|
+
`getExecutions() options must be an object; got ${options === null ? "null" : typeof options}`
|
|
21421
|
+
);
|
|
21422
|
+
}
|
|
21423
|
+
const { limit, cursor } = options;
|
|
21424
|
+
if (limit !== void 0 && limit !== Infinity && !(typeof limit === "number" && Number.isInteger(limit) && limit > 0)) {
|
|
21425
|
+
throw new AxlError(
|
|
21426
|
+
"INVALID_ARGUMENT",
|
|
21427
|
+
`getExecutions() limit must be a positive integer; got ${String(limit)}`
|
|
21428
|
+
);
|
|
21429
|
+
}
|
|
21430
|
+
let parsedCursor;
|
|
21431
|
+
if (cursor !== void 0) {
|
|
21432
|
+
const candidate = cursor;
|
|
21433
|
+
if (candidate === null || typeof candidate !== "object" || typeof candidate.startedAt !== "number" || !Number.isFinite(candidate.startedAt) || typeof candidate.executionId !== "string") {
|
|
21434
|
+
throw new AxlError(
|
|
21435
|
+
"INVALID_ARGUMENT",
|
|
21436
|
+
"getExecutions() cursor must be { startedAt: number; executionId: string }, built from the last item of the previous page"
|
|
21437
|
+
);
|
|
21438
|
+
}
|
|
21439
|
+
parsedCursor = { startedAt: candidate.startedAt, executionId: candidate.executionId };
|
|
21440
|
+
}
|
|
21441
|
+
return {
|
|
21442
|
+
...limit === void 0 || limit === Infinity ? {} : { limit },
|
|
21443
|
+
...parsedCursor ? { cursor: parsedCursor } : {}
|
|
21444
|
+
};
|
|
21445
|
+
}
|
|
20871
21446
|
var costScopeStorage = new AsyncLocalStorage4();
|
|
20872
21447
|
function forwardAbortSignal(external, internal) {
|
|
20873
21448
|
if (!external) return () => {
|
|
@@ -20914,8 +21489,6 @@ var AxlRuntime = class extends EventEmitter2 {
|
|
|
20914
21489
|
mcpManager;
|
|
20915
21490
|
memoryManager;
|
|
20916
21491
|
spanManager = new NoopSpanManager();
|
|
20917
|
-
historicalExecutions = /* @__PURE__ */ new Map();
|
|
20918
|
-
historicalExecutionsLoadPromise = null;
|
|
20919
21492
|
/** Per-session in-process serialization. Each entry is a chain of pending
|
|
20920
21493
|
* work for that session id — `Session.send` / `Session.stream` await the
|
|
20921
21494
|
* prior entry before reading history, so concurrent calls produce a
|
|
@@ -20951,12 +21524,34 @@ var AxlRuntime = class extends EventEmitter2 {
|
|
|
20951
21524
|
* a delete from resurrecting the (intentionally-removed) row. Entries are
|
|
20952
21525
|
* removed once the would-be persist has been short-circuited. */
|
|
20953
21526
|
pendingDeletedExecutions = /* @__PURE__ */ new Set();
|
|
20954
|
-
/** In-flight `persistExecution` chains (save → finalize)
|
|
20955
|
-
* awaits these before closing the StateStore so a workflow whose abort
|
|
21527
|
+
/** In-flight `persistExecution` chains (save → finalize) and crash-
|
|
21528
|
+
* recovery saves. `shutdown()` awaits these before closing the StateStore so a workflow whose abort
|
|
20956
21529
|
* triggers a persist-during-shutdown isn't racing a closed connection.
|
|
20957
21530
|
* The Set is mutated by the chain's own `.finally` so entries clear as
|
|
20958
21531
|
* soon as the work settles. */
|
|
20959
21532
|
persistInflight = /* @__PURE__ */ new Set();
|
|
21533
|
+
/** Finished runs whose history save has not settled, keyed by execution
|
|
21534
|
+
* id. Reads serve the snapshot until the save lands, so a run is visible
|
|
21535
|
+
* the moment it finishes (read-your-writes). `deleteExecution()` awaits
|
|
21536
|
+
* the entry before deleting from the store, so a save that lands late
|
|
21537
|
+
* cannot re-create a deleted run. Each entry removes itself when its save
|
|
21538
|
+
* settles (success or failure), but only if it is still the current entry
|
|
21539
|
+
* for that id, so the map holds only in-flight saves. */
|
|
21540
|
+
pendingSaves = /* @__PURE__ */ new Map();
|
|
21541
|
+
/** Recently finished runs, kept only when the store cannot save, list, or
|
|
21542
|
+
* fetch execution history itself (and the cache size is not `0`).
|
|
21543
|
+
* `undefined` for every built-in store: execution history is read through
|
|
21544
|
+
* the store on every call and never cached here. */
|
|
21545
|
+
recentRuns;
|
|
21546
|
+
/** Execution ids `recoverIncompleteStreams()` is working on, each with a
|
|
21547
|
+
* promise that settles when recovery is done with that id.
|
|
21548
|
+
* `deleteExecution()` awaits it, so a recovered record cannot land after
|
|
21549
|
+
* the delete. */
|
|
21550
|
+
recoveringExecutions = /* @__PURE__ */ new Map();
|
|
21551
|
+
/** Execution ids with a `deleteExecution()` in progress, with a count of
|
|
21552
|
+
* concurrent deletes. Recovery skips them: their store sweep removes the
|
|
21553
|
+
* streaming buffer it would otherwise turn back into a record. */
|
|
21554
|
+
deletingExecutions = /* @__PURE__ */ new Map();
|
|
20960
21555
|
/** Resolved diagnostic artifact backend, when `diagnostics.artifacts` is set. */
|
|
20961
21556
|
artifactStore;
|
|
20962
21557
|
artifactLeaseMs = DEFAULT_ARTIFACT_LEASE_MS;
|
|
@@ -20990,6 +21585,13 @@ var AxlRuntime = class extends EventEmitter2 {
|
|
|
20990
21585
|
this.providerRegistry = new ProviderRegistry();
|
|
20991
21586
|
this.transcriptionProviderRegistry = new TranscriptionProviderRegistry();
|
|
20992
21587
|
this.stateStore = this.createStateStore();
|
|
21588
|
+
const recentRunsSize = resolveRecentExecutionsCacheSize(
|
|
21589
|
+
this.config.state?.recentExecutionsCacheSize
|
|
21590
|
+
);
|
|
21591
|
+
const storeKeepsHistory = Boolean(
|
|
21592
|
+
this.stateStore.saveExecution && this.stateStore.listExecutions
|
|
21593
|
+
);
|
|
21594
|
+
this.recentRuns = !storeKeepsHistory && recentRunsSize > 0 ? new RecentRunsCache(recentRunsSize) : void 0;
|
|
20993
21595
|
const requested = this.config.state?.maxEventsPerExecution;
|
|
20994
21596
|
if (requested === void 0) {
|
|
20995
21597
|
this.maxEventsPerExecution = DEFAULT_MAX_EVENTS_PER_EXECUTION;
|
|
@@ -21395,11 +21997,15 @@ var AxlRuntime = class extends EventEmitter2 {
|
|
|
21395
21997
|
const storeOption = this.config.state?.store ?? "memory";
|
|
21396
21998
|
if (typeof storeOption !== "string") return storeOption;
|
|
21397
21999
|
switch (storeOption) {
|
|
21398
|
-
case "sqlite":
|
|
21399
|
-
|
|
22000
|
+
case "sqlite": {
|
|
22001
|
+
const create = () => new SQLiteStore(this.config.state?.sqlite?.path ?? "./data/axl.db", {
|
|
22002
|
+
ttls: this.config.state?.sqlite?.ttls
|
|
22003
|
+
});
|
|
22004
|
+
return this.config.diagnostics?.silent ? withDiagnosticsSilenced(create) : create();
|
|
22005
|
+
}
|
|
21400
22006
|
case "memory":
|
|
21401
22007
|
default:
|
|
21402
|
-
return new MemoryStore();
|
|
22008
|
+
return new MemoryStore(this.config.state?.memory);
|
|
21403
22009
|
}
|
|
21404
22010
|
}
|
|
21405
22011
|
/**
|
|
@@ -21487,13 +22093,15 @@ var AxlRuntime = class extends EventEmitter2 {
|
|
|
21487
22093
|
}
|
|
21488
22094
|
/**
|
|
21489
22095
|
* Persist a completed/failed execution to the state store (fire-and-forget)
|
|
21490
|
-
* and
|
|
22096
|
+
* and remove it from the active executions map. Until the save settles the
|
|
22097
|
+
* run is served from `pendingSaves`; afterwards from the store (or, for a
|
|
22098
|
+
* store that cannot keep history, from `recentRuns`).
|
|
21491
22099
|
*
|
|
21492
22100
|
* Resurrection guard: if `runtime.deleteExecution(id)` was called while
|
|
21493
22101
|
* this execution was still running, the id will be in
|
|
21494
|
-
* `pendingDeletedExecutions`. In that case we skip the save AND
|
|
21495
|
-
*
|
|
21496
|
-
* the buffer doesn't outlive the delete.
|
|
22102
|
+
* `pendingDeletedExecutions`. In that case we skip the save AND every
|
|
22103
|
+
* in-process record of the run, and finalize the streaming buffer (if any)
|
|
22104
|
+
* so the buffer doesn't outlive the delete.
|
|
21497
22105
|
*/
|
|
21498
22106
|
persistExecution(execInfo) {
|
|
21499
22107
|
const id = execInfo.executionId;
|
|
@@ -21524,14 +22132,19 @@ var AxlRuntime = class extends EventEmitter2 {
|
|
|
21524
22132
|
}).catch(() => {
|
|
21525
22133
|
}).then(() => void 0);
|
|
21526
22134
|
this.persistInflight.add(chain);
|
|
21527
|
-
|
|
22135
|
+
const pending = { snapshot, settled: chain };
|
|
22136
|
+
this.pendingSaves.set(id, pending);
|
|
22137
|
+
chain.finally(() => {
|
|
22138
|
+
this.persistInflight.delete(chain);
|
|
22139
|
+
if (this.pendingSaves.get(id) === pending) this.pendingSaves.delete(id);
|
|
22140
|
+
});
|
|
21528
22141
|
} else if (this.streamingFlusher) {
|
|
21529
22142
|
const chain = this.streamingFlusher.finalize(id).catch(() => {
|
|
21530
22143
|
});
|
|
21531
22144
|
this.persistInflight.add(chain);
|
|
21532
22145
|
chain.finally(() => this.persistInflight.delete(chain));
|
|
21533
22146
|
}
|
|
21534
|
-
this.
|
|
22147
|
+
this.recentRuns?.set(id, snapshot);
|
|
21535
22148
|
this.executions.delete(id);
|
|
21536
22149
|
}
|
|
21537
22150
|
/** Register a workflow with the runtime. */
|
|
@@ -21657,7 +22270,6 @@ var AxlRuntime = class extends EventEmitter2 {
|
|
|
21657
22270
|
}
|
|
21658
22271
|
return result;
|
|
21659
22272
|
}
|
|
21660
|
-
/** Get all execution info (running + completed + historical). */
|
|
21661
22273
|
/** Normalize an execution loaded from a `StateStore` so the
|
|
21662
22274
|
* historical event union holds for downstream consumers
|
|
21663
22275
|
* (Studio aggregators, REST routes, redaction). Custom or legacy stores
|
|
@@ -21677,27 +22289,78 @@ var AxlRuntime = class extends EventEmitter2 {
|
|
|
21677
22289
|
}
|
|
21678
22290
|
return normalizeStoredExecution(exec);
|
|
21679
22291
|
}
|
|
21680
|
-
|
|
21681
|
-
|
|
21682
|
-
|
|
21683
|
-
|
|
21684
|
-
|
|
21685
|
-
|
|
21686
|
-
|
|
21687
|
-
|
|
21688
|
-
|
|
21689
|
-
|
|
21690
|
-
|
|
21691
|
-
|
|
21692
|
-
|
|
21693
|
-
|
|
21694
|
-
|
|
21695
|
-
|
|
21696
|
-
|
|
21697
|
-
|
|
21698
|
-
|
|
22292
|
+
/**
|
|
22293
|
+
* List executions, newest first: running runs, runs whose save is still in
|
|
22294
|
+
* flight, and the history the state store holds. Ordered by `startedAt`,
|
|
22295
|
+
* then `executionId`, both descending.
|
|
22296
|
+
*
|
|
22297
|
+
* With no options, returns every retained run. Pass `limit` for one page,
|
|
22298
|
+
* and `cursor` (built from the last item of the previous page) for the
|
|
22299
|
+
* page after it. Each call reads through the store, so runs finished or
|
|
22300
|
+
* deleted by other runtimes sharing the store are reflected immediately.
|
|
22301
|
+
* A call without `limit` that returns more than 1 000 runs warns once per
|
|
22302
|
+
* process (silenced by `diagnostics.silent`); pass `limit: Infinity` to ask
|
|
22303
|
+
* for everything without the warning.
|
|
22304
|
+
*
|
|
22305
|
+
* @throws {AxlError} `INVALID_ARGUMENT` for a `limit` that is not a positive
|
|
22306
|
+
* integer (`Infinity` means no limit) or a malformed `cursor`.
|
|
22307
|
+
*/
|
|
22308
|
+
async getExecutions(options) {
|
|
22309
|
+
const { limit, cursor } = parseGetExecutionsOptions(options);
|
|
22310
|
+
const isAfterCursor = (exec) => cursor === void 0 || compareExecutionsNewestFirst(exec, cursor) > 0;
|
|
22311
|
+
const live = new Map(this.executions);
|
|
22312
|
+
for (const [id, { snapshot }] of this.pendingSaves) {
|
|
22313
|
+
if (!live.has(id)) live.set(id, snapshot);
|
|
22314
|
+
}
|
|
22315
|
+
const recent = this.recentRunsServeListing() ? this.recentRuns.values() : [];
|
|
22316
|
+
const stored = await this.listStoredExecutions({
|
|
22317
|
+
limit,
|
|
22318
|
+
cursor,
|
|
22319
|
+
overlap: live.size + recent.length
|
|
22320
|
+
});
|
|
22321
|
+
const merged = /* @__PURE__ */ new Map();
|
|
22322
|
+
for (const [id, exec] of live) {
|
|
22323
|
+
if (isAfterCursor(exec)) merged.set(id, exec);
|
|
22324
|
+
}
|
|
22325
|
+
for (const exec of stored) {
|
|
22326
|
+
const id = exec.executionId;
|
|
22327
|
+
if (!live.has(id) && !merged.has(id)) merged.set(id, exec);
|
|
22328
|
+
}
|
|
22329
|
+
for (const exec of recent) {
|
|
22330
|
+
const id = exec.executionId;
|
|
22331
|
+
if (!live.has(id) && !merged.has(id) && isAfterCursor(exec)) merged.set(id, exec);
|
|
22332
|
+
}
|
|
22333
|
+
const sorted = [...merged.values()].sort(compareExecutionsNewestFirst);
|
|
22334
|
+
if (limit !== void 0) return sorted.slice(0, limit);
|
|
22335
|
+
if (options?.limit === void 0 && sorted.length > LARGE_EXECUTION_LISTING) {
|
|
22336
|
+
warnDiagnosticOnce(
|
|
22337
|
+
"large_execution_listing",
|
|
22338
|
+
`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.`,
|
|
22339
|
+
this.config.diagnostics?.silent,
|
|
22340
|
+
"docs/migration/execution-history-retention.md#page-through-large-histories"
|
|
22341
|
+
);
|
|
21699
22342
|
}
|
|
21700
|
-
return
|
|
22343
|
+
return sorted;
|
|
22344
|
+
}
|
|
22345
|
+
/**
|
|
22346
|
+
* Read one page of execution history from the store: the records strictly
|
|
22347
|
+
* after `cursor`, newest first, at least `limit + overlap` of them when the
|
|
22348
|
+
* store holds that many (all of them when `limit` is unset).
|
|
22349
|
+
*
|
|
22350
|
+
* Only a store that declares `historyPaging` is handed the cursor. Any
|
|
22351
|
+
* other store is listed in full and paged here: filtering the output of a
|
|
22352
|
+
* store that ignored the cursor would return its first page again, and an
|
|
22353
|
+
* empty page after that.
|
|
22354
|
+
*/
|
|
22355
|
+
async listStoredExecutions(page) {
|
|
22356
|
+
const store = this.stateStore;
|
|
22357
|
+
if (!store.listExecutions) return [];
|
|
22358
|
+
const wanted = page.limit === void 0 ? void 0 : page.limit + page.overlap;
|
|
22359
|
+
const rows = store.historyPaging === true ? await store.listExecutions(wanted, { cursor: page.cursor }) : await store.listExecutions();
|
|
22360
|
+
const cursor = page.cursor;
|
|
22361
|
+
const after = cursor === void 0 ? rows : rows.filter((row) => compareExecutionsNewestFirst(row, cursor) > 0);
|
|
22362
|
+
const window = wanted === void 0 ? after : [...after].sort(compareExecutionsNewestFirst).slice(0, wanted);
|
|
22363
|
+
return window.map((row) => this.normalizeStoredExecution(row));
|
|
21701
22364
|
}
|
|
21702
22365
|
/**
|
|
21703
22366
|
* Create a WorkflowContext for ad-hoc use (evals, tool testing, prototyping).
|
|
@@ -22128,20 +22791,25 @@ var AxlRuntime = class extends EventEmitter2 {
|
|
|
22128
22791
|
* Recover executions whose process died mid-run when `state.persist:
|
|
22129
22792
|
* 'streaming'` was configured. Synthesizes a partial `ExecutionInfo`
|
|
22130
22793
|
* from the streaming buffer for each — `status: 'failed'`, error
|
|
22131
|
-
* `'process terminated'` —
|
|
22132
|
-
* they show up in `getExecutions()
|
|
22133
|
-
*
|
|
22794
|
+
* `'process terminated'` — persists them via the store's
|
|
22795
|
+
* `saveExecution` (so they show up in `getExecutions()`), and finalizes
|
|
22796
|
+
* the streaming buffer.
|
|
22134
22797
|
*
|
|
22135
22798
|
* Idempotent — if the streaming buffer is already gone (or
|
|
22136
22799
|
* `listStreamingExecutions` is empty), this is a no-op. Returns the
|
|
22137
22800
|
* list of recovered `ExecutionInfo`s for caller introspection.
|
|
22138
22801
|
*
|
|
22139
|
-
*
|
|
22140
|
-
*
|
|
22141
|
-
*
|
|
22142
|
-
*
|
|
22143
|
-
*
|
|
22144
|
-
*
|
|
22802
|
+
* A run whose final record already exists is never recovered: for each
|
|
22803
|
+
* buffered id this asks the store's `getExecution(id)` and, when a record
|
|
22804
|
+
* is there, only drops the leftover buffer. It does not list history, so
|
|
22805
|
+
* its cost does not grow with the size of the store. A store without
|
|
22806
|
+
* `getExecution` cannot be checked, and every buffered run is recovered.
|
|
22807
|
+
*
|
|
22808
|
+
* Wire this into process startup, before the process accepts new work:
|
|
22809
|
+
* a run live in another process looks the same as a crashed one.
|
|
22810
|
+
* `deleteExecution(id)` called while recovery is working on that id waits
|
|
22811
|
+
* for recovery to finish with it, so the recovered record cannot outlive
|
|
22812
|
+
* the delete.
|
|
22145
22813
|
*/
|
|
22146
22814
|
async recoverIncompleteStreams() {
|
|
22147
22815
|
if (!this.stateStore.listStreamingExecutions || !this.stateStore.getStreamingEvents) {
|
|
@@ -22149,73 +22817,119 @@ var AxlRuntime = class extends EventEmitter2 {
|
|
|
22149
22817
|
}
|
|
22150
22818
|
const ids = await this.stateStore.listStreamingExecutions();
|
|
22151
22819
|
if (ids.length === 0) return [];
|
|
22152
|
-
await this.getExecutions();
|
|
22153
22820
|
const recovered = [];
|
|
22154
22821
|
for (const id of ids) {
|
|
22155
22822
|
if (this.executions.has(id) || this.streamableExecutionIds.has(id)) {
|
|
22156
22823
|
continue;
|
|
22157
22824
|
}
|
|
22158
|
-
|
|
22159
|
-
|
|
22160
|
-
|
|
22161
|
-
|
|
22162
|
-
|
|
22163
|
-
|
|
22164
|
-
|
|
22165
|
-
|
|
22166
|
-
|
|
22167
|
-
|
|
22168
|
-
|
|
22169
|
-
|
|
22170
|
-
|
|
22171
|
-
|
|
22172
|
-
}
|
|
22825
|
+
if (this.deletingExecutions.has(id) || this.recoveringExecutions.has(id)) continue;
|
|
22826
|
+
let finishRecovery;
|
|
22827
|
+
this.recoveringExecutions.set(
|
|
22828
|
+
id,
|
|
22829
|
+
new Promise((resolve2) => {
|
|
22830
|
+
finishRecovery = resolve2;
|
|
22831
|
+
})
|
|
22832
|
+
);
|
|
22833
|
+
try {
|
|
22834
|
+
const result = await this.recoverStreamingExecution(id);
|
|
22835
|
+
if (result) recovered.push(result);
|
|
22836
|
+
} finally {
|
|
22837
|
+
this.recoveringExecutions.delete(id);
|
|
22838
|
+
finishRecovery();
|
|
22173
22839
|
}
|
|
22174
|
-
|
|
22175
|
-
|
|
22176
|
-
|
|
22177
|
-
|
|
22178
|
-
|
|
22179
|
-
|
|
22180
|
-
|
|
22181
|
-
|
|
22182
|
-
|
|
22183
|
-
|
|
22184
|
-
|
|
22185
|
-
|
|
22186
|
-
|
|
22187
|
-
|
|
22188
|
-
|
|
22189
|
-
|
|
22190
|
-
|
|
22191
|
-
|
|
22192
|
-
|
|
22193
|
-
|
|
22194
|
-
|
|
22195
|
-
|
|
22196
|
-
|
|
22197
|
-
|
|
22198
|
-
|
|
22199
|
-
|
|
22200
|
-
|
|
22201
|
-
|
|
22202
|
-
|
|
22840
|
+
}
|
|
22841
|
+
return recovered;
|
|
22842
|
+
}
|
|
22843
|
+
/** Recover one buffered execution. Returns the synthesized record, or
|
|
22844
|
+
* `undefined` when there was nothing to recover. */
|
|
22845
|
+
async recoverStreamingExecution(id) {
|
|
22846
|
+
if (!this.stateStore.getStreamingEvents) return void 0;
|
|
22847
|
+
const existing = await this.stateStore.getExecution?.(id);
|
|
22848
|
+
if (existing) {
|
|
22849
|
+
await this.stateStore.finalizeStreamingEvents?.(id);
|
|
22850
|
+
return void 0;
|
|
22851
|
+
}
|
|
22852
|
+
const events = await this.stateStore.getStreamingEvents(id);
|
|
22853
|
+
if (events.length === 0) {
|
|
22854
|
+
await this.stateStore.finalizeStreamingEvents?.(id);
|
|
22855
|
+
return void 0;
|
|
22856
|
+
}
|
|
22857
|
+
const eventSchemaVersion = getEventSchemaVersion(events[0]);
|
|
22858
|
+
for (const event of events) {
|
|
22859
|
+
if (getEventSchemaVersion(event) !== eventSchemaVersion) {
|
|
22860
|
+
throw new Error(`Streaming buffer ${id} contains mixed event schema versions`);
|
|
22861
|
+
}
|
|
22862
|
+
}
|
|
22863
|
+
const boundedEvents = boundRecoveredEvents(events, this.maxEventsPerExecution);
|
|
22864
|
+
const workflowStartEvent = boundedEvents.find((e) => e.type === "workflow_start");
|
|
22865
|
+
const lastEvent = boundedEvents[boundedEvents.length - 1];
|
|
22866
|
+
const startedAt = workflowStartEvent?.timestamp ?? boundedEvents[0]?.timestamp ?? Date.now();
|
|
22867
|
+
const completedAt = lastEvent.timestamp ?? startedAt;
|
|
22868
|
+
const workflowName = workflowStartEvent?.workflow ?? RECOVERED_UNKNOWN_WORKFLOW;
|
|
22869
|
+
const totalCost = boundedEvents.reduce((sum, e) => sum + eventCostContribution(e), 0);
|
|
22870
|
+
const unpriced = boundedEvents.some(isUnpricedLeaf);
|
|
22871
|
+
const executionBase = {
|
|
22872
|
+
executionId: id,
|
|
22873
|
+
workflow: workflowName,
|
|
22874
|
+
status: "failed",
|
|
22875
|
+
events: boundedEvents,
|
|
22876
|
+
totalCost,
|
|
22877
|
+
unpriced,
|
|
22878
|
+
startedAt,
|
|
22879
|
+
completedAt,
|
|
22880
|
+
duration: completedAt - startedAt,
|
|
22881
|
+
error: "process terminated (recovered from streaming buffer)"
|
|
22882
|
+
};
|
|
22883
|
+
const synthesized = eventSchemaVersion === 2 ? {
|
|
22884
|
+
...executionBase,
|
|
22885
|
+
eventSchemaVersion: 2,
|
|
22886
|
+
events: boundedEvents,
|
|
22887
|
+
observation: { complete: false, reason: "process_interrupted" }
|
|
22888
|
+
} : {
|
|
22889
|
+
...executionBase,
|
|
22890
|
+
eventSchemaVersion: 1,
|
|
22891
|
+
events: boundedEvents
|
|
22892
|
+
};
|
|
22893
|
+
const saveExecution = this.stateStore.saveExecution;
|
|
22894
|
+
if (saveExecution) {
|
|
22895
|
+
const save = (async () => saveExecution.call(this.stateStore, synthesized))();
|
|
22896
|
+
const pending = {
|
|
22897
|
+
snapshot: synthesized,
|
|
22898
|
+
settled: save.then(
|
|
22899
|
+
() => void 0,
|
|
22900
|
+
() => void 0
|
|
22901
|
+
)
|
|
22203
22902
|
};
|
|
22204
|
-
|
|
22205
|
-
|
|
22206
|
-
|
|
22207
|
-
|
|
22208
|
-
|
|
22209
|
-
|
|
22210
|
-
)
|
|
22211
|
-
|
|
22212
|
-
|
|
22903
|
+
this.pendingSaves.set(id, pending);
|
|
22904
|
+
this.persistInflight.add(pending.settled);
|
|
22905
|
+
try {
|
|
22906
|
+
await save;
|
|
22907
|
+
} catch (err) {
|
|
22908
|
+
console.error(
|
|
22909
|
+
`[axl] recoverIncompleteStreams: failed to save synthesized execution ${id}: ${err instanceof Error ? err.message : String(err)}. Streaming buffer left in place for next recovery attempt.`
|
|
22910
|
+
);
|
|
22911
|
+
return void 0;
|
|
22912
|
+
} finally {
|
|
22913
|
+
this.persistInflight.delete(pending.settled);
|
|
22914
|
+
if (this.pendingSaves.get(id) === pending) this.pendingSaves.delete(id);
|
|
22213
22915
|
}
|
|
22214
|
-
this.historicalExecutions.set(id, synthesized);
|
|
22215
|
-
await this.stateStore.finalizeStreamingEvents?.(id);
|
|
22216
|
-
recovered.push(synthesized);
|
|
22217
22916
|
}
|
|
22218
|
-
|
|
22917
|
+
this.recentRuns?.set(id, synthesized);
|
|
22918
|
+
const finalize = Promise.resolve(this.stateStore.finalizeStreamingEvents?.(id)).then(
|
|
22919
|
+
() => void 0,
|
|
22920
|
+
(err) => {
|
|
22921
|
+
console.error(
|
|
22922
|
+
`[axl] recoverIncompleteStreams: saved execution ${id} but failed to drop its streaming buffer: ${err instanceof Error ? err.message : String(err)}. The next recovery removes it.`
|
|
22923
|
+
);
|
|
22924
|
+
}
|
|
22925
|
+
);
|
|
22926
|
+
this.persistInflight.add(finalize);
|
|
22927
|
+
try {
|
|
22928
|
+
await finalize;
|
|
22929
|
+
} finally {
|
|
22930
|
+
this.persistInflight.delete(finalize);
|
|
22931
|
+
}
|
|
22932
|
+
return synthesized;
|
|
22219
22933
|
}
|
|
22220
22934
|
/** Abort a running execution by its ID. */
|
|
22221
22935
|
abort(executionId) {
|
|
@@ -22225,21 +22939,57 @@ var AxlRuntime = class extends EventEmitter2 {
|
|
|
22225
22939
|
this.abortControllers.delete(executionId);
|
|
22226
22940
|
}
|
|
22227
22941
|
}
|
|
22228
|
-
/**
|
|
22942
|
+
/**
|
|
22943
|
+
* Get execution details by ID. Looks in order at running runs, runs whose
|
|
22944
|
+
* save is still in flight, the state store (its `getExecution`, or a scan
|
|
22945
|
+
* of its listing when it has `listExecutions` but no `getExecution`), and
|
|
22946
|
+
* (only for a store that cannot list or save history) the recent-runs
|
|
22947
|
+
* cache. Nothing read from the store is cached, so a run deleted or
|
|
22948
|
+
* expired elsewhere is gone on the next call, and every run
|
|
22949
|
+
* `getExecutions()` lists can be fetched here.
|
|
22950
|
+
*/
|
|
22229
22951
|
async getExecution(executionId) {
|
|
22230
|
-
const
|
|
22231
|
-
if (
|
|
22232
|
-
const
|
|
22233
|
-
if (
|
|
22234
|
-
|
|
22235
|
-
|
|
22236
|
-
|
|
22237
|
-
|
|
22238
|
-
|
|
22239
|
-
|
|
22240
|
-
|
|
22952
|
+
const active = this.executions.get(executionId);
|
|
22953
|
+
if (active) return active;
|
|
22954
|
+
const pending = this.pendingSaves.get(executionId);
|
|
22955
|
+
if (pending) return pending.snapshot;
|
|
22956
|
+
const stored = this.stateStore.getExecution ? await this.stateStore.getExecution(executionId) : await this.findInStoreListing(executionId);
|
|
22957
|
+
if (stored) return this.normalizeStoredExecution(stored);
|
|
22958
|
+
return this.recentRunsServeReads() ? this.recentRuns.get(executionId) : void 0;
|
|
22959
|
+
}
|
|
22960
|
+
/**
|
|
22961
|
+
* By-id read for a store that lists but cannot fetch by id. A paging store
|
|
22962
|
+
* is scanned page by page with the cursor, stopping at the match; any
|
|
22963
|
+
* other store is listed once in full. Returns `null` when the store cannot
|
|
22964
|
+
* list or does not hold the run.
|
|
22965
|
+
*/
|
|
22966
|
+
async findInStoreListing(executionId) {
|
|
22967
|
+
const store = this.stateStore;
|
|
22968
|
+
if (!store.listExecutions) return null;
|
|
22969
|
+
if (store.historyPaging !== true) {
|
|
22970
|
+
return (await store.listExecutions()).find((e) => e.executionId === executionId) ?? null;
|
|
22241
22971
|
}
|
|
22242
|
-
|
|
22972
|
+
let cursor;
|
|
22973
|
+
for (; ; ) {
|
|
22974
|
+
const page = await store.listExecutions(EXECUTION_SCAN_PAGE_SIZE, { cursor });
|
|
22975
|
+
const found = page.find((e) => e.executionId === executionId);
|
|
22976
|
+
if (found) return found;
|
|
22977
|
+
if (page.length === 0) return null;
|
|
22978
|
+
const last = [...page].sort(compareExecutionsNewestFirst)[page.length - 1];
|
|
22979
|
+
if (cursor && compareExecutionsNewestFirst(last, cursor) <= 0) return null;
|
|
22980
|
+
cursor = { startedAt: last.startedAt, executionId: last.executionId };
|
|
22981
|
+
}
|
|
22982
|
+
}
|
|
22983
|
+
/** The recent-runs cache stands in for listing when the store cannot list
|
|
22984
|
+
* runs, or never receives them because it cannot save. */
|
|
22985
|
+
recentRunsServeListing() {
|
|
22986
|
+
return this.recentRuns !== void 0 && (!this.stateStore.saveExecution || !this.stateStore.listExecutions);
|
|
22987
|
+
}
|
|
22988
|
+
/** The recent-runs cache stands in for by-id reads when the store cannot
|
|
22989
|
+
* fetch or list runs (it exists only then), or never receives them because
|
|
22990
|
+
* it cannot save. */
|
|
22991
|
+
recentRunsServeReads() {
|
|
22992
|
+
return this.recentRuns !== void 0 && (!this.stateStore.saveExecution || !this.stateStore.getExecution);
|
|
22243
22993
|
}
|
|
22244
22994
|
/** Save an eval result to history. */
|
|
22245
22995
|
async saveEvalResult(entry) {
|
|
@@ -22272,11 +23022,16 @@ var AxlRuntime = class extends EventEmitter2 {
|
|
|
22272
23022
|
}
|
|
22273
23023
|
/**
|
|
22274
23024
|
* Delete an execution from history by id. Removes from the active map (if
|
|
22275
|
-
* still running), the
|
|
23025
|
+
* still running), the recent-runs cache, the configured StateStore, AND
|
|
22276
23026
|
* any execution-scoped side state — pending awaitHuman decisions,
|
|
22277
23027
|
* resolver maps, streaming buffer registration, in-flight abort controller.
|
|
22278
|
-
* Returns true if
|
|
22279
|
-
*
|
|
23028
|
+
* Returns true if the run was running, had a save in flight, or was
|
|
23029
|
+
* removed from the recent-runs cache or the store.
|
|
23030
|
+
*
|
|
23031
|
+
* If the run has already finished but its history save has not landed
|
|
23032
|
+
* yet, this method waits for that save (whether it succeeds or fails)
|
|
23033
|
+
* before deleting, so the save cannot re-create the deleted record. It
|
|
23034
|
+
* likewise waits for `recoverIncompleteStreams()` to finish with the id.
|
|
22280
23035
|
*
|
|
22281
23036
|
* In-flight handling: if the execution is still running, this method
|
|
22282
23037
|
* **aborts** it (via the registered abort controller) AND marks the id for
|
|
@@ -22301,7 +23056,18 @@ var AxlRuntime = class extends EventEmitter2 {
|
|
|
22301
23056
|
* (`defaultTtl` / `ttls.executionHistory`).
|
|
22302
23057
|
*/
|
|
22303
23058
|
async deleteExecution(id) {
|
|
23059
|
+
this.deletingExecutions.set(id, (this.deletingExecutions.get(id) ?? 0) + 1);
|
|
23060
|
+
try {
|
|
23061
|
+
return await this.deleteExecutionWhileMarked(id);
|
|
23062
|
+
} finally {
|
|
23063
|
+
const remaining = (this.deletingExecutions.get(id) ?? 1) - 1;
|
|
23064
|
+
if (remaining > 0) this.deletingExecutions.set(id, remaining);
|
|
23065
|
+
else this.deletingExecutions.delete(id);
|
|
23066
|
+
}
|
|
23067
|
+
}
|
|
23068
|
+
async deleteExecutionWhileMarked(id) {
|
|
22304
23069
|
const wasActive = this.executions.has(id);
|
|
23070
|
+
const hadPendingSave = this.pendingSaves.has(id);
|
|
22305
23071
|
const pendingDecisionResolver = this.pendingDecisionResolvers.get(id);
|
|
22306
23072
|
const hadPendingDecision = pendingDecisionResolver !== void 0;
|
|
22307
23073
|
if (wasActive) {
|
|
@@ -22319,22 +23085,25 @@ var AxlRuntime = class extends EventEmitter2 {
|
|
|
22319
23085
|
this.pendingDecisionResolvers.delete(id);
|
|
22320
23086
|
}
|
|
22321
23087
|
}
|
|
23088
|
+
await this.pendingSaves.get(id)?.settled;
|
|
23089
|
+
await this.recoveringExecutions.get(id);
|
|
22322
23090
|
const existing = await this.getExecution(id);
|
|
22323
23091
|
const workflow2 = existing?.workflow;
|
|
22324
23092
|
const removedFromActive = this.executions.delete(id);
|
|
22325
|
-
const
|
|
23093
|
+
const removedFromRecent = this.recentRuns?.delete(id) ?? false;
|
|
22326
23094
|
let removedFromStore = false;
|
|
22327
23095
|
if (this.stateStore.deleteExecution) {
|
|
22328
23096
|
removedFromStore = await this.stateStore.deleteExecution(id);
|
|
22329
23097
|
}
|
|
23098
|
+
const removed = removedFromActive || hadPendingSave || removedFromRecent || removedFromStore;
|
|
22330
23099
|
this.emit("execution_deleted", {
|
|
22331
23100
|
executionId: id,
|
|
22332
23101
|
workflow: workflow2,
|
|
22333
23102
|
wasActive,
|
|
22334
23103
|
hadPendingDecision,
|
|
22335
|
-
removed
|
|
23104
|
+
removed
|
|
22336
23105
|
});
|
|
22337
|
-
return
|
|
23106
|
+
return removed;
|
|
22338
23107
|
}
|
|
22339
23108
|
/**
|
|
22340
23109
|
* Delete an eval history entry by id. Removes from in-memory cache and
|
|
@@ -23752,12 +24521,57 @@ async function* stringStreamFromEvents(source, opts) {
|
|
|
23752
24521
|
}
|
|
23753
24522
|
|
|
23754
24523
|
// src/state/redis.ts
|
|
24524
|
+
var PRUNE_HISTORY_INDEX_SCRIPT = `
|
|
24525
|
+
local now = tonumber(ARGV[1])
|
|
24526
|
+
local removed = 0
|
|
24527
|
+
for i = 2, #ARGV do
|
|
24528
|
+
local id = ARGV[i]
|
|
24529
|
+
local pttl = redis.call('PTTL', KEYS[i + 2])
|
|
24530
|
+
if pttl == -2 then
|
|
24531
|
+
redis.call('SREM', KEYS[1], id)
|
|
24532
|
+
redis.call('ZREM', KEYS[2], id)
|
|
24533
|
+
redis.call('ZREM', KEYS[3], id)
|
|
24534
|
+
removed = removed + 1
|
|
24535
|
+
elseif pttl == -1 then
|
|
24536
|
+
redis.call('ZREM', KEYS[3], id)
|
|
24537
|
+
else
|
|
24538
|
+
redis.call('ZADD', KEYS[3], string.format('%d', now + pttl), id)
|
|
24539
|
+
end
|
|
24540
|
+
end
|
|
24541
|
+
return removed
|
|
24542
|
+
`;
|
|
24543
|
+
function compareMembers(a, b) {
|
|
24544
|
+
return Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"));
|
|
24545
|
+
}
|
|
24546
|
+
function compareNewestFirst(a, b) {
|
|
24547
|
+
if (a.score !== b.score) return b.score - a.score;
|
|
24548
|
+
return compareMembers(b.id, a.id);
|
|
24549
|
+
}
|
|
24550
|
+
function warnIfRedisExecutionRetentionUnset(opts) {
|
|
24551
|
+
if (opts.ttls?.executionHistory === void 0 && typeof opts.defaultTtl !== "number") {
|
|
24552
|
+
warnExecutionRetentionUnset("RedisStore");
|
|
24553
|
+
}
|
|
24554
|
+
}
|
|
23755
24555
|
var RedisStore = class _RedisStore {
|
|
23756
24556
|
constructor(client, keyPrefix, ttls) {
|
|
23757
24557
|
this.client = client;
|
|
23758
24558
|
this.keyPrefix = keyPrefix;
|
|
23759
24559
|
this.ttls = ttls;
|
|
23760
24560
|
}
|
|
24561
|
+
/**
|
|
24562
|
+
* History kinds with a prune running in this process. A save skips its
|
|
24563
|
+
* prune while another one of the same kind is in flight: the running one
|
|
24564
|
+
* already covers the oldest expired entries. Created lazily so the
|
|
24565
|
+
* instance needs no constructor state.
|
|
24566
|
+
*/
|
|
24567
|
+
prunesInFlight;
|
|
24568
|
+
/**
|
|
24569
|
+
* Supports `listExecutions(limit, { cursor })`. A getter rather than a
|
|
24570
|
+
* field so it lives on the prototype.
|
|
24571
|
+
*/
|
|
24572
|
+
get historyPaging() {
|
|
24573
|
+
return true;
|
|
24574
|
+
}
|
|
23761
24575
|
/**
|
|
23762
24576
|
* Resolve the TTL for a given storage category. Returns `undefined` when
|
|
23763
24577
|
* no TTL applies (category is `null` in resolved config, the value is
|
|
@@ -23836,6 +24650,7 @@ var RedisStore = class _RedisStore {
|
|
|
23836
24650
|
streamingEvents: resolveStreamingEvents(opts.ttls?.streamingEvents)
|
|
23837
24651
|
};
|
|
23838
24652
|
const store = new _RedisStore(client, keyPrefix, effectiveTtls);
|
|
24653
|
+
warnIfRedisExecutionRetentionUnset(opts);
|
|
23839
24654
|
if (!opts.skipMigration) {
|
|
23840
24655
|
await store.backfillExecutionIndex();
|
|
23841
24656
|
await store.backfillEvalIndex();
|
|
@@ -23947,13 +24762,9 @@ var RedisStore = class _RedisStore {
|
|
|
23947
24762
|
execHistoryKey(executionId) {
|
|
23948
24763
|
return `${this.keyPrefix}exec-history:${executionId}`;
|
|
23949
24764
|
}
|
|
23950
|
-
//
|
|
23951
|
-
//
|
|
23952
|
-
//
|
|
23953
|
-
// The slow-path fallback (`listHistoryByZset`) reads from the SET when
|
|
23954
|
-
// `zsetCard < setCard`, so SET-based eviction would race with the fallback
|
|
23955
|
-
// and surface dead IDs whose data has been TTL'd away. ZSET eviction +
|
|
23956
|
-
// dual-write `SREM` is the clean path; doc the assumption then.
|
|
24765
|
+
// The legacy id set and the sorted set below always change together (save,
|
|
24766
|
+
// delete, prune), so the slow-path guard `ZCARD >= SCARD` in
|
|
24767
|
+
// `listHistoryByZset` and backfill stays meaningful.
|
|
23957
24768
|
execHistorySetKey() {
|
|
23958
24769
|
return `${this.keyPrefix}exec-history-ids`;
|
|
23959
24770
|
}
|
|
@@ -23976,6 +24787,36 @@ var RedisStore = class _RedisStore {
|
|
|
23976
24787
|
evalHistoryZsetKey() {
|
|
23977
24788
|
return `${this.keyPrefix}eval-history-z`;
|
|
23978
24789
|
}
|
|
24790
|
+
/** Keys and blob parsing for one kind of history. See {@link HistoryIndex}. */
|
|
24791
|
+
historyIndex(kind) {
|
|
24792
|
+
if (kind === "execution") {
|
|
24793
|
+
return {
|
|
24794
|
+
kind,
|
|
24795
|
+
label: "execution-history",
|
|
24796
|
+
ids: this.execHistorySetKey(),
|
|
24797
|
+
time: this.execHistoryZsetKey(),
|
|
24798
|
+
expiry: `${this.keyPrefix}exec-history-exp`,
|
|
24799
|
+
dataKey: (id) => this.execHistoryKey(id),
|
|
24800
|
+
lastSavedAtOf: (raw) => {
|
|
24801
|
+
const record = JSON.parse(raw);
|
|
24802
|
+
return typeof record.startedAt === "number" ? executionLastSavedAt(record) : null;
|
|
24803
|
+
}
|
|
24804
|
+
};
|
|
24805
|
+
}
|
|
24806
|
+
return {
|
|
24807
|
+
kind,
|
|
24808
|
+
label: "eval-history",
|
|
24809
|
+
ids: this.evalHistorySetKey(),
|
|
24810
|
+
time: this.evalHistoryZsetKey(),
|
|
24811
|
+
expiry: `${this.keyPrefix}eval-history-exp`,
|
|
24812
|
+
dataKey: (id) => this.evalHistoryKey(id),
|
|
24813
|
+
// Eval retention counts from the first write, which is the row's timestamp.
|
|
24814
|
+
lastSavedAtOf: (raw) => {
|
|
24815
|
+
const timestamp = JSON.parse(raw).timestamp;
|
|
24816
|
+
return typeof timestamp === "number" ? timestamp : null;
|
|
24817
|
+
}
|
|
24818
|
+
};
|
|
24819
|
+
}
|
|
23979
24820
|
memoryKey(scope) {
|
|
23980
24821
|
return `${this.keyPrefix}memory:${scope}`;
|
|
23981
24822
|
}
|
|
@@ -24147,31 +24988,35 @@ var RedisStore = class _RedisStore {
|
|
|
24147
24988
|
async saveExecution(execution) {
|
|
24148
24989
|
const ttl = this.ttlFor("executionHistory");
|
|
24149
24990
|
const setOptions = ttl !== void 0 ? { EX: ttl } : void 0;
|
|
24991
|
+
const index = this.historyIndex("execution");
|
|
24992
|
+
const id = execution.executionId;
|
|
24150
24993
|
const storedExecution = normalizeStoredExecution({
|
|
24151
24994
|
...execution,
|
|
24152
24995
|
eventSchemaVersion: getExecutionEventSchemaVersion(execution)
|
|
24153
24996
|
});
|
|
24154
|
-
|
|
24155
|
-
|
|
24156
|
-
|
|
24157
|
-
|
|
24997
|
+
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 });
|
|
24998
|
+
if (ttl !== void 0) tx.zAdd(index.expiry, { score: Date.now() + ttl * 1e3, value: id });
|
|
24999
|
+
else tx.zRem(index.expiry, id);
|
|
25000
|
+
await tx.exec();
|
|
25001
|
+
await this.pruneHistoryAfterSave(index);
|
|
24158
25002
|
}
|
|
24159
25003
|
async getExecution(executionId) {
|
|
24160
25004
|
const raw = await this.client.get(this.execHistoryKey(executionId));
|
|
24161
25005
|
return raw ? normalizeStoredExecution(JSON.parse(raw)) : null;
|
|
24162
25006
|
}
|
|
24163
|
-
async listExecutions(limit) {
|
|
25007
|
+
async listExecutions(limit, options) {
|
|
25008
|
+
const cursor = options?.cursor;
|
|
24164
25009
|
const executions = await this.listHistoryByZset(
|
|
24165
|
-
this.
|
|
24166
|
-
|
|
24167
|
-
|
|
24168
|
-
|
|
24169
|
-
limit
|
|
25010
|
+
this.historyIndex("execution"),
|
|
25011
|
+
(e) => ({ score: e.startedAt, id: e.executionId }),
|
|
25012
|
+
limit,
|
|
25013
|
+
cursor !== void 0 ? { score: cursor.startedAt, id: cursor.executionId } : void 0
|
|
24170
25014
|
);
|
|
24171
25015
|
return executions.map(normalizeStoredExecution);
|
|
24172
25016
|
}
|
|
24173
25017
|
async deleteExecution(executionId) {
|
|
24174
|
-
const
|
|
25018
|
+
const index = this.historyIndex("execution");
|
|
25019
|
+
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();
|
|
24175
25020
|
return results.some((result) => typeof result === "number" && result > 0);
|
|
24176
25021
|
}
|
|
24177
25022
|
// ── Eval History ────────────────────────────────────────────────────
|
|
@@ -24196,18 +25041,31 @@ var RedisStore = class _RedisStore {
|
|
|
24196
25041
|
* lifetime, so the race costs at most one window's length, never the window
|
|
24197
25042
|
* itself. It also carries no Redis version floor, where `KEEPTTL` needs 6.0.
|
|
24198
25043
|
*
|
|
24199
|
-
*
|
|
25044
|
+
* As with `saveExecution`, a timed row is also scored in the expiry sorted
|
|
25045
|
+
* set (by the expiry it carries, so a re-save keeps its score), an untimed
|
|
25046
|
+
* one is taken out of it, and a bounded prune follows the save.
|
|
24200
25047
|
*/
|
|
24201
25048
|
async saveEvalResult(entry) {
|
|
24202
25049
|
const ttl = this.ttlFor("evalHistory");
|
|
24203
|
-
const
|
|
25050
|
+
const index = this.historyIndex("eval");
|
|
25051
|
+
const key = index.dataKey(entry.id);
|
|
24204
25052
|
let setOptions;
|
|
25053
|
+
let expiresAt;
|
|
24205
25054
|
if (ttl !== void 0) {
|
|
24206
25055
|
const remaining = await this.client.pTTL(key);
|
|
24207
|
-
if (remaining === -2)
|
|
24208
|
-
|
|
25056
|
+
if (remaining === -2) {
|
|
25057
|
+
setOptions = { EX: ttl };
|
|
25058
|
+
expiresAt = Date.now() + ttl * 1e3;
|
|
25059
|
+
} else if (remaining > 0) {
|
|
25060
|
+
setOptions = { PX: remaining };
|
|
25061
|
+
expiresAt = Date.now() + remaining;
|
|
25062
|
+
}
|
|
24209
25063
|
}
|
|
24210
|
-
|
|
25064
|
+
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 });
|
|
25065
|
+
if (expiresAt !== void 0) tx.zAdd(index.expiry, { score: expiresAt, value: entry.id });
|
|
25066
|
+
else tx.zRem(index.expiry, entry.id);
|
|
25067
|
+
await tx.exec();
|
|
25068
|
+
await this.pruneHistoryAfterSave(index);
|
|
24211
25069
|
}
|
|
24212
25070
|
/**
|
|
24213
25071
|
* Update-only write: see `StateStore.updateEvalResult`.
|
|
@@ -24247,15 +25105,14 @@ var RedisStore = class _RedisStore {
|
|
|
24247
25105
|
}
|
|
24248
25106
|
async listEvalResults(limit) {
|
|
24249
25107
|
return this.listHistoryByZset(
|
|
24250
|
-
this.
|
|
24251
|
-
|
|
24252
|
-
(id) => this.evalHistoryKey(id),
|
|
24253
|
-
(a, b) => b.timestamp - a.timestamp,
|
|
25108
|
+
this.historyIndex("eval"),
|
|
25109
|
+
(e) => ({ score: e.timestamp, id: e.id }),
|
|
24254
25110
|
limit
|
|
24255
25111
|
);
|
|
24256
25112
|
}
|
|
24257
25113
|
async deleteEvalResult(id) {
|
|
24258
|
-
const
|
|
25114
|
+
const index = this.historyIndex("eval");
|
|
25115
|
+
const [, deletedCount] = await this.client.multi().sRem(index.ids, id).del(index.dataKey(id)).zRem(index.time, id).zRem(index.expiry, id).exec();
|
|
24259
25116
|
const deleted = typeof deletedCount === "number" ? deletedCount : 0;
|
|
24260
25117
|
return deleted > 0;
|
|
24261
25118
|
}
|
|
@@ -24279,50 +25136,291 @@ var RedisStore = class _RedisStore {
|
|
|
24279
25136
|
return { exists: true, expiresAt: Date.now() + ttl };
|
|
24280
25137
|
}
|
|
24281
25138
|
/**
|
|
24282
|
-
* Shared list-by-sorted-set implementation
|
|
24283
|
-
*
|
|
24284
|
-
*
|
|
24285
|
-
*
|
|
24286
|
-
*
|
|
25139
|
+
* Shared list-by-sorted-set implementation, newest first by
|
|
25140
|
+
* `(score DESC, member DESC)`, starting strictly after `cursor` when given.
|
|
25141
|
+
*
|
|
25142
|
+
* Fast path (the time sorted set is at least as large as the id set): walk
|
|
25143
|
+
* the sorted set in chunks and MGET each chunk's blobs. Index entries whose
|
|
25144
|
+
* blob is gone (expired, or deleted before the prune caught up) are skipped,
|
|
25145
|
+
* and the walk keeps reading until it has `limit` live records or the set
|
|
25146
|
+
* runs out, however many dead entries are in the way. Each chunk restarts
|
|
25147
|
+
* at the last member read, by score and member, rather than at an offset,
|
|
25148
|
+
* so entries added or removed meanwhile cannot shift the walk.
|
|
25149
|
+
*
|
|
25150
|
+
* Slow path (the time sorted set is behind the id set: backfill has not
|
|
25151
|
+
* run): read every id with GET, then sort and apply the cursor in process.
|
|
24287
25152
|
*/
|
|
24288
|
-
async listHistoryByZset(
|
|
24289
|
-
const setSize = await this.client.sCard(
|
|
25153
|
+
async listHistoryByZset(index, positionOf, limit, cursor) {
|
|
25154
|
+
const setSize = await this.client.sCard(index.ids);
|
|
24290
25155
|
if (setSize === 0) return [];
|
|
24291
|
-
const zsetSize = await this.client.zCard(
|
|
24292
|
-
|
|
24293
|
-
|
|
24294
|
-
const
|
|
25156
|
+
const zsetSize = await this.client.zCard(index.time);
|
|
25157
|
+
const max = limit ? limit : void 0;
|
|
25158
|
+
if (zsetSize < setSize) {
|
|
25159
|
+
const ids = await this.client.sMembers(index.ids);
|
|
25160
|
+
const entries = [];
|
|
25161
|
+
for (const id of ids) {
|
|
25162
|
+
const raw = await this.client.get(index.dataKey(id));
|
|
25163
|
+
if (raw == null) continue;
|
|
25164
|
+
try {
|
|
25165
|
+
entries.push(JSON.parse(raw));
|
|
25166
|
+
} catch {
|
|
25167
|
+
}
|
|
25168
|
+
}
|
|
25169
|
+
const ordered = entries.filter((e) => cursor === void 0 || compareNewestFirst(cursor, positionOf(e)) < 0).sort((a, b) => compareNewestFirst(positionOf(a), positionOf(b)));
|
|
25170
|
+
return max !== void 0 ? ordered.slice(0, max) : ordered;
|
|
25171
|
+
}
|
|
25172
|
+
const results = [];
|
|
25173
|
+
const seen = /* @__PURE__ */ new Set();
|
|
25174
|
+
let bound = cursor;
|
|
25175
|
+
let rangeMax = cursor !== void 0 ? cursor.score : "+inf";
|
|
25176
|
+
let anchor;
|
|
25177
|
+
let grow = 1;
|
|
25178
|
+
const read = (offset, count2) => (
|
|
25179
|
+
// ZRANGE BY SCORE REV: start is the highest score, stop the lowest.
|
|
25180
|
+
this.client.zRangeWithScores(index.time, rangeMax, "-inf", {
|
|
24295
25181
|
BY: "SCORE",
|
|
24296
25182
|
REV: true,
|
|
24297
|
-
...
|
|
25183
|
+
...count2 !== void 0 ? { LIMIT: { offset, count: count2 } } : {}
|
|
25184
|
+
})
|
|
25185
|
+
);
|
|
25186
|
+
for (; ; ) {
|
|
25187
|
+
const remaining = max !== void 0 ? max - results.length : void 0;
|
|
25188
|
+
const want = remaining !== void 0 ? Math.max(remaining * 2, remaining + 5) * grow : void 0;
|
|
25189
|
+
let offset = 0;
|
|
25190
|
+
let count2 = want;
|
|
25191
|
+
let members;
|
|
25192
|
+
if (anchor !== void 0 && want !== void 0) {
|
|
25193
|
+
offset = anchor.pos;
|
|
25194
|
+
count2 = want + 1;
|
|
25195
|
+
members = await read(offset, count2);
|
|
25196
|
+
if (members[0]?.value !== anchor.id) {
|
|
25197
|
+
offset = 0;
|
|
25198
|
+
count2 = anchor.pos + 1 + want;
|
|
25199
|
+
members = await read(offset, count2);
|
|
25200
|
+
}
|
|
25201
|
+
} else {
|
|
25202
|
+
members = await read(offset, count2);
|
|
25203
|
+
}
|
|
25204
|
+
const fresh = members.filter(
|
|
25205
|
+
(m) => !seen.has(m.value) && (bound === void 0 || compareNewestFirst(bound, { score: m.score, id: m.value }) < 0)
|
|
25206
|
+
);
|
|
25207
|
+
if (fresh.length > 0) {
|
|
25208
|
+
const raws = await this.client.mGet(fresh.map((m) => index.dataKey(m.value)));
|
|
25209
|
+
for (let i = 0; i < fresh.length; i++) {
|
|
25210
|
+
seen.add(fresh[i].value);
|
|
25211
|
+
const raw = raws[i];
|
|
25212
|
+
if (raw == null) continue;
|
|
25213
|
+
try {
|
|
25214
|
+
results.push(JSON.parse(raw));
|
|
25215
|
+
} catch {
|
|
25216
|
+
continue;
|
|
25217
|
+
}
|
|
25218
|
+
if (max !== void 0 && results.length >= max) return results;
|
|
25219
|
+
}
|
|
25220
|
+
const lastFresh = fresh[fresh.length - 1];
|
|
25221
|
+
bound = { score: lastFresh.score, id: lastFresh.value };
|
|
25222
|
+
grow = 1;
|
|
25223
|
+
} else {
|
|
25224
|
+
grow *= 2;
|
|
25225
|
+
}
|
|
25226
|
+
if (count2 === void 0 || members.length < count2) return results;
|
|
25227
|
+
const last = members[members.length - 1];
|
|
25228
|
+
if (last.score === rangeMax) {
|
|
25229
|
+
anchor = { id: last.value, score: last.score, pos: offset + members.length - 1 };
|
|
25230
|
+
} else {
|
|
25231
|
+
rangeMax = last.score;
|
|
25232
|
+
const before = members.filter((m) => m.score === last.score).length - 1;
|
|
25233
|
+
anchor = { id: last.value, score: last.score, pos: before };
|
|
25234
|
+
}
|
|
25235
|
+
}
|
|
25236
|
+
}
|
|
25237
|
+
// ── History index cleanup ──────────────────────────────────────────────
|
|
25238
|
+
//
|
|
25239
|
+
// Index members cannot carry a TTL, so when a history record expires its
|
|
25240
|
+
// entries in the id set and the time sorted set stay behind. Every history
|
|
25241
|
+
// save runs one bounded prune of its kind: it reads up to PRUNE_BATCH ids
|
|
25242
|
+
// whose expiry has passed from the expiry sorted set, then runs
|
|
25243
|
+
// PRUNE_HISTORY_INDEX_SCRIPT over them. The script checks each record's blob
|
|
25244
|
+
// and updates the indexes in one atomic step, so a save or delete landing
|
|
25245
|
+
// between the read and the script cannot be undone by it: a record saved
|
|
25246
|
+
// again is seen live and stays listed, and a deleted one is already gone.
|
|
25247
|
+
// The script only removes index entries and re-scores expiry entries; it
|
|
25248
|
+
// never adds a listing entry or writes a blob, so it cannot bring back
|
|
25249
|
+
// deleted data. Several instances pruning at once is safe.
|
|
25250
|
+
/**
|
|
25251
|
+
* Run one prune of `index`'s kind after a save. A prune failure does not
|
|
25252
|
+
* fail the save, whose MULTI already committed the record: it is reported
|
|
25253
|
+
* once per process and the next save of that kind tries again. That also
|
|
25254
|
+
* covers a Redis user whose ACL denies `EVAL`.
|
|
25255
|
+
*/
|
|
25256
|
+
async pruneHistoryAfterSave(index) {
|
|
25257
|
+
const inFlight = this.prunesInFlight ??= /* @__PURE__ */ new Set();
|
|
25258
|
+
if (inFlight.has(index.kind)) return;
|
|
25259
|
+
inFlight.add(index.kind);
|
|
25260
|
+
try {
|
|
25261
|
+
await this.pruneHistory(index);
|
|
25262
|
+
} catch (err) {
|
|
25263
|
+
warnDiagnosticOnce(
|
|
25264
|
+
`redis_history_prune_failed\0${index.kind}`,
|
|
25265
|
+
`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.`,
|
|
25266
|
+
void 0,
|
|
25267
|
+
"docs/api-reference.md#redisstore-options"
|
|
25268
|
+
);
|
|
25269
|
+
} finally {
|
|
25270
|
+
inFlight.delete(index.kind);
|
|
25271
|
+
}
|
|
25272
|
+
}
|
|
25273
|
+
async pruneHistory(index) {
|
|
25274
|
+
const candidates = await this.client.zRange(index.expiry, "-inf", Date.now(), {
|
|
25275
|
+
BY: "SCORE",
|
|
25276
|
+
LIMIT: { offset: 0, count: PRUNE_BATCH }
|
|
25277
|
+
});
|
|
25278
|
+
if (candidates.length > 0) await this.runPruneScript(index, candidates);
|
|
25279
|
+
}
|
|
25280
|
+
/**
|
|
25281
|
+
* Run {@link PRUNE_HISTORY_INDEX_SCRIPT} over `ids`, at most PRUNE_BATCH
|
|
25282
|
+
* per call so one script never blocks the server for long.
|
|
25283
|
+
*/
|
|
25284
|
+
async runPruneScript(index, ids) {
|
|
25285
|
+
for (let start = 0; start < ids.length; start += PRUNE_BATCH) {
|
|
25286
|
+
const chunk = ids.slice(start, start + PRUNE_BATCH);
|
|
25287
|
+
await this.client.eval(PRUNE_HISTORY_INDEX_SCRIPT, {
|
|
25288
|
+
keys: [index.ids, index.time, index.expiry, ...chunk.map((id) => index.dataKey(id))],
|
|
25289
|
+
arguments: [String(Date.now()), ...chunk]
|
|
24298
25290
|
});
|
|
24299
|
-
|
|
24300
|
-
|
|
24301
|
-
|
|
24302
|
-
|
|
24303
|
-
|
|
24304
|
-
|
|
25291
|
+
}
|
|
25292
|
+
}
|
|
25293
|
+
// ── History maintenance ────────────────────────────────────────────────
|
|
25294
|
+
/**
|
|
25295
|
+
* See `StateStore.applyHistoryRetention`. One call:
|
|
25296
|
+
*
|
|
25297
|
+
* 1. On the first call of a walk (no cursor), backfills the time sorted
|
|
25298
|
+
* set from the id set if it is behind (see `backfillExecutionIndex`).
|
|
25299
|
+
* 2. Reads the next `batchSize` ids from the time sorted set, oldest first
|
|
25300
|
+
* by `(score, member)`, strictly after the cursor. The read restarts at
|
|
25301
|
+
* the cursor's position by value, never by offset.
|
|
25302
|
+
* 3. MGETs their blobs and reads each live blob's PTTL.
|
|
25303
|
+
* 4. In one MULTI, for each live blob:
|
|
25304
|
+
* - a TTL (`PTTL > 0`): `ZADD NX` to the expiry set at now + PTTL. A
|
|
25305
|
+
* record already in the expiry set keeps its entry (`indexed` counts
|
|
25306
|
+
* only new entries).
|
|
25307
|
+
* - no TTL (`-1`), with a retention configured: `PEXPIREAT … NX` at last
|
|
25308
|
+
* save + retention. `NX` applies only to a key that still has no TTL,
|
|
25309
|
+
* so a record re-saved meanwhile keeps the fresh window its save gave
|
|
25310
|
+
* it. A window that has already passed uses a timestamp in the past,
|
|
25311
|
+
* which makes Redis delete the key, again only if it is still untimed.
|
|
25312
|
+
* 5. Adds each newly armed record to the expiry set (`ZADD NX`).
|
|
25313
|
+
* 6. Runs the prune script (`PRUNE_HISTORY_INDEX_SCRIPT`) over ids whose
|
|
25314
|
+
* blob is gone and ids just deleted in step 4. It checks each blob
|
|
25315
|
+
* atomically, so a record re-saved at any point stays listed and a
|
|
25316
|
+
* deleted one is never re-indexed.
|
|
25317
|
+
*
|
|
25318
|
+
* Blobs are never written. A record deleted while the call runs can leave
|
|
25319
|
+
* an entry in the expiry set only; the next prune removes it.
|
|
25320
|
+
*/
|
|
25321
|
+
async applyHistoryRetention(options) {
|
|
25322
|
+
const { kind, batchSize, cursor } = resolveHistoryRetentionOptions("RedisStore", options);
|
|
25323
|
+
const index = this.historyIndex(kind === "executions" ? "execution" : "eval");
|
|
25324
|
+
const ttl = this.ttlFor(kind === "executions" ? "executionHistory" : "evalHistory");
|
|
25325
|
+
if (cursor === void 0) {
|
|
25326
|
+
if (kind === "executions") await this.backfillExecutionIndex();
|
|
25327
|
+
else await this.backfillEvalIndex();
|
|
25328
|
+
}
|
|
25329
|
+
const positions = await this.readRetentionBatch(index, cursor, batchSize + 1);
|
|
25330
|
+
const batch = positions.slice(0, batchSize);
|
|
25331
|
+
const result = {
|
|
25332
|
+
expired: 0,
|
|
25333
|
+
armed: 0,
|
|
25334
|
+
indexed: 0,
|
|
25335
|
+
deadIndexEntries: 0,
|
|
25336
|
+
cursor: positions.length > batchSize ? { time: batch[batch.length - 1].score, id: batch[batch.length - 1].value } : null
|
|
25337
|
+
};
|
|
25338
|
+
if (batch.length === 0) return result;
|
|
25339
|
+
const ids = batch.map((m) => m.value);
|
|
25340
|
+
const raws = await this.client.mGet(ids.map((id) => index.dataKey(id)));
|
|
25341
|
+
const dead = ids.filter((_, i) => raws[i] == null);
|
|
25342
|
+
const present = ids.flatMap((id, i) => {
|
|
25343
|
+
const raw = raws[i];
|
|
25344
|
+
return raw == null ? [] : [{ id, raw }];
|
|
25345
|
+
});
|
|
25346
|
+
const pttls = await Promise.all(present.map((r) => this.client.pTTL(index.dataKey(r.id))));
|
|
25347
|
+
const now = Date.now();
|
|
25348
|
+
const tx = this.client.multi();
|
|
25349
|
+
const queued = [];
|
|
25350
|
+
for (let i = 0; i < present.length; i++) {
|
|
25351
|
+
const { id, raw } = present[i];
|
|
25352
|
+
const pttl = pttls[i];
|
|
25353
|
+
if (pttl === -2) {
|
|
25354
|
+
dead.push(id);
|
|
25355
|
+
} else if (pttl > 0) {
|
|
25356
|
+
tx.zAdd(index.expiry, { score: now + pttl, value: id }, { condition: "NX" });
|
|
25357
|
+
queued.push({ action: "index", id });
|
|
25358
|
+
} else if (pttl === -1 && ttl !== void 0) {
|
|
25359
|
+
let lastSavedAt;
|
|
24305
25360
|
try {
|
|
24306
|
-
|
|
25361
|
+
lastSavedAt = index.lastSavedAtOf(raw);
|
|
24307
25362
|
} catch {
|
|
25363
|
+
lastSavedAt = null;
|
|
25364
|
+
}
|
|
25365
|
+
if (lastSavedAt === null) continue;
|
|
25366
|
+
const decision = retentionForUntimedRecord(lastSavedAt, ttl, now);
|
|
25367
|
+
if (decision.expired) {
|
|
25368
|
+
tx.pExpireAt(index.dataKey(id), 1, "NX");
|
|
25369
|
+
queued.push({ action: "expire", id });
|
|
25370
|
+
} else {
|
|
25371
|
+
tx.pExpireAt(index.dataKey(id), decision.expiresAt, "NX");
|
|
25372
|
+
queued.push({ action: "arm", id, expiresAt: decision.expiresAt });
|
|
24308
25373
|
}
|
|
24309
|
-
if (limit !== void 0 && entries2.length >= limit) break;
|
|
24310
25374
|
}
|
|
24311
|
-
return entries2;
|
|
24312
25375
|
}
|
|
24313
|
-
const
|
|
24314
|
-
|
|
24315
|
-
|
|
24316
|
-
|
|
24317
|
-
|
|
24318
|
-
|
|
24319
|
-
|
|
24320
|
-
|
|
24321
|
-
|
|
25376
|
+
const expiredIds = [];
|
|
25377
|
+
const armed = [];
|
|
25378
|
+
if (queued.length > 0) {
|
|
25379
|
+
const replies = await tx.exec();
|
|
25380
|
+
for (let i = 0; i < queued.length; i++) {
|
|
25381
|
+
if (Number(replies[i]) !== 1) continue;
|
|
25382
|
+
const entry = queued[i];
|
|
25383
|
+
if (entry.action === "index") result.indexed++;
|
|
25384
|
+
else if (entry.action === "expire") expiredIds.push(entry.id);
|
|
25385
|
+
else armed.push(entry);
|
|
24322
25386
|
}
|
|
24323
25387
|
}
|
|
24324
|
-
|
|
24325
|
-
|
|
25388
|
+
if (armed.length > 0) {
|
|
25389
|
+
const indexTx = this.client.multi();
|
|
25390
|
+
for (const { id, expiresAt } of armed) {
|
|
25391
|
+
indexTx.zAdd(index.expiry, { score: expiresAt, value: id }, { condition: "NX" });
|
|
25392
|
+
}
|
|
25393
|
+
await indexTx.exec();
|
|
25394
|
+
}
|
|
25395
|
+
result.armed = armed.length;
|
|
25396
|
+
result.expired = expiredIds.length;
|
|
25397
|
+
result.deadIndexEntries = dead.length;
|
|
25398
|
+
if (dead.length > 0 || expiredIds.length > 0) {
|
|
25399
|
+
await this.runPruneScript(index, [...dead, ...expiredIds]);
|
|
25400
|
+
}
|
|
25401
|
+
return result;
|
|
25402
|
+
}
|
|
25403
|
+
/**
|
|
25404
|
+
* Up to `count` members of the time sorted set strictly after `cursor`,
|
|
25405
|
+
* oldest first by `(score, member)`. The members sharing the cursor's score
|
|
25406
|
+
* are read in full and filtered by member; the rest start just above that
|
|
25407
|
+
* score. Neither read uses an offset, so entries removed or added
|
|
25408
|
+
* meanwhile cannot shift the walk.
|
|
25409
|
+
*/
|
|
25410
|
+
async readRetentionBatch(index, cursor, count2) {
|
|
25411
|
+
if (cursor === void 0) {
|
|
25412
|
+
return this.client.zRangeWithScores(index.time, "-inf", "+inf", {
|
|
25413
|
+
BY: "SCORE",
|
|
25414
|
+
LIMIT: { offset: 0, count: count2 }
|
|
25415
|
+
});
|
|
25416
|
+
}
|
|
25417
|
+
const ties = (await this.client.zRangeWithScores(index.time, cursor.time, cursor.time, { BY: "SCORE" })).filter((m) => compareMembers(m.value, cursor.id) > 0);
|
|
25418
|
+
if (ties.length >= count2) return ties.slice(0, count2);
|
|
25419
|
+
const rest = await this.client.zRangeWithScores(index.time, `(${cursor.time}`, "+inf", {
|
|
25420
|
+
BY: "SCORE",
|
|
25421
|
+
LIMIT: { offset: 0, count: count2 - ties.length }
|
|
25422
|
+
});
|
|
25423
|
+
return [...ties, ...rest];
|
|
24326
25424
|
}
|
|
24327
25425
|
// ── Sessions (Studio introspection) ────────────────────────────────────
|
|
24328
25426
|
async listSessions() {
|