@axlsdk/axl 0.27.0 → 0.27.2
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/dist/index.cjs +574 -59
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +140 -22
- package/dist/index.d.ts +140 -22
- package/dist/index.js +574 -59
- 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.27.
|
|
351
|
+
version: true ? "0.27.2" : "unknown"
|
|
352
352
|
};
|
|
353
353
|
function warnCrossCopy(other, joined) {
|
|
354
354
|
if (other === thisCopy) return;
|
|
@@ -17026,7 +17026,7 @@ ${appendedSchemaText}`;
|
|
|
17026
17026
|
`ctx.checkpoint name "${name}" is reserved \u2014 names starting with "__auto/" (case-insensitive) are used by the runtime for ask/spawn/race/parallel/map auto-checkpointing.`
|
|
17027
17027
|
);
|
|
17028
17028
|
}
|
|
17029
|
-
return this._checkpoint(name, fn);
|
|
17029
|
+
return this._checkpoint(name, fn, { saveRequired: true });
|
|
17030
17030
|
}
|
|
17031
17031
|
/**
|
|
17032
17032
|
* Internal checkpoint implementation shared by both the public
|
|
@@ -17056,7 +17056,17 @@ ${appendedSchemaText}`;
|
|
|
17056
17056
|
return saved;
|
|
17057
17057
|
}
|
|
17058
17058
|
const result = await fn();
|
|
17059
|
-
|
|
17059
|
+
if (!options?.saveRequired && this.autoCheckpointCounters.saveFailed) return result;
|
|
17060
|
+
try {
|
|
17061
|
+
await this.stateStore.saveCheckpoint(this.executionId, name, result);
|
|
17062
|
+
} catch (err) {
|
|
17063
|
+
if (options?.saveRequired) throw err;
|
|
17064
|
+
this.autoCheckpointCounters.saveFailed = true;
|
|
17065
|
+
console.error(
|
|
17066
|
+
`[axl] Failed to save checkpoint "${name}" for executionId=${this.executionId}; a replay would run this step and later ones again: ${err instanceof Error ? err.message : String(err)}`
|
|
17067
|
+
);
|
|
17068
|
+
return result;
|
|
17069
|
+
}
|
|
17060
17070
|
this.emitEvent({
|
|
17061
17071
|
type: "checkpoint_save",
|
|
17062
17072
|
...agentStamp,
|
|
@@ -18804,6 +18814,21 @@ function codePointRank(unit) {
|
|
|
18804
18814
|
import { writeFileSync, readFileSync, existsSync, mkdirSync, unlinkSync } from "fs";
|
|
18805
18815
|
import { join } from "path";
|
|
18806
18816
|
import { tmpdir } from "os";
|
|
18817
|
+
|
|
18818
|
+
// src/state/session-update.ts
|
|
18819
|
+
async function updateSessionThroughOverrides(store, storeName, sessionId, update) {
|
|
18820
|
+
warnDiagnosticOnce(
|
|
18821
|
+
`session_update_overridden\0${storeName}`,
|
|
18822
|
+
`${storeName}: this subclass overrides saveSession or getSession, so session turns go through them, and overlapping turns from different instances can overwrite each other. Override updateSession too to keep that protection.`,
|
|
18823
|
+
void 0,
|
|
18824
|
+
"docs/api-reference.md#several-app-instances-sharing-a-session"
|
|
18825
|
+
);
|
|
18826
|
+
const stored = await store.getSession(sessionId);
|
|
18827
|
+
const next = update(stored.length > 0 ? stored : null);
|
|
18828
|
+
if (next !== null) await store.saveSession(sessionId, next);
|
|
18829
|
+
}
|
|
18830
|
+
|
|
18831
|
+
// src/state/memory.ts
|
|
18807
18832
|
function compareOldestFirst(a, b) {
|
|
18808
18833
|
return compareExecutionsNewestFirst(b, a);
|
|
18809
18834
|
}
|
|
@@ -18820,7 +18845,7 @@ function resolveMaxExecutions(value) {
|
|
|
18820
18845
|
}
|
|
18821
18846
|
var AWAIT_HUMAN_TEMP_DIR = join(tmpdir(), "axl-memory-store");
|
|
18822
18847
|
var AWAIT_HUMAN_TEMP_FILE = join(AWAIT_HUMAN_TEMP_DIR, "await-human-state.json");
|
|
18823
|
-
var MemoryStore = class {
|
|
18848
|
+
var MemoryStore = class _MemoryStore {
|
|
18824
18849
|
checkpoints = /* @__PURE__ */ new Map();
|
|
18825
18850
|
sessions = /* @__PURE__ */ new Map();
|
|
18826
18851
|
sessionMeta = /* @__PURE__ */ new Map();
|
|
@@ -18872,6 +18897,14 @@ var MemoryStore = class {
|
|
|
18872
18897
|
async saveSession(sessionId, history) {
|
|
18873
18898
|
this.sessions.set(sessionId, structuredClone(normalizePersistedSessionHistory(history)));
|
|
18874
18899
|
}
|
|
18900
|
+
async updateSession(sessionId, update) {
|
|
18901
|
+
if (this.saveSession !== _MemoryStore.prototype.saveSession || this.getSession !== _MemoryStore.prototype.getSession) {
|
|
18902
|
+
return updateSessionThroughOverrides(this, "MemoryStore", sessionId, update);
|
|
18903
|
+
}
|
|
18904
|
+
const stored = this.sessions.get(sessionId);
|
|
18905
|
+
const next = update(stored ? structuredClone(stored) : null);
|
|
18906
|
+
if (next !== null) await this.saveSession(sessionId, next);
|
|
18907
|
+
}
|
|
18875
18908
|
async getSession(sessionId) {
|
|
18876
18909
|
const history = this.sessions.get(sessionId);
|
|
18877
18910
|
return history ? structuredClone(history) : [];
|
|
@@ -19295,7 +19328,7 @@ function rowToExecutionInfo(row) {
|
|
|
19295
19328
|
}
|
|
19296
19329
|
return normalizeStoredExecution(info);
|
|
19297
19330
|
}
|
|
19298
|
-
var SQLiteStore = class {
|
|
19331
|
+
var SQLiteStore = class _SQLiteStore {
|
|
19299
19332
|
db;
|
|
19300
19333
|
ttls;
|
|
19301
19334
|
now;
|
|
@@ -19529,6 +19562,17 @@ var SQLiteStore = class {
|
|
|
19529
19562
|
);
|
|
19530
19563
|
stmt.run(sessionId, JSON.stringify(normalizePersistedSessionHistory(history)));
|
|
19531
19564
|
}
|
|
19565
|
+
async updateSession(sessionId, update) {
|
|
19566
|
+
if (this.saveSession !== _SQLiteStore.prototype.saveSession || this.getSession !== _SQLiteStore.prototype.getSession) {
|
|
19567
|
+
return updateSessionThroughOverrides(this, "SQLiteStore", sessionId, update);
|
|
19568
|
+
}
|
|
19569
|
+
this.db.transaction(() => {
|
|
19570
|
+
const row = this.db.prepare("SELECT history FROM sessions WHERE session_id = ?").get(sessionId);
|
|
19571
|
+
const next = update(row ? safeJsonParse(row.history) ?? [] : null);
|
|
19572
|
+
if (next === null) return;
|
|
19573
|
+
this.db.prepare("INSERT OR REPLACE INTO sessions (session_id, history) VALUES (?, ?)").run(sessionId, JSON.stringify(normalizePersistedSessionHistory(next)));
|
|
19574
|
+
}).immediate();
|
|
19575
|
+
}
|
|
19532
19576
|
async getSession(sessionId) {
|
|
19533
19577
|
const stmt = this.db.prepare("SELECT history FROM sessions WHERE session_id = ?");
|
|
19534
19578
|
const row = stmt.get(sessionId);
|
|
@@ -19863,10 +19907,10 @@ var Session = class _Session {
|
|
|
19863
19907
|
);
|
|
19864
19908
|
}
|
|
19865
19909
|
async sendImpl(workflowName, input, signal, events) {
|
|
19866
|
-
const { history, metadata } = await this.prepareHistory(input);
|
|
19910
|
+
const { history, metadata, base } = await this.prepareHistory(input);
|
|
19867
19911
|
try {
|
|
19868
19912
|
const result = await this.runtime.execute(workflowName, input, { metadata, signal, events });
|
|
19869
|
-
await this.commitHistory(history, result);
|
|
19913
|
+
await this.commitHistory(history, base, result);
|
|
19870
19914
|
return result;
|
|
19871
19915
|
} finally {
|
|
19872
19916
|
clearSessionInput(history);
|
|
@@ -19882,6 +19926,8 @@ var Session = class _Session {
|
|
|
19882
19926
|
async prepareHistory(input) {
|
|
19883
19927
|
const preparedInput = prepareSessionInput(input);
|
|
19884
19928
|
const history = await this.store.getSession(this.sessionId);
|
|
19929
|
+
const storedJson = JSON.stringify(history);
|
|
19930
|
+
const hadHistory = history.length > 0;
|
|
19885
19931
|
let cachedSummary = await this.store.getSessionMeta(this.sessionId, "summaryCache");
|
|
19886
19932
|
const maxMessages = this.options.history?.maxMessages;
|
|
19887
19933
|
if (maxMessages && history.length > maxMessages) {
|
|
@@ -19896,13 +19942,17 @@ var Session = class _Session {
|
|
|
19896
19942
|
summaryModel,
|
|
19897
19943
|
cachedSummary ? { previousSummary: cachedSummary } : void 0
|
|
19898
19944
|
);
|
|
19899
|
-
await this.store.
|
|
19945
|
+
const stored = JSON.stringify(await this.store.getSession(this.sessionId));
|
|
19946
|
+
if (stored === storedJson) {
|
|
19947
|
+
await this.store.saveSessionMeta(this.sessionId, "summaryCache", summary);
|
|
19948
|
+
}
|
|
19900
19949
|
cachedSummary = summary;
|
|
19901
19950
|
}
|
|
19902
19951
|
const trimmed = history.slice(-maxMessages);
|
|
19903
19952
|
history.length = 0;
|
|
19904
19953
|
history.push(...trimmed.map(withoutAnthropicThinking));
|
|
19905
19954
|
}
|
|
19955
|
+
const base = { storedJson, hadHistory, firstOwnMessage: history.length };
|
|
19906
19956
|
const userMessage = { role: "user", content: preparedInput.content };
|
|
19907
19957
|
history.push(userMessage);
|
|
19908
19958
|
if (this.options.deduplicateInput !== false && preparedInput.normalizedInput !== void 0) {
|
|
@@ -19916,13 +19966,13 @@ var Session = class _Session {
|
|
|
19916
19966
|
// agent-scoped ask-summary records the runtime would otherwise cache.
|
|
19917
19967
|
...this.options.persist === false ? { sessionPersist: false } : {}
|
|
19918
19968
|
};
|
|
19919
|
-
return { history, metadata };
|
|
19969
|
+
return { history, metadata, base };
|
|
19920
19970
|
}
|
|
19921
19971
|
/** Push the assistant reply (if `executeAgentCall` didn't already) and
|
|
19922
19972
|
* persist. Shared by `sendImpl` and `streamImpl`. The fallback assistant
|
|
19923
19973
|
* push has no agent context and so leaves `agent` undefined — see the
|
|
19924
19974
|
* `ChatMessage.agent` field doc. */
|
|
19925
|
-
async commitHistory(history, result) {
|
|
19975
|
+
async commitHistory(history, base, result) {
|
|
19926
19976
|
const lastMsg = history[history.length - 1];
|
|
19927
19977
|
if (!(lastMsg && lastMsg.role === "assistant")) {
|
|
19928
19978
|
history.push({
|
|
@@ -19930,8 +19980,35 @@ var Session = class _Session {
|
|
|
19930
19980
|
content: typeof result === "string" ? result : JSON.stringify(result)
|
|
19931
19981
|
});
|
|
19932
19982
|
}
|
|
19933
|
-
if (this.options.persist
|
|
19983
|
+
if (this.options.persist === false) return;
|
|
19984
|
+
if (!this.store.updateSession) {
|
|
19934
19985
|
await this.store.saveSession(this.sessionId, history);
|
|
19986
|
+
return;
|
|
19987
|
+
}
|
|
19988
|
+
let outcome = "saved";
|
|
19989
|
+
await this.store.updateSession(this.sessionId, (current) => {
|
|
19990
|
+
if (current === null) {
|
|
19991
|
+
outcome = base.hadHistory ? "discarded" : "saved";
|
|
19992
|
+
return base.hadHistory ? null : history;
|
|
19993
|
+
}
|
|
19994
|
+
if (JSON.stringify(current) === base.storedJson) {
|
|
19995
|
+
outcome = "saved";
|
|
19996
|
+
return history;
|
|
19997
|
+
}
|
|
19998
|
+
outcome = "merged";
|
|
19999
|
+
return [...current, ...history.slice(base.firstOwnMessage).map(withoutAnthropicThinking)];
|
|
20000
|
+
});
|
|
20001
|
+
if (outcome === "merged") this.notify("session_turns_merged");
|
|
20002
|
+
else if (outcome === "discarded") this.notify("session_turn_discarded");
|
|
20003
|
+
}
|
|
20004
|
+
/** The turn is already saved, so a listener that throws can't be allowed to fail it. */
|
|
20005
|
+
notify(event) {
|
|
20006
|
+
try {
|
|
20007
|
+
this.runtime.emit(event, { sessionId: this.sessionId });
|
|
20008
|
+
} catch (err) {
|
|
20009
|
+
console.error(
|
|
20010
|
+
`[axl] A ${event} listener failed: ${err instanceof Error ? err.message : String(err)}`
|
|
20011
|
+
);
|
|
19935
20012
|
}
|
|
19936
20013
|
}
|
|
19937
20014
|
async stream(workflowName, input, options) {
|
|
@@ -19959,10 +20036,12 @@ var Session = class _Session {
|
|
|
19959
20036
|
}
|
|
19960
20037
|
async streamImpl(workflowName, input, resolveReady, rejectReady, signal, events) {
|
|
19961
20038
|
let history;
|
|
20039
|
+
let base;
|
|
19962
20040
|
let axlStream;
|
|
19963
20041
|
try {
|
|
19964
20042
|
const prepared = await this.prepareHistory(input);
|
|
19965
20043
|
history = prepared.history;
|
|
20044
|
+
base = prepared.base;
|
|
19966
20045
|
axlStream = this.runtime.stream(workflowName, input, {
|
|
19967
20046
|
metadata: prepared.metadata,
|
|
19968
20047
|
signal,
|
|
@@ -19976,12 +20055,19 @@ var Session = class _Session {
|
|
|
19976
20055
|
resolveReady(axlStream);
|
|
19977
20056
|
try {
|
|
19978
20057
|
const completion = axlStream.promise.then(
|
|
19979
|
-
(result) => this.commitHistory(history, result).catch((err) => {
|
|
19980
|
-
|
|
19981
|
-
|
|
19982
|
-
|
|
19983
|
-
|
|
19984
|
-
|
|
20058
|
+
(result) => this.commitHistory(history, base, result).catch((err) => {
|
|
20059
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
20060
|
+
try {
|
|
20061
|
+
this.runtime.emit("error", {
|
|
20062
|
+
type: "session_history_save_failed",
|
|
20063
|
+
sessionId: this.sessionId,
|
|
20064
|
+
error
|
|
20065
|
+
});
|
|
20066
|
+
} catch {
|
|
20067
|
+
console.error(
|
|
20068
|
+
`[axl] Failed to save session history for sessionId=${this.sessionId}: ${error}`
|
|
20069
|
+
);
|
|
20070
|
+
}
|
|
19985
20071
|
}),
|
|
19986
20072
|
() => void 0
|
|
19987
20073
|
);
|
|
@@ -22057,7 +22143,11 @@ var AxlRuntime = class extends EventEmitter2 {
|
|
|
22057
22143
|
observation
|
|
22058
22144
|
});
|
|
22059
22145
|
if (this.stateStore.deleteCheckpoints) {
|
|
22060
|
-
await this.stateStore.deleteCheckpoints(execInfo.executionId)
|
|
22146
|
+
await this.stateStore.deleteCheckpoints(execInfo.executionId).catch((err) => {
|
|
22147
|
+
console.error(
|
|
22148
|
+
`[axl] Failed to delete checkpoints for executionId=${execInfo.executionId}: ` + (err instanceof Error ? err.message : String(err))
|
|
22149
|
+
);
|
|
22150
|
+
});
|
|
22061
22151
|
}
|
|
22062
22152
|
span?.setAttribute("axl.workflow.cost", execInfo.totalCost);
|
|
22063
22153
|
span?.setAttribute("axl.workflow.duration", execInfo.duration);
|
|
@@ -22129,7 +22219,10 @@ var AxlRuntime = class extends EventEmitter2 {
|
|
|
22129
22219
|
const chain = this.stateStore.saveExecution(snapshot).then(() => {
|
|
22130
22220
|
return this.streamingFlusher?.finalize(id).catch(() => {
|
|
22131
22221
|
});
|
|
22132
|
-
}).catch(() => {
|
|
22222
|
+
}).catch((err) => {
|
|
22223
|
+
console.error(
|
|
22224
|
+
`[axl] Failed to save execution history for executionId=${id}: ` + (err instanceof Error ? err.message : String(err))
|
|
22225
|
+
);
|
|
22133
22226
|
}).then(() => void 0);
|
|
22134
22227
|
this.persistInflight.add(chain);
|
|
22135
22228
|
const pending = { snapshot, settled: chain };
|
|
@@ -24521,6 +24614,7 @@ async function* stringStreamFromEvents(source, opts) {
|
|
|
24521
24614
|
}
|
|
24522
24615
|
|
|
24523
24616
|
// src/state/redis.ts
|
|
24617
|
+
import { createHash as createHash2 } from "crypto";
|
|
24524
24618
|
var PRUNE_HISTORY_INDEX_SCRIPT = `
|
|
24525
24619
|
local now = tonumber(ARGV[1])
|
|
24526
24620
|
local removed = 0
|
|
@@ -24540,6 +24634,34 @@ for i = 2, #ARGV do
|
|
|
24540
24634
|
end
|
|
24541
24635
|
return removed
|
|
24542
24636
|
`;
|
|
24637
|
+
var PRUNE_SESSION_IDS_SCRIPT = `
|
|
24638
|
+
local removed = {}
|
|
24639
|
+
for i, id in ipairs(ARGV) do
|
|
24640
|
+
if redis.call('EXISTS', KEYS[i * 2]) == 0 then
|
|
24641
|
+
redis.call('SREM', KEYS[1], id)
|
|
24642
|
+
redis.call('DEL', KEYS[i * 2 + 1])
|
|
24643
|
+
removed[#removed + 1] = id
|
|
24644
|
+
end
|
|
24645
|
+
end
|
|
24646
|
+
return removed
|
|
24647
|
+
`;
|
|
24648
|
+
var SESSION_ID_BATCH = 100;
|
|
24649
|
+
var SESSION_SWEEP_COUNT = 50;
|
|
24650
|
+
var UPDATE_SESSION_SCRIPT = `
|
|
24651
|
+
local stored = redis.call('GET', KEYS[1])
|
|
24652
|
+
local actual = stored and redis.sha1hex(stored) or ''
|
|
24653
|
+
if actual ~= ARGV[1] then
|
|
24654
|
+
return 0
|
|
24655
|
+
end
|
|
24656
|
+
if ARGV[3] ~= '' then
|
|
24657
|
+
redis.call('SET', KEYS[1], ARGV[2], 'EX', ARGV[3])
|
|
24658
|
+
else
|
|
24659
|
+
redis.call('SET', KEYS[1], ARGV[2])
|
|
24660
|
+
end
|
|
24661
|
+
redis.call('SADD', KEYS[2], ARGV[4])
|
|
24662
|
+
return 1
|
|
24663
|
+
`;
|
|
24664
|
+
var UPDATE_SESSION_ATTEMPTS = 10;
|
|
24543
24665
|
function compareMembers(a, b) {
|
|
24544
24666
|
return Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"));
|
|
24545
24667
|
}
|
|
@@ -24552,12 +24674,222 @@ function warnIfRedisExecutionRetentionUnset(opts) {
|
|
|
24552
24674
|
warnExecutionRetentionUnset("RedisStore");
|
|
24553
24675
|
}
|
|
24554
24676
|
}
|
|
24677
|
+
var CLOSE_TIMEOUT_MS = 2e3;
|
|
24678
|
+
function reconnectDelayMs(retries) {
|
|
24679
|
+
return Math.min(2 ** retries * 50, 2e3) + Math.floor(Math.random() * 200);
|
|
24680
|
+
}
|
|
24681
|
+
function abandonConnection(client) {
|
|
24682
|
+
try {
|
|
24683
|
+
client.destroy?.();
|
|
24684
|
+
} catch {
|
|
24685
|
+
}
|
|
24686
|
+
}
|
|
24687
|
+
function errorMessage(error) {
|
|
24688
|
+
return error instanceof Error ? error.message : String(error);
|
|
24689
|
+
}
|
|
24690
|
+
function connectionEventReporter(client, onConnectionEvent) {
|
|
24691
|
+
if (onConnectionEvent) {
|
|
24692
|
+
const reportFailure = (error) => console.error(`[axl] RedisStore: onConnectionEvent failed: ${errorMessage(error)}`);
|
|
24693
|
+
return (event) => {
|
|
24694
|
+
try {
|
|
24695
|
+
Promise.resolve(onConnectionEvent(event)).catch(reportFailure);
|
|
24696
|
+
} catch (error) {
|
|
24697
|
+
reportFailure(error);
|
|
24698
|
+
}
|
|
24699
|
+
};
|
|
24700
|
+
}
|
|
24701
|
+
let outageLogged = false;
|
|
24702
|
+
return (event) => {
|
|
24703
|
+
if (event.type === "reconnected") {
|
|
24704
|
+
outageLogged = false;
|
|
24705
|
+
console.warn("[axl] RedisStore: reconnected to Redis.");
|
|
24706
|
+
} else if (!outageLogged) {
|
|
24707
|
+
outageLogged = client.isReady === false;
|
|
24708
|
+
console.warn(
|
|
24709
|
+
`[axl] RedisStore: Redis connection lost (${event.error.message}). Store calls fail until it reconnects, which happens automatically.`
|
|
24710
|
+
);
|
|
24711
|
+
}
|
|
24712
|
+
};
|
|
24713
|
+
}
|
|
24714
|
+
var LOOP_STALL_TOLERANCE_MS = 100;
|
|
24715
|
+
var LOOP_STALL_GRACE_MS = 1e3;
|
|
24716
|
+
function watchResponses(client, timeoutMs, reportConnectionEvent) {
|
|
24717
|
+
const checkEveryMs = timeoutMs / 3;
|
|
24718
|
+
let timer;
|
|
24719
|
+
let stopped = false;
|
|
24720
|
+
const schedule = (run, delayMs) => {
|
|
24721
|
+
timer = setTimeout(run, delayMs);
|
|
24722
|
+
timer.unref?.();
|
|
24723
|
+
};
|
|
24724
|
+
const check = () => {
|
|
24725
|
+
if (client.isReady === false) {
|
|
24726
|
+
schedule(check, checkEveryMs);
|
|
24727
|
+
return;
|
|
24728
|
+
}
|
|
24729
|
+
let settled = false;
|
|
24730
|
+
const onReply = () => {
|
|
24731
|
+
if (settled || stopped) return;
|
|
24732
|
+
settled = true;
|
|
24733
|
+
clearTimeout(timer);
|
|
24734
|
+
schedule(check, checkEveryMs);
|
|
24735
|
+
};
|
|
24736
|
+
const expireAfter = (delayMs, mayWaitForBlockedLoop) => {
|
|
24737
|
+
const dueAt = Date.now() + delayMs;
|
|
24738
|
+
schedule(() => {
|
|
24739
|
+
if (settled || stopped) return;
|
|
24740
|
+
if (mayWaitForBlockedLoop && Date.now() - dueAt > LOOP_STALL_TOLERANCE_MS) {
|
|
24741
|
+
expireAfter(LOOP_STALL_GRACE_MS, false);
|
|
24742
|
+
return;
|
|
24743
|
+
}
|
|
24744
|
+
reset();
|
|
24745
|
+
}, delayMs);
|
|
24746
|
+
};
|
|
24747
|
+
const reset = () => {
|
|
24748
|
+
settled = true;
|
|
24749
|
+
if (client.isReady === false) {
|
|
24750
|
+
schedule(check, checkEveryMs);
|
|
24751
|
+
return;
|
|
24752
|
+
}
|
|
24753
|
+
client.destroy?.();
|
|
24754
|
+
client.connect().catch(() => {
|
|
24755
|
+
});
|
|
24756
|
+
reportConnectionEvent({
|
|
24757
|
+
type: "error",
|
|
24758
|
+
error: new Error(`Redis did not answer within ${timeoutMs}ms; reconnecting.`)
|
|
24759
|
+
});
|
|
24760
|
+
schedule(check, checkEveryMs);
|
|
24761
|
+
};
|
|
24762
|
+
client.ping().then(onReply, onReply);
|
|
24763
|
+
expireAfter(timeoutMs, true);
|
|
24764
|
+
};
|
|
24765
|
+
schedule(check, checkEveryMs);
|
|
24766
|
+
return () => {
|
|
24767
|
+
stopped = true;
|
|
24768
|
+
clearTimeout(timer);
|
|
24769
|
+
};
|
|
24770
|
+
}
|
|
24771
|
+
async function connectWithin(connecting, timeoutMs) {
|
|
24772
|
+
let timer;
|
|
24773
|
+
try {
|
|
24774
|
+
await Promise.race([
|
|
24775
|
+
connecting,
|
|
24776
|
+
new Promise((_resolve, reject) => {
|
|
24777
|
+
timer = setTimeout(
|
|
24778
|
+
() => reject(
|
|
24779
|
+
new AxlError(
|
|
24780
|
+
"REDIS_UNAVAILABLE",
|
|
24781
|
+
`RedisStore: Redis did not finish connecting within ${timeoutMs}ms.`
|
|
24782
|
+
)
|
|
24783
|
+
),
|
|
24784
|
+
timeoutMs
|
|
24785
|
+
);
|
|
24786
|
+
})
|
|
24787
|
+
]);
|
|
24788
|
+
} finally {
|
|
24789
|
+
clearTimeout(timer);
|
|
24790
|
+
}
|
|
24791
|
+
}
|
|
24792
|
+
async function connectClient(createClient, opts) {
|
|
24793
|
+
const inBackground = opts.waitForConnection === false;
|
|
24794
|
+
let established = inBackground;
|
|
24795
|
+
let everFailed = false;
|
|
24796
|
+
const responseTimeoutMs = opts.responseTimeoutMs;
|
|
24797
|
+
const client = createClient({
|
|
24798
|
+
...opts.url ? { url: opts.url } : {},
|
|
24799
|
+
// A command accepted just before a drop rejects instead of waiting for
|
|
24800
|
+
// the reconnect.
|
|
24801
|
+
disableOfflineQueue: true,
|
|
24802
|
+
socket: {
|
|
24803
|
+
reconnectStrategy: (retries) => established ? reconnectDelayMs(retries) : false,
|
|
24804
|
+
// Bounds a reconnect's handshake, where the response watch doesn't run.
|
|
24805
|
+
// Twice the timeout so that on an open connection the watch acts first:
|
|
24806
|
+
// this timer fires after any long event-loop block, which the watch allows for.
|
|
24807
|
+
...responseTimeoutMs !== void 0 ? { socketTimeout: responseTimeoutMs * 2 } : {}
|
|
24808
|
+
}
|
|
24809
|
+
});
|
|
24810
|
+
const reportConnectionEvent = connectionEventReporter(client, opts.onConnectionEvent);
|
|
24811
|
+
client.on("error", (error) => {
|
|
24812
|
+
if (!established || client.isOpen === false) return;
|
|
24813
|
+
everFailed = true;
|
|
24814
|
+
reportConnectionEvent({
|
|
24815
|
+
type: "error",
|
|
24816
|
+
error: error instanceof Error ? error : new Error(String(error))
|
|
24817
|
+
});
|
|
24818
|
+
});
|
|
24819
|
+
client.on("connect", () => {
|
|
24820
|
+
if (client.isOpen === false) client.unref?.();
|
|
24821
|
+
});
|
|
24822
|
+
client.on("ready", () => {
|
|
24823
|
+
if (!established) {
|
|
24824
|
+
established = true;
|
|
24825
|
+
} else if (client.isOpen !== false && (!inBackground || everFailed)) {
|
|
24826
|
+
reportConnectionEvent({ type: "reconnected" });
|
|
24827
|
+
}
|
|
24828
|
+
});
|
|
24829
|
+
if (inBackground) {
|
|
24830
|
+
client.connect().catch(() => {
|
|
24831
|
+
});
|
|
24832
|
+
return {
|
|
24833
|
+
client,
|
|
24834
|
+
stopResponseWatch: watchResponses(client, responseTimeoutMs, reportConnectionEvent)
|
|
24835
|
+
};
|
|
24836
|
+
}
|
|
24837
|
+
if (responseTimeoutMs === void 0) {
|
|
24838
|
+
await client.connect();
|
|
24839
|
+
return { client };
|
|
24840
|
+
}
|
|
24841
|
+
try {
|
|
24842
|
+
await connectWithin(client.connect(), responseTimeoutMs);
|
|
24843
|
+
} catch (error) {
|
|
24844
|
+
abandonConnection(client);
|
|
24845
|
+
throw error;
|
|
24846
|
+
}
|
|
24847
|
+
return {
|
|
24848
|
+
client,
|
|
24849
|
+
stopResponseWatch: watchResponses(client, responseTimeoutMs, reportConnectionEvent)
|
|
24850
|
+
};
|
|
24851
|
+
}
|
|
24555
24852
|
var RedisStore = class _RedisStore {
|
|
24556
|
-
constructor(
|
|
24557
|
-
this.
|
|
24853
|
+
constructor(connection, keyPrefix, ttls) {
|
|
24854
|
+
this.connection = connection;
|
|
24558
24855
|
this.keyPrefix = keyPrefix;
|
|
24559
24856
|
this.ttls = ttls;
|
|
24560
24857
|
}
|
|
24858
|
+
/** Set when `close()` starts, while the connection is still open. */
|
|
24859
|
+
closing = false;
|
|
24860
|
+
/** Where the next save's check of listed sessions picks up. */
|
|
24861
|
+
sessionSweepCursor = "0";
|
|
24862
|
+
/** Overlapping saves would all check the same page, so only one checks at a time. */
|
|
24863
|
+
sessionSweepRunning = false;
|
|
24864
|
+
/**
|
|
24865
|
+
* Whether the store can send commands right now, for a health check or for
|
|
24866
|
+
* telling an outage from other failures when a call rejects. `false` while
|
|
24867
|
+
* reconnecting and after `close()`.
|
|
24868
|
+
*/
|
|
24869
|
+
get isConnected() {
|
|
24870
|
+
return !this.closing && this.connection.isOpen !== false && this.connection.isReady !== false;
|
|
24871
|
+
}
|
|
24872
|
+
/**
|
|
24873
|
+
* The client for sending a command, failing immediately while disconnected.
|
|
24874
|
+
*
|
|
24875
|
+
* Every command goes through here because node-redis's `disableOfflineQueue`
|
|
24876
|
+
* rejects single commands but not `multi().exec()`, which would wait for a
|
|
24877
|
+
* reconnect attempt to fail or succeed. Nothing may `await` between
|
|
24878
|
+
* `client.multi()` and `.exec()`, or a transaction could get past this check.
|
|
24879
|
+
*
|
|
24880
|
+
* A closed store isn't "unavailable": it falls through to node-redis's own
|
|
24881
|
+
* "The client is closed" rejection.
|
|
24882
|
+
*/
|
|
24883
|
+
get client() {
|
|
24884
|
+
if (this.closing) throw new Error("The client is closed");
|
|
24885
|
+
if (this.connection.isOpen !== false && this.connection.isReady === false) {
|
|
24886
|
+
throw new AxlError(
|
|
24887
|
+
"REDIS_UNAVAILABLE",
|
|
24888
|
+
"RedisStore: not connected to Redis, so the command was not sent. The store reconnects automatically; retry once Redis is back."
|
|
24889
|
+
);
|
|
24890
|
+
}
|
|
24891
|
+
return this.connection;
|
|
24892
|
+
}
|
|
24561
24893
|
/**
|
|
24562
24894
|
* History kinds with a prune running in this process. A save skips its
|
|
24563
24895
|
* prune while another one of the same kind is in flight: the running one
|
|
@@ -24565,6 +24897,8 @@ var RedisStore = class _RedisStore {
|
|
|
24565
24897
|
* instance needs no constructor state.
|
|
24566
24898
|
*/
|
|
24567
24899
|
prunesInFlight;
|
|
24900
|
+
/** Stops the `responseTimeoutMs` health checks; unset when the option is off. */
|
|
24901
|
+
stopResponseWatch;
|
|
24568
24902
|
/**
|
|
24569
24903
|
* Supports `listExecutions(limit, { cursor })`. A getter rather than a
|
|
24570
24904
|
* field so it lives on the prototype.
|
|
@@ -24614,6 +24948,17 @@ var RedisStore = class _RedisStore {
|
|
|
24614
24948
|
'RedisStore: keyPrefix cannot be empty string. Omit it for the default "axl:" prefix.'
|
|
24615
24949
|
);
|
|
24616
24950
|
}
|
|
24951
|
+
const responseTimeoutMs = opts.responseTimeoutMs;
|
|
24952
|
+
if (responseTimeoutMs !== void 0 && !(Number.isFinite(responseTimeoutMs) && responseTimeoutMs > 0)) {
|
|
24953
|
+
throw new Error(
|
|
24954
|
+
`RedisStore: responseTimeoutMs must be a positive number of milliseconds, got ${responseTimeoutMs}.`
|
|
24955
|
+
);
|
|
24956
|
+
}
|
|
24957
|
+
if (opts.waitForConnection === false && responseTimeoutMs === void 0) {
|
|
24958
|
+
throw new Error(
|
|
24959
|
+
"RedisStore: waitForConnection: false requires responseTimeoutMs, so a server that accepts the connection but never answers cannot leave it connecting forever."
|
|
24960
|
+
);
|
|
24961
|
+
}
|
|
24617
24962
|
let createClient;
|
|
24618
24963
|
try {
|
|
24619
24964
|
const mod = __require("redis");
|
|
@@ -24627,8 +24972,7 @@ var RedisStore = class _RedisStore {
|
|
|
24627
24972
|
if (err instanceof Error && err.message.includes("createClient")) throw err;
|
|
24628
24973
|
throw new Error("redis is required for RedisStore. Install it with: npm install redis");
|
|
24629
24974
|
}
|
|
24630
|
-
const client =
|
|
24631
|
-
await client.connect();
|
|
24975
|
+
const { client, stopResponseWatch } = await connectClient(createClient, opts);
|
|
24632
24976
|
const resolveCategory = (override) => {
|
|
24633
24977
|
if (override === null) return null;
|
|
24634
24978
|
if (typeof override === "number") return override;
|
|
@@ -24650,13 +24994,48 @@ var RedisStore = class _RedisStore {
|
|
|
24650
24994
|
streamingEvents: resolveStreamingEvents(opts.ttls?.streamingEvents)
|
|
24651
24995
|
};
|
|
24652
24996
|
const store = new _RedisStore(client, keyPrefix, effectiveTtls);
|
|
24997
|
+
store.stopResponseWatch = stopResponseWatch;
|
|
24653
24998
|
warnIfRedisExecutionRetentionUnset(opts);
|
|
24999
|
+
if (opts.waitForConnection === false) {
|
|
25000
|
+
if (!opts.skipMigration) store.backfillOnceConnected(client);
|
|
25001
|
+
return store;
|
|
25002
|
+
}
|
|
24654
25003
|
if (!opts.skipMigration) {
|
|
24655
|
-
|
|
24656
|
-
|
|
25004
|
+
try {
|
|
25005
|
+
await store.backfillExecutionIndex();
|
|
25006
|
+
await store.backfillEvalIndex();
|
|
25007
|
+
} catch (error) {
|
|
25008
|
+
stopResponseWatch?.();
|
|
25009
|
+
abandonConnection(client);
|
|
25010
|
+
throw error;
|
|
25011
|
+
}
|
|
24657
25012
|
}
|
|
24658
25013
|
return store;
|
|
24659
25014
|
}
|
|
25015
|
+
/**
|
|
25016
|
+
* Run the startup backfill when a connection becomes ready, until it succeeds.
|
|
25017
|
+
* Nothing is waiting on it, and listings work without it, so a failure is
|
|
25018
|
+
* reported and the next connection tries again.
|
|
25019
|
+
*/
|
|
25020
|
+
backfillOnceConnected(client) {
|
|
25021
|
+
let state = "waiting";
|
|
25022
|
+
client.on("ready", () => {
|
|
25023
|
+
if (state !== "waiting") return;
|
|
25024
|
+
state = "running";
|
|
25025
|
+
this.backfillExecutionIndex().then(() => this.backfillEvalIndex()).then(
|
|
25026
|
+
() => {
|
|
25027
|
+
state = "done";
|
|
25028
|
+
},
|
|
25029
|
+
(err) => {
|
|
25030
|
+
state = "waiting";
|
|
25031
|
+
if (client.isReady === false || client.isOpen === false) return;
|
|
25032
|
+
console.warn(
|
|
25033
|
+
`[axl] RedisStore: the startup index backfill failed (${errorMessage(err)}). Listings stay correct meanwhile.`
|
|
25034
|
+
);
|
|
25035
|
+
}
|
|
25036
|
+
);
|
|
25037
|
+
});
|
|
25038
|
+
}
|
|
24660
25039
|
/**
|
|
24661
25040
|
* Backfill the execution-history sorted-set from the legacy ID set.
|
|
24662
25041
|
* Idempotent — runs only if the ZSET is missing entries the SET has.
|
|
@@ -24664,36 +25043,30 @@ var RedisStore = class _RedisStore {
|
|
|
24664
25043
|
* `skipMigration: true`.
|
|
24665
25044
|
*/
|
|
24666
25045
|
async backfillExecutionIndex() {
|
|
24667
|
-
await this.backfillHistoryIndex(
|
|
24668
|
-
|
|
24669
|
-
|
|
24670
|
-
|
|
24671
|
-
(raw) => {
|
|
24672
|
-
const exec = JSON.parse(raw);
|
|
24673
|
-
return typeof exec.startedAt === "number" ? { score: exec.startedAt, value: exec.executionId } : null;
|
|
24674
|
-
},
|
|
24675
|
-
"execution-history"
|
|
24676
|
-
);
|
|
25046
|
+
await this.backfillHistoryIndex(this.historyIndex("execution"), (raw) => {
|
|
25047
|
+
const exec = JSON.parse(raw);
|
|
25048
|
+
return typeof exec.startedAt === "number" ? { score: exec.startedAt, value: exec.executionId } : null;
|
|
25049
|
+
});
|
|
24677
25050
|
}
|
|
24678
25051
|
/** Backfill the eval-history sorted-set. Symmetric to `backfillExecutionIndex`. */
|
|
24679
25052
|
async backfillEvalIndex() {
|
|
24680
|
-
await this.backfillHistoryIndex(
|
|
24681
|
-
|
|
24682
|
-
|
|
24683
|
-
|
|
24684
|
-
(raw) => {
|
|
24685
|
-
const entry = JSON.parse(raw);
|
|
24686
|
-
return typeof entry.timestamp === "number" ? { score: entry.timestamp, value: entry.id } : null;
|
|
24687
|
-
},
|
|
24688
|
-
"eval-history"
|
|
24689
|
-
);
|
|
25053
|
+
await this.backfillHistoryIndex(this.historyIndex("eval"), (raw) => {
|
|
25054
|
+
const entry = JSON.parse(raw);
|
|
25055
|
+
return typeof entry.timestamp === "number" ? { score: entry.timestamp, value: entry.id } : null;
|
|
25056
|
+
});
|
|
24690
25057
|
}
|
|
24691
25058
|
/**
|
|
24692
25059
|
* Shared implementation for both backfill paths. Reads SET members,
|
|
24693
25060
|
* batch-MGETs their data blobs in chunks, and ZADDs each into the new
|
|
24694
25061
|
* sorted-set inside a single MULTI per chunk.
|
|
25062
|
+
*
|
|
25063
|
+
* Ids with no data blob are removed from the id set. Left there, the set stays
|
|
25064
|
+
* larger than the sorted set, so every startup repeats this walk and listing
|
|
25065
|
+
* stays on its slow path.
|
|
24695
25066
|
*/
|
|
24696
|
-
async backfillHistoryIndex(
|
|
25067
|
+
async backfillHistoryIndex(index, scoreOf) {
|
|
25068
|
+
const setKey = index.ids;
|
|
25069
|
+
const zsetKey = index.time;
|
|
24697
25070
|
const [setSize, zsetSize] = await Promise.all([
|
|
24698
25071
|
this.client.sCard(setKey),
|
|
24699
25072
|
this.client.zCard(zsetKey)
|
|
@@ -24704,9 +25077,10 @@ var RedisStore = class _RedisStore {
|
|
|
24704
25077
|
const CHUNK_SIZE = 500;
|
|
24705
25078
|
let migrated = 0;
|
|
24706
25079
|
let skipped = 0;
|
|
25080
|
+
const missing = [];
|
|
24707
25081
|
for (let i = 0; i < allIds.length; i += CHUNK_SIZE) {
|
|
24708
25082
|
const chunk = allIds.slice(i, i + CHUNK_SIZE);
|
|
24709
|
-
const dataKeys = chunk.map((id) => dataKey(id));
|
|
25083
|
+
const dataKeys = chunk.map((id) => index.dataKey(id));
|
|
24710
25084
|
const raws = await this.client.mGet(dataKeys);
|
|
24711
25085
|
const tx = this.client.multi();
|
|
24712
25086
|
let queuedInChunk = 0;
|
|
@@ -24714,6 +25088,7 @@ var RedisStore = class _RedisStore {
|
|
|
24714
25088
|
const raw = raws[j];
|
|
24715
25089
|
if (raw == null) {
|
|
24716
25090
|
skipped++;
|
|
25091
|
+
missing.push(chunk[j]);
|
|
24717
25092
|
continue;
|
|
24718
25093
|
}
|
|
24719
25094
|
try {
|
|
@@ -24731,9 +25106,14 @@ var RedisStore = class _RedisStore {
|
|
|
24731
25106
|
}
|
|
24732
25107
|
if (queuedInChunk > 0) await tx.exec();
|
|
24733
25108
|
}
|
|
25109
|
+
if (missing.length > 0) {
|
|
25110
|
+
await this.runPruneScript(index, missing).catch(
|
|
25111
|
+
(err) => this.warnPruneFailed(index, err)
|
|
25112
|
+
);
|
|
25113
|
+
}
|
|
24734
25114
|
if (migrated > 0 || skipped > 0) {
|
|
24735
25115
|
console.log(
|
|
24736
|
-
`[axl] RedisStore: backfilled ${migrated} ${label} entries into sorted-set index` + (skipped > 0 ? ` (${skipped} skipped \u2014 missing or malformed)` : "")
|
|
25116
|
+
`[axl] RedisStore: backfilled ${migrated} ${index.label} entries into sorted-set index` + (skipped > 0 ? ` (${skipped} skipped \u2014 missing or malformed)` : "")
|
|
24737
25117
|
);
|
|
24738
25118
|
}
|
|
24739
25119
|
}
|
|
@@ -24859,6 +25239,51 @@ var RedisStore = class _RedisStore {
|
|
|
24859
25239
|
JSON.stringify(normalizePersistedSessionHistory(history)),
|
|
24860
25240
|
setOptions
|
|
24861
25241
|
).sAdd(this.sessionIdsKey(), sessionId).exec();
|
|
25242
|
+
await this.sweepSessionIds();
|
|
25243
|
+
}
|
|
25244
|
+
async updateSession(sessionId, update) {
|
|
25245
|
+
if (this.saveSession !== _RedisStore.prototype.saveSession || this.getSession !== _RedisStore.prototype.getSession) {
|
|
25246
|
+
return updateSessionThroughOverrides(this, "RedisStore", sessionId, update);
|
|
25247
|
+
}
|
|
25248
|
+
const key = this.sessionKey(sessionId);
|
|
25249
|
+
const ttl = this.ttlFor("session");
|
|
25250
|
+
for (let attempt = 0; attempt < UPDATE_SESSION_ATTEMPTS; attempt++) {
|
|
25251
|
+
const raw = await this.client.get(key);
|
|
25252
|
+
const next = update(raw ? JSON.parse(raw) : null);
|
|
25253
|
+
if (next === null) return;
|
|
25254
|
+
let written;
|
|
25255
|
+
try {
|
|
25256
|
+
written = await this.client.eval(UPDATE_SESSION_SCRIPT, {
|
|
25257
|
+
keys: [key, this.sessionIdsKey()],
|
|
25258
|
+
arguments: [
|
|
25259
|
+
raw === null ? "" : createHash2("sha1").update(raw).digest("hex"),
|
|
25260
|
+
JSON.stringify(normalizePersistedSessionHistory(next)),
|
|
25261
|
+
ttl === void 0 ? "" : String(ttl),
|
|
25262
|
+
sessionId
|
|
25263
|
+
]
|
|
25264
|
+
});
|
|
25265
|
+
} catch (err) {
|
|
25266
|
+
if (!errorMessage(err).includes("NOPERM")) throw err;
|
|
25267
|
+
warnDiagnosticOnce(
|
|
25268
|
+
"redis_update_session_denied",
|
|
25269
|
+
"RedisStore: saving a session turn safely runs a Lua script, which this Redis user may not run, so it was saved without that protection. Overlapping turns from different instances can overwrite each other until the user may run EVAL.",
|
|
25270
|
+
void 0,
|
|
25271
|
+
"docs/api-reference.md#concurrency-and-races"
|
|
25272
|
+
);
|
|
25273
|
+
await this.saveSession(sessionId, next);
|
|
25274
|
+
return;
|
|
25275
|
+
}
|
|
25276
|
+
if (Number(written) === 1) {
|
|
25277
|
+
await this.sweepSessionIds();
|
|
25278
|
+
return;
|
|
25279
|
+
}
|
|
25280
|
+
const backoffMs = Math.min(20 * 2 ** attempt, 200);
|
|
25281
|
+
await new Promise((resolve2) => setTimeout(resolve2, Math.random() * backoffMs));
|
|
25282
|
+
}
|
|
25283
|
+
throw new AxlError(
|
|
25284
|
+
"SESSION_CONFLICT",
|
|
25285
|
+
`RedisStore: gave up saving session "${sessionId}" after ${UPDATE_SESSION_ATTEMPTS} conflicting writes from other instances.`
|
|
25286
|
+
);
|
|
24862
25287
|
}
|
|
24863
25288
|
async getSession(sessionId) {
|
|
24864
25289
|
const raw = await this.client.get(this.sessionKey(sessionId));
|
|
@@ -24922,7 +25347,7 @@ var RedisStore = class _RedisStore {
|
|
|
24922
25347
|
}
|
|
24923
25348
|
}
|
|
24924
25349
|
if (dead.length > 0) {
|
|
24925
|
-
this.
|
|
25350
|
+
this.connection.sRem(this.pendingExecSetKey(), dead).catch((err) => {
|
|
24926
25351
|
console.error(
|
|
24927
25352
|
`[axl] RedisStore: failed to prune ${dead.length} stale pending-exec ids: ` + (err instanceof Error ? err.message : String(err))
|
|
24928
25353
|
);
|
|
@@ -24968,7 +25393,7 @@ var RedisStore = class _RedisStore {
|
|
|
24968
25393
|
}
|
|
24969
25394
|
const ttl = this.ttlFor("memory");
|
|
24970
25395
|
if (ttl !== void 0) {
|
|
24971
|
-
this.
|
|
25396
|
+
this.connection.expire(this.memoryKey(scope), ttl).catch((err) => {
|
|
24972
25397
|
console.error(
|
|
24973
25398
|
`[axl] RedisStore: failed to set TTL on migrated memory key ${this.memoryKey(scope)}: ` + (err instanceof Error ? err.message : String(err))
|
|
24974
25399
|
);
|
|
@@ -25260,16 +25685,20 @@ var RedisStore = class _RedisStore {
|
|
|
25260
25685
|
try {
|
|
25261
25686
|
await this.pruneHistory(index);
|
|
25262
25687
|
} catch (err) {
|
|
25263
|
-
|
|
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
|
-
);
|
|
25688
|
+
this.warnPruneFailed(index, err);
|
|
25269
25689
|
} finally {
|
|
25270
25690
|
inFlight.delete(index.kind);
|
|
25271
25691
|
}
|
|
25272
25692
|
}
|
|
25693
|
+
warnPruneFailed(index, err) {
|
|
25694
|
+
if (!this.isConnected) return;
|
|
25695
|
+
warnDiagnosticOnce(
|
|
25696
|
+
`redis_history_prune_failed\0${index.kind}`,
|
|
25697
|
+
`RedisStore: removing expired ${index.label} index entries failed (${err instanceof Error ? err.message : String(err)}). The cleanup is tried again later, and listings stay correct meanwhile. The cleanup runs a Lua script, so the Redis user needs permission to run EVAL.`,
|
|
25698
|
+
void 0,
|
|
25699
|
+
"docs/api-reference.md#redisstore-options"
|
|
25700
|
+
);
|
|
25701
|
+
}
|
|
25273
25702
|
async pruneHistory(index) {
|
|
25274
25703
|
const candidates = await this.client.zRange(index.expiry, "-inf", Date.now(), {
|
|
25275
25704
|
BY: "SCORE",
|
|
@@ -25424,7 +25853,69 @@ var RedisStore = class _RedisStore {
|
|
|
25424
25853
|
}
|
|
25425
25854
|
// ── Sessions (Studio introspection) ────────────────────────────────────
|
|
25426
25855
|
async listSessions() {
|
|
25427
|
-
|
|
25856
|
+
const live = /* @__PURE__ */ new Set();
|
|
25857
|
+
let cursor = "0";
|
|
25858
|
+
do {
|
|
25859
|
+
const page = await this.client.sScan(this.sessionIdsKey(), cursor, {
|
|
25860
|
+
COUNT: SESSION_ID_BATCH
|
|
25861
|
+
});
|
|
25862
|
+
cursor = page.cursor;
|
|
25863
|
+
const removed = new Set(await this.pruneSessionIds(page.members));
|
|
25864
|
+
for (const id of page.members) if (!removed.has(id)) live.add(id);
|
|
25865
|
+
} while (cursor !== "0");
|
|
25866
|
+
return [...live];
|
|
25867
|
+
}
|
|
25868
|
+
/**
|
|
25869
|
+
* Check a few listed sessions after a save, so ids of expired sessions go
|
|
25870
|
+
* away for apps that never list. Sessions without a TTL don't expire, so
|
|
25871
|
+
* there's nothing to do. The save has already succeeded, so failing here
|
|
25872
|
+
* can't fail it.
|
|
25873
|
+
*/
|
|
25874
|
+
async sweepSessionIds() {
|
|
25875
|
+
if (this.ttlFor("session") === void 0 || this.sessionSweepRunning) return;
|
|
25876
|
+
this.sessionSweepRunning = true;
|
|
25877
|
+
try {
|
|
25878
|
+
const page = await this.client.sScan(this.sessionIdsKey(), this.sessionSweepCursor, {
|
|
25879
|
+
COUNT: SESSION_SWEEP_COUNT
|
|
25880
|
+
});
|
|
25881
|
+
this.sessionSweepCursor = page.cursor;
|
|
25882
|
+
await this.pruneSessionIds(page.members);
|
|
25883
|
+
} catch (err) {
|
|
25884
|
+
this.warnSessionPruneFailed(err);
|
|
25885
|
+
} finally {
|
|
25886
|
+
this.sessionSweepRunning = false;
|
|
25887
|
+
}
|
|
25888
|
+
}
|
|
25889
|
+
/** Returns the ids removed. Without permission to run the script, removes none and warns. */
|
|
25890
|
+
async pruneSessionIds(ids) {
|
|
25891
|
+
const removed = [];
|
|
25892
|
+
for (let start = 0; start < ids.length; start += SESSION_ID_BATCH) {
|
|
25893
|
+
const batch = ids.slice(start, start + SESSION_ID_BATCH);
|
|
25894
|
+
try {
|
|
25895
|
+
const result = await this.client.eval(PRUNE_SESSION_IDS_SCRIPT, {
|
|
25896
|
+
keys: [
|
|
25897
|
+
this.sessionIdsKey(),
|
|
25898
|
+
...batch.flatMap((id) => [this.sessionKey(id), this.sessionMetaKey(id)])
|
|
25899
|
+
],
|
|
25900
|
+
arguments: batch
|
|
25901
|
+
});
|
|
25902
|
+
removed.push(...result);
|
|
25903
|
+
} catch (err) {
|
|
25904
|
+
if (!errorMessage(err).includes("NOPERM")) throw err;
|
|
25905
|
+
this.warnSessionPruneFailed(err);
|
|
25906
|
+
return removed;
|
|
25907
|
+
}
|
|
25908
|
+
}
|
|
25909
|
+
return removed;
|
|
25910
|
+
}
|
|
25911
|
+
warnSessionPruneFailed(err) {
|
|
25912
|
+
if (!this.isConnected) return;
|
|
25913
|
+
warnDiagnosticOnce(
|
|
25914
|
+
"redis_session_ids_prune_failed",
|
|
25915
|
+
`RedisStore: removing expired session ids failed (${errorMessage(err)}), so listSessions can return sessions that have expired. The cleanup runs a Lua script, so the Redis user needs permission to run EVAL.`,
|
|
25916
|
+
void 0,
|
|
25917
|
+
"docs/api-reference.md#redisstore-options"
|
|
25918
|
+
);
|
|
25428
25919
|
}
|
|
25429
25920
|
// ── Streaming events (for state.persist: 'streaming') ──────────────────
|
|
25430
25921
|
async appendStreamingEvents(executionId, events) {
|
|
@@ -25464,10 +25955,34 @@ var RedisStore = class _RedisStore {
|
|
|
25464
25955
|
* must not get a rejected promise for it. `MemoryStore.close()` and
|
|
25465
25956
|
* better-sqlite3's `close()` are both no-ops the second time; node-redis
|
|
25466
25957
|
* instead rejects with "The client is closed", so absorb that here.
|
|
25958
|
+
*
|
|
25959
|
+
* Bounded, so a shutdown can't hang on Redis. Replies arrive in order, so a
|
|
25960
|
+
* reply to this PING means every earlier command has finished.
|
|
25961
|
+
*
|
|
25962
|
+
* Not `quit()`: it marks the client closed before the server answers, and
|
|
25963
|
+
* `destroy()` then throws without touching the socket, so a stalled server's
|
|
25964
|
+
* socket could never be closed. There's no other public way to close it. A
|
|
25965
|
+
* closed socket is all Redis needs to clean up, so nothing is lost.
|
|
25467
25966
|
*/
|
|
25468
25967
|
async close() {
|
|
25469
|
-
|
|
25470
|
-
|
|
25968
|
+
this.closing = true;
|
|
25969
|
+
this.stopResponseWatch?.();
|
|
25970
|
+
if (this.connection.isOpen === false) return;
|
|
25971
|
+
if (this.connection.isReady !== false) {
|
|
25972
|
+
let timer;
|
|
25973
|
+
try {
|
|
25974
|
+
await Promise.race([
|
|
25975
|
+
this.connection.ping(),
|
|
25976
|
+
new Promise((_resolve, reject) => {
|
|
25977
|
+
timer = setTimeout(() => reject(new Error("PING timed out")), CLOSE_TIMEOUT_MS);
|
|
25978
|
+
})
|
|
25979
|
+
]);
|
|
25980
|
+
} catch {
|
|
25981
|
+
} finally {
|
|
25982
|
+
clearTimeout(timer);
|
|
25983
|
+
}
|
|
25984
|
+
}
|
|
25985
|
+
abandonConnection(this.connection);
|
|
25471
25986
|
}
|
|
25472
25987
|
async deleteCheckpoints(executionId) {
|
|
25473
25988
|
await this.client.del(this.checkpointKey(executionId));
|