@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.cjs
CHANGED
|
@@ -602,7 +602,7 @@ var sharedGuard = typeof installedGuard === "object" && installedGuard !== null
|
|
|
602
602
|
var buildPath = typeof __AXL_BUILD_PATH__ === "string" ? __AXL_BUILD_PATH__ : "<source>";
|
|
603
603
|
var thisCopy = {
|
|
604
604
|
path: buildPath.startsWith("file:") ? (0, import_node_url.fileURLToPath)(buildPath) : buildPath,
|
|
605
|
-
version: true ? "0.27.
|
|
605
|
+
version: true ? "0.27.2" : "unknown"
|
|
606
606
|
};
|
|
607
607
|
function warnCrossCopy(other, joined) {
|
|
608
608
|
if (other === thisCopy) return;
|
|
@@ -17280,7 +17280,7 @@ ${appendedSchemaText}`;
|
|
|
17280
17280
|
`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.`
|
|
17281
17281
|
);
|
|
17282
17282
|
}
|
|
17283
|
-
return this._checkpoint(name, fn);
|
|
17283
|
+
return this._checkpoint(name, fn, { saveRequired: true });
|
|
17284
17284
|
}
|
|
17285
17285
|
/**
|
|
17286
17286
|
* Internal checkpoint implementation shared by both the public
|
|
@@ -17310,7 +17310,17 @@ ${appendedSchemaText}`;
|
|
|
17310
17310
|
return saved;
|
|
17311
17311
|
}
|
|
17312
17312
|
const result = await fn();
|
|
17313
|
-
|
|
17313
|
+
if (!options?.saveRequired && this.autoCheckpointCounters.saveFailed) return result;
|
|
17314
|
+
try {
|
|
17315
|
+
await this.stateStore.saveCheckpoint(this.executionId, name, result);
|
|
17316
|
+
} catch (err) {
|
|
17317
|
+
if (options?.saveRequired) throw err;
|
|
17318
|
+
this.autoCheckpointCounters.saveFailed = true;
|
|
17319
|
+
console.error(
|
|
17320
|
+
`[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)}`
|
|
17321
|
+
);
|
|
17322
|
+
return result;
|
|
17323
|
+
}
|
|
17314
17324
|
this.emitEvent({
|
|
17315
17325
|
type: "checkpoint_save",
|
|
17316
17326
|
...agentStamp,
|
|
@@ -19058,6 +19068,21 @@ function codePointRank(unit) {
|
|
|
19058
19068
|
var import_node_fs = require("fs");
|
|
19059
19069
|
var import_node_path = require("path");
|
|
19060
19070
|
var import_node_os = require("os");
|
|
19071
|
+
|
|
19072
|
+
// src/state/session-update.ts
|
|
19073
|
+
async function updateSessionThroughOverrides(store, storeName, sessionId, update) {
|
|
19074
|
+
warnDiagnosticOnce(
|
|
19075
|
+
`session_update_overridden\0${storeName}`,
|
|
19076
|
+
`${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.`,
|
|
19077
|
+
void 0,
|
|
19078
|
+
"docs/api-reference.md#several-app-instances-sharing-a-session"
|
|
19079
|
+
);
|
|
19080
|
+
const stored = await store.getSession(sessionId);
|
|
19081
|
+
const next = update(stored.length > 0 ? stored : null);
|
|
19082
|
+
if (next !== null) await store.saveSession(sessionId, next);
|
|
19083
|
+
}
|
|
19084
|
+
|
|
19085
|
+
// src/state/memory.ts
|
|
19061
19086
|
function compareOldestFirst(a, b) {
|
|
19062
19087
|
return compareExecutionsNewestFirst(b, a);
|
|
19063
19088
|
}
|
|
@@ -19074,7 +19099,7 @@ function resolveMaxExecutions(value) {
|
|
|
19074
19099
|
}
|
|
19075
19100
|
var AWAIT_HUMAN_TEMP_DIR = (0, import_node_path.join)((0, import_node_os.tmpdir)(), "axl-memory-store");
|
|
19076
19101
|
var AWAIT_HUMAN_TEMP_FILE = (0, import_node_path.join)(AWAIT_HUMAN_TEMP_DIR, "await-human-state.json");
|
|
19077
|
-
var MemoryStore = class {
|
|
19102
|
+
var MemoryStore = class _MemoryStore {
|
|
19078
19103
|
checkpoints = /* @__PURE__ */ new Map();
|
|
19079
19104
|
sessions = /* @__PURE__ */ new Map();
|
|
19080
19105
|
sessionMeta = /* @__PURE__ */ new Map();
|
|
@@ -19126,6 +19151,14 @@ var MemoryStore = class {
|
|
|
19126
19151
|
async saveSession(sessionId, history) {
|
|
19127
19152
|
this.sessions.set(sessionId, structuredClone(normalizePersistedSessionHistory(history)));
|
|
19128
19153
|
}
|
|
19154
|
+
async updateSession(sessionId, update) {
|
|
19155
|
+
if (this.saveSession !== _MemoryStore.prototype.saveSession || this.getSession !== _MemoryStore.prototype.getSession) {
|
|
19156
|
+
return updateSessionThroughOverrides(this, "MemoryStore", sessionId, update);
|
|
19157
|
+
}
|
|
19158
|
+
const stored = this.sessions.get(sessionId);
|
|
19159
|
+
const next = update(stored ? structuredClone(stored) : null);
|
|
19160
|
+
if (next !== null) await this.saveSession(sessionId, next);
|
|
19161
|
+
}
|
|
19129
19162
|
async getSession(sessionId) {
|
|
19130
19163
|
const history = this.sessions.get(sessionId);
|
|
19131
19164
|
return history ? structuredClone(history) : [];
|
|
@@ -19549,7 +19582,7 @@ function rowToExecutionInfo(row) {
|
|
|
19549
19582
|
}
|
|
19550
19583
|
return normalizeStoredExecution(info);
|
|
19551
19584
|
}
|
|
19552
|
-
var SQLiteStore = class {
|
|
19585
|
+
var SQLiteStore = class _SQLiteStore {
|
|
19553
19586
|
db;
|
|
19554
19587
|
ttls;
|
|
19555
19588
|
now;
|
|
@@ -19783,6 +19816,17 @@ var SQLiteStore = class {
|
|
|
19783
19816
|
);
|
|
19784
19817
|
stmt.run(sessionId, JSON.stringify(normalizePersistedSessionHistory(history)));
|
|
19785
19818
|
}
|
|
19819
|
+
async updateSession(sessionId, update) {
|
|
19820
|
+
if (this.saveSession !== _SQLiteStore.prototype.saveSession || this.getSession !== _SQLiteStore.prototype.getSession) {
|
|
19821
|
+
return updateSessionThroughOverrides(this, "SQLiteStore", sessionId, update);
|
|
19822
|
+
}
|
|
19823
|
+
this.db.transaction(() => {
|
|
19824
|
+
const row = this.db.prepare("SELECT history FROM sessions WHERE session_id = ?").get(sessionId);
|
|
19825
|
+
const next = update(row ? safeJsonParse(row.history) ?? [] : null);
|
|
19826
|
+
if (next === null) return;
|
|
19827
|
+
this.db.prepare("INSERT OR REPLACE INTO sessions (session_id, history) VALUES (?, ?)").run(sessionId, JSON.stringify(normalizePersistedSessionHistory(next)));
|
|
19828
|
+
}).immediate();
|
|
19829
|
+
}
|
|
19786
19830
|
async getSession(sessionId) {
|
|
19787
19831
|
const stmt = this.db.prepare("SELECT history FROM sessions WHERE session_id = ?");
|
|
19788
19832
|
const row = stmt.get(sessionId);
|
|
@@ -20117,10 +20161,10 @@ var Session = class _Session {
|
|
|
20117
20161
|
);
|
|
20118
20162
|
}
|
|
20119
20163
|
async sendImpl(workflowName, input, signal, events) {
|
|
20120
|
-
const { history, metadata } = await this.prepareHistory(input);
|
|
20164
|
+
const { history, metadata, base } = await this.prepareHistory(input);
|
|
20121
20165
|
try {
|
|
20122
20166
|
const result = await this.runtime.execute(workflowName, input, { metadata, signal, events });
|
|
20123
|
-
await this.commitHistory(history, result);
|
|
20167
|
+
await this.commitHistory(history, base, result);
|
|
20124
20168
|
return result;
|
|
20125
20169
|
} finally {
|
|
20126
20170
|
clearSessionInput(history);
|
|
@@ -20136,6 +20180,8 @@ var Session = class _Session {
|
|
|
20136
20180
|
async prepareHistory(input) {
|
|
20137
20181
|
const preparedInput = prepareSessionInput(input);
|
|
20138
20182
|
const history = await this.store.getSession(this.sessionId);
|
|
20183
|
+
const storedJson = JSON.stringify(history);
|
|
20184
|
+
const hadHistory = history.length > 0;
|
|
20139
20185
|
let cachedSummary = await this.store.getSessionMeta(this.sessionId, "summaryCache");
|
|
20140
20186
|
const maxMessages = this.options.history?.maxMessages;
|
|
20141
20187
|
if (maxMessages && history.length > maxMessages) {
|
|
@@ -20150,13 +20196,17 @@ var Session = class _Session {
|
|
|
20150
20196
|
summaryModel,
|
|
20151
20197
|
cachedSummary ? { previousSummary: cachedSummary } : void 0
|
|
20152
20198
|
);
|
|
20153
|
-
await this.store.
|
|
20199
|
+
const stored = JSON.stringify(await this.store.getSession(this.sessionId));
|
|
20200
|
+
if (stored === storedJson) {
|
|
20201
|
+
await this.store.saveSessionMeta(this.sessionId, "summaryCache", summary);
|
|
20202
|
+
}
|
|
20154
20203
|
cachedSummary = summary;
|
|
20155
20204
|
}
|
|
20156
20205
|
const trimmed = history.slice(-maxMessages);
|
|
20157
20206
|
history.length = 0;
|
|
20158
20207
|
history.push(...trimmed.map(withoutAnthropicThinking));
|
|
20159
20208
|
}
|
|
20209
|
+
const base = { storedJson, hadHistory, firstOwnMessage: history.length };
|
|
20160
20210
|
const userMessage = { role: "user", content: preparedInput.content };
|
|
20161
20211
|
history.push(userMessage);
|
|
20162
20212
|
if (this.options.deduplicateInput !== false && preparedInput.normalizedInput !== void 0) {
|
|
@@ -20170,13 +20220,13 @@ var Session = class _Session {
|
|
|
20170
20220
|
// agent-scoped ask-summary records the runtime would otherwise cache.
|
|
20171
20221
|
...this.options.persist === false ? { sessionPersist: false } : {}
|
|
20172
20222
|
};
|
|
20173
|
-
return { history, metadata };
|
|
20223
|
+
return { history, metadata, base };
|
|
20174
20224
|
}
|
|
20175
20225
|
/** Push the assistant reply (if `executeAgentCall` didn't already) and
|
|
20176
20226
|
* persist. Shared by `sendImpl` and `streamImpl`. The fallback assistant
|
|
20177
20227
|
* push has no agent context and so leaves `agent` undefined — see the
|
|
20178
20228
|
* `ChatMessage.agent` field doc. */
|
|
20179
|
-
async commitHistory(history, result) {
|
|
20229
|
+
async commitHistory(history, base, result) {
|
|
20180
20230
|
const lastMsg = history[history.length - 1];
|
|
20181
20231
|
if (!(lastMsg && lastMsg.role === "assistant")) {
|
|
20182
20232
|
history.push({
|
|
@@ -20184,8 +20234,35 @@ var Session = class _Session {
|
|
|
20184
20234
|
content: typeof result === "string" ? result : JSON.stringify(result)
|
|
20185
20235
|
});
|
|
20186
20236
|
}
|
|
20187
|
-
if (this.options.persist
|
|
20237
|
+
if (this.options.persist === false) return;
|
|
20238
|
+
if (!this.store.updateSession) {
|
|
20188
20239
|
await this.store.saveSession(this.sessionId, history);
|
|
20240
|
+
return;
|
|
20241
|
+
}
|
|
20242
|
+
let outcome = "saved";
|
|
20243
|
+
await this.store.updateSession(this.sessionId, (current) => {
|
|
20244
|
+
if (current === null) {
|
|
20245
|
+
outcome = base.hadHistory ? "discarded" : "saved";
|
|
20246
|
+
return base.hadHistory ? null : history;
|
|
20247
|
+
}
|
|
20248
|
+
if (JSON.stringify(current) === base.storedJson) {
|
|
20249
|
+
outcome = "saved";
|
|
20250
|
+
return history;
|
|
20251
|
+
}
|
|
20252
|
+
outcome = "merged";
|
|
20253
|
+
return [...current, ...history.slice(base.firstOwnMessage).map(withoutAnthropicThinking)];
|
|
20254
|
+
});
|
|
20255
|
+
if (outcome === "merged") this.notify("session_turns_merged");
|
|
20256
|
+
else if (outcome === "discarded") this.notify("session_turn_discarded");
|
|
20257
|
+
}
|
|
20258
|
+
/** The turn is already saved, so a listener that throws can't be allowed to fail it. */
|
|
20259
|
+
notify(event) {
|
|
20260
|
+
try {
|
|
20261
|
+
this.runtime.emit(event, { sessionId: this.sessionId });
|
|
20262
|
+
} catch (err) {
|
|
20263
|
+
console.error(
|
|
20264
|
+
`[axl] A ${event} listener failed: ${err instanceof Error ? err.message : String(err)}`
|
|
20265
|
+
);
|
|
20189
20266
|
}
|
|
20190
20267
|
}
|
|
20191
20268
|
async stream(workflowName, input, options) {
|
|
@@ -20213,10 +20290,12 @@ var Session = class _Session {
|
|
|
20213
20290
|
}
|
|
20214
20291
|
async streamImpl(workflowName, input, resolveReady, rejectReady, signal, events) {
|
|
20215
20292
|
let history;
|
|
20293
|
+
let base;
|
|
20216
20294
|
let axlStream;
|
|
20217
20295
|
try {
|
|
20218
20296
|
const prepared = await this.prepareHistory(input);
|
|
20219
20297
|
history = prepared.history;
|
|
20298
|
+
base = prepared.base;
|
|
20220
20299
|
axlStream = this.runtime.stream(workflowName, input, {
|
|
20221
20300
|
metadata: prepared.metadata,
|
|
20222
20301
|
signal,
|
|
@@ -20230,12 +20309,19 @@ var Session = class _Session {
|
|
|
20230
20309
|
resolveReady(axlStream);
|
|
20231
20310
|
try {
|
|
20232
20311
|
const completion = axlStream.promise.then(
|
|
20233
|
-
(result) => this.commitHistory(history, result).catch((err) => {
|
|
20234
|
-
|
|
20235
|
-
|
|
20236
|
-
|
|
20237
|
-
|
|
20238
|
-
|
|
20312
|
+
(result) => this.commitHistory(history, base, result).catch((err) => {
|
|
20313
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
20314
|
+
try {
|
|
20315
|
+
this.runtime.emit("error", {
|
|
20316
|
+
type: "session_history_save_failed",
|
|
20317
|
+
sessionId: this.sessionId,
|
|
20318
|
+
error
|
|
20319
|
+
});
|
|
20320
|
+
} catch {
|
|
20321
|
+
console.error(
|
|
20322
|
+
`[axl] Failed to save session history for sessionId=${this.sessionId}: ${error}`
|
|
20323
|
+
);
|
|
20324
|
+
}
|
|
20239
20325
|
}),
|
|
20240
20326
|
() => void 0
|
|
20241
20327
|
);
|
|
@@ -22302,7 +22388,11 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
|
|
|
22302
22388
|
observation
|
|
22303
22389
|
});
|
|
22304
22390
|
if (this.stateStore.deleteCheckpoints) {
|
|
22305
|
-
await this.stateStore.deleteCheckpoints(execInfo.executionId)
|
|
22391
|
+
await this.stateStore.deleteCheckpoints(execInfo.executionId).catch((err) => {
|
|
22392
|
+
console.error(
|
|
22393
|
+
`[axl] Failed to delete checkpoints for executionId=${execInfo.executionId}: ` + (err instanceof Error ? err.message : String(err))
|
|
22394
|
+
);
|
|
22395
|
+
});
|
|
22306
22396
|
}
|
|
22307
22397
|
span?.setAttribute("axl.workflow.cost", execInfo.totalCost);
|
|
22308
22398
|
span?.setAttribute("axl.workflow.duration", execInfo.duration);
|
|
@@ -22374,7 +22464,10 @@ var AxlRuntime = class extends import_node_events2.EventEmitter {
|
|
|
22374
22464
|
const chain = this.stateStore.saveExecution(snapshot).then(() => {
|
|
22375
22465
|
return this.streamingFlusher?.finalize(id).catch(() => {
|
|
22376
22466
|
});
|
|
22377
|
-
}).catch(() => {
|
|
22467
|
+
}).catch((err) => {
|
|
22468
|
+
console.error(
|
|
22469
|
+
`[axl] Failed to save execution history for executionId=${id}: ` + (err instanceof Error ? err.message : String(err))
|
|
22470
|
+
);
|
|
22378
22471
|
}).then(() => void 0);
|
|
22379
22472
|
this.persistInflight.add(chain);
|
|
22380
22473
|
const pending = { snapshot, settled: chain };
|
|
@@ -24766,6 +24859,7 @@ async function* stringStreamFromEvents(source, opts) {
|
|
|
24766
24859
|
}
|
|
24767
24860
|
|
|
24768
24861
|
// src/state/redis.ts
|
|
24862
|
+
var import_node_crypto6 = require("crypto");
|
|
24769
24863
|
var PRUNE_HISTORY_INDEX_SCRIPT = `
|
|
24770
24864
|
local now = tonumber(ARGV[1])
|
|
24771
24865
|
local removed = 0
|
|
@@ -24785,6 +24879,34 @@ for i = 2, #ARGV do
|
|
|
24785
24879
|
end
|
|
24786
24880
|
return removed
|
|
24787
24881
|
`;
|
|
24882
|
+
var PRUNE_SESSION_IDS_SCRIPT = `
|
|
24883
|
+
local removed = {}
|
|
24884
|
+
for i, id in ipairs(ARGV) do
|
|
24885
|
+
if redis.call('EXISTS', KEYS[i * 2]) == 0 then
|
|
24886
|
+
redis.call('SREM', KEYS[1], id)
|
|
24887
|
+
redis.call('DEL', KEYS[i * 2 + 1])
|
|
24888
|
+
removed[#removed + 1] = id
|
|
24889
|
+
end
|
|
24890
|
+
end
|
|
24891
|
+
return removed
|
|
24892
|
+
`;
|
|
24893
|
+
var SESSION_ID_BATCH = 100;
|
|
24894
|
+
var SESSION_SWEEP_COUNT = 50;
|
|
24895
|
+
var UPDATE_SESSION_SCRIPT = `
|
|
24896
|
+
local stored = redis.call('GET', KEYS[1])
|
|
24897
|
+
local actual = stored and redis.sha1hex(stored) or ''
|
|
24898
|
+
if actual ~= ARGV[1] then
|
|
24899
|
+
return 0
|
|
24900
|
+
end
|
|
24901
|
+
if ARGV[3] ~= '' then
|
|
24902
|
+
redis.call('SET', KEYS[1], ARGV[2], 'EX', ARGV[3])
|
|
24903
|
+
else
|
|
24904
|
+
redis.call('SET', KEYS[1], ARGV[2])
|
|
24905
|
+
end
|
|
24906
|
+
redis.call('SADD', KEYS[2], ARGV[4])
|
|
24907
|
+
return 1
|
|
24908
|
+
`;
|
|
24909
|
+
var UPDATE_SESSION_ATTEMPTS = 10;
|
|
24788
24910
|
function compareMembers(a, b) {
|
|
24789
24911
|
return Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"));
|
|
24790
24912
|
}
|
|
@@ -24797,12 +24919,222 @@ function warnIfRedisExecutionRetentionUnset(opts) {
|
|
|
24797
24919
|
warnExecutionRetentionUnset("RedisStore");
|
|
24798
24920
|
}
|
|
24799
24921
|
}
|
|
24922
|
+
var CLOSE_TIMEOUT_MS = 2e3;
|
|
24923
|
+
function reconnectDelayMs(retries) {
|
|
24924
|
+
return Math.min(2 ** retries * 50, 2e3) + Math.floor(Math.random() * 200);
|
|
24925
|
+
}
|
|
24926
|
+
function abandonConnection(client) {
|
|
24927
|
+
try {
|
|
24928
|
+
client.destroy?.();
|
|
24929
|
+
} catch {
|
|
24930
|
+
}
|
|
24931
|
+
}
|
|
24932
|
+
function errorMessage(error) {
|
|
24933
|
+
return error instanceof Error ? error.message : String(error);
|
|
24934
|
+
}
|
|
24935
|
+
function connectionEventReporter(client, onConnectionEvent) {
|
|
24936
|
+
if (onConnectionEvent) {
|
|
24937
|
+
const reportFailure = (error) => console.error(`[axl] RedisStore: onConnectionEvent failed: ${errorMessage(error)}`);
|
|
24938
|
+
return (event) => {
|
|
24939
|
+
try {
|
|
24940
|
+
Promise.resolve(onConnectionEvent(event)).catch(reportFailure);
|
|
24941
|
+
} catch (error) {
|
|
24942
|
+
reportFailure(error);
|
|
24943
|
+
}
|
|
24944
|
+
};
|
|
24945
|
+
}
|
|
24946
|
+
let outageLogged = false;
|
|
24947
|
+
return (event) => {
|
|
24948
|
+
if (event.type === "reconnected") {
|
|
24949
|
+
outageLogged = false;
|
|
24950
|
+
console.warn("[axl] RedisStore: reconnected to Redis.");
|
|
24951
|
+
} else if (!outageLogged) {
|
|
24952
|
+
outageLogged = client.isReady === false;
|
|
24953
|
+
console.warn(
|
|
24954
|
+
`[axl] RedisStore: Redis connection lost (${event.error.message}). Store calls fail until it reconnects, which happens automatically.`
|
|
24955
|
+
);
|
|
24956
|
+
}
|
|
24957
|
+
};
|
|
24958
|
+
}
|
|
24959
|
+
var LOOP_STALL_TOLERANCE_MS = 100;
|
|
24960
|
+
var LOOP_STALL_GRACE_MS = 1e3;
|
|
24961
|
+
function watchResponses(client, timeoutMs, reportConnectionEvent) {
|
|
24962
|
+
const checkEveryMs = timeoutMs / 3;
|
|
24963
|
+
let timer;
|
|
24964
|
+
let stopped = false;
|
|
24965
|
+
const schedule = (run, delayMs) => {
|
|
24966
|
+
timer = setTimeout(run, delayMs);
|
|
24967
|
+
timer.unref?.();
|
|
24968
|
+
};
|
|
24969
|
+
const check = () => {
|
|
24970
|
+
if (client.isReady === false) {
|
|
24971
|
+
schedule(check, checkEveryMs);
|
|
24972
|
+
return;
|
|
24973
|
+
}
|
|
24974
|
+
let settled = false;
|
|
24975
|
+
const onReply = () => {
|
|
24976
|
+
if (settled || stopped) return;
|
|
24977
|
+
settled = true;
|
|
24978
|
+
clearTimeout(timer);
|
|
24979
|
+
schedule(check, checkEveryMs);
|
|
24980
|
+
};
|
|
24981
|
+
const expireAfter = (delayMs, mayWaitForBlockedLoop) => {
|
|
24982
|
+
const dueAt = Date.now() + delayMs;
|
|
24983
|
+
schedule(() => {
|
|
24984
|
+
if (settled || stopped) return;
|
|
24985
|
+
if (mayWaitForBlockedLoop && Date.now() - dueAt > LOOP_STALL_TOLERANCE_MS) {
|
|
24986
|
+
expireAfter(LOOP_STALL_GRACE_MS, false);
|
|
24987
|
+
return;
|
|
24988
|
+
}
|
|
24989
|
+
reset();
|
|
24990
|
+
}, delayMs);
|
|
24991
|
+
};
|
|
24992
|
+
const reset = () => {
|
|
24993
|
+
settled = true;
|
|
24994
|
+
if (client.isReady === false) {
|
|
24995
|
+
schedule(check, checkEveryMs);
|
|
24996
|
+
return;
|
|
24997
|
+
}
|
|
24998
|
+
client.destroy?.();
|
|
24999
|
+
client.connect().catch(() => {
|
|
25000
|
+
});
|
|
25001
|
+
reportConnectionEvent({
|
|
25002
|
+
type: "error",
|
|
25003
|
+
error: new Error(`Redis did not answer within ${timeoutMs}ms; reconnecting.`)
|
|
25004
|
+
});
|
|
25005
|
+
schedule(check, checkEveryMs);
|
|
25006
|
+
};
|
|
25007
|
+
client.ping().then(onReply, onReply);
|
|
25008
|
+
expireAfter(timeoutMs, true);
|
|
25009
|
+
};
|
|
25010
|
+
schedule(check, checkEveryMs);
|
|
25011
|
+
return () => {
|
|
25012
|
+
stopped = true;
|
|
25013
|
+
clearTimeout(timer);
|
|
25014
|
+
};
|
|
25015
|
+
}
|
|
25016
|
+
async function connectWithin(connecting, timeoutMs) {
|
|
25017
|
+
let timer;
|
|
25018
|
+
try {
|
|
25019
|
+
await Promise.race([
|
|
25020
|
+
connecting,
|
|
25021
|
+
new Promise((_resolve, reject) => {
|
|
25022
|
+
timer = setTimeout(
|
|
25023
|
+
() => reject(
|
|
25024
|
+
new AxlError(
|
|
25025
|
+
"REDIS_UNAVAILABLE",
|
|
25026
|
+
`RedisStore: Redis did not finish connecting within ${timeoutMs}ms.`
|
|
25027
|
+
)
|
|
25028
|
+
),
|
|
25029
|
+
timeoutMs
|
|
25030
|
+
);
|
|
25031
|
+
})
|
|
25032
|
+
]);
|
|
25033
|
+
} finally {
|
|
25034
|
+
clearTimeout(timer);
|
|
25035
|
+
}
|
|
25036
|
+
}
|
|
25037
|
+
async function connectClient(createClient, opts) {
|
|
25038
|
+
const inBackground = opts.waitForConnection === false;
|
|
25039
|
+
let established = inBackground;
|
|
25040
|
+
let everFailed = false;
|
|
25041
|
+
const responseTimeoutMs = opts.responseTimeoutMs;
|
|
25042
|
+
const client = createClient({
|
|
25043
|
+
...opts.url ? { url: opts.url } : {},
|
|
25044
|
+
// A command accepted just before a drop rejects instead of waiting for
|
|
25045
|
+
// the reconnect.
|
|
25046
|
+
disableOfflineQueue: true,
|
|
25047
|
+
socket: {
|
|
25048
|
+
reconnectStrategy: (retries) => established ? reconnectDelayMs(retries) : false,
|
|
25049
|
+
// Bounds a reconnect's handshake, where the response watch doesn't run.
|
|
25050
|
+
// Twice the timeout so that on an open connection the watch acts first:
|
|
25051
|
+
// this timer fires after any long event-loop block, which the watch allows for.
|
|
25052
|
+
...responseTimeoutMs !== void 0 ? { socketTimeout: responseTimeoutMs * 2 } : {}
|
|
25053
|
+
}
|
|
25054
|
+
});
|
|
25055
|
+
const reportConnectionEvent = connectionEventReporter(client, opts.onConnectionEvent);
|
|
25056
|
+
client.on("error", (error) => {
|
|
25057
|
+
if (!established || client.isOpen === false) return;
|
|
25058
|
+
everFailed = true;
|
|
25059
|
+
reportConnectionEvent({
|
|
25060
|
+
type: "error",
|
|
25061
|
+
error: error instanceof Error ? error : new Error(String(error))
|
|
25062
|
+
});
|
|
25063
|
+
});
|
|
25064
|
+
client.on("connect", () => {
|
|
25065
|
+
if (client.isOpen === false) client.unref?.();
|
|
25066
|
+
});
|
|
25067
|
+
client.on("ready", () => {
|
|
25068
|
+
if (!established) {
|
|
25069
|
+
established = true;
|
|
25070
|
+
} else if (client.isOpen !== false && (!inBackground || everFailed)) {
|
|
25071
|
+
reportConnectionEvent({ type: "reconnected" });
|
|
25072
|
+
}
|
|
25073
|
+
});
|
|
25074
|
+
if (inBackground) {
|
|
25075
|
+
client.connect().catch(() => {
|
|
25076
|
+
});
|
|
25077
|
+
return {
|
|
25078
|
+
client,
|
|
25079
|
+
stopResponseWatch: watchResponses(client, responseTimeoutMs, reportConnectionEvent)
|
|
25080
|
+
};
|
|
25081
|
+
}
|
|
25082
|
+
if (responseTimeoutMs === void 0) {
|
|
25083
|
+
await client.connect();
|
|
25084
|
+
return { client };
|
|
25085
|
+
}
|
|
25086
|
+
try {
|
|
25087
|
+
await connectWithin(client.connect(), responseTimeoutMs);
|
|
25088
|
+
} catch (error) {
|
|
25089
|
+
abandonConnection(client);
|
|
25090
|
+
throw error;
|
|
25091
|
+
}
|
|
25092
|
+
return {
|
|
25093
|
+
client,
|
|
25094
|
+
stopResponseWatch: watchResponses(client, responseTimeoutMs, reportConnectionEvent)
|
|
25095
|
+
};
|
|
25096
|
+
}
|
|
24800
25097
|
var RedisStore = class _RedisStore {
|
|
24801
|
-
constructor(
|
|
24802
|
-
this.
|
|
25098
|
+
constructor(connection, keyPrefix, ttls) {
|
|
25099
|
+
this.connection = connection;
|
|
24803
25100
|
this.keyPrefix = keyPrefix;
|
|
24804
25101
|
this.ttls = ttls;
|
|
24805
25102
|
}
|
|
25103
|
+
/** Set when `close()` starts, while the connection is still open. */
|
|
25104
|
+
closing = false;
|
|
25105
|
+
/** Where the next save's check of listed sessions picks up. */
|
|
25106
|
+
sessionSweepCursor = "0";
|
|
25107
|
+
/** Overlapping saves would all check the same page, so only one checks at a time. */
|
|
25108
|
+
sessionSweepRunning = false;
|
|
25109
|
+
/**
|
|
25110
|
+
* Whether the store can send commands right now, for a health check or for
|
|
25111
|
+
* telling an outage from other failures when a call rejects. `false` while
|
|
25112
|
+
* reconnecting and after `close()`.
|
|
25113
|
+
*/
|
|
25114
|
+
get isConnected() {
|
|
25115
|
+
return !this.closing && this.connection.isOpen !== false && this.connection.isReady !== false;
|
|
25116
|
+
}
|
|
25117
|
+
/**
|
|
25118
|
+
* The client for sending a command, failing immediately while disconnected.
|
|
25119
|
+
*
|
|
25120
|
+
* Every command goes through here because node-redis's `disableOfflineQueue`
|
|
25121
|
+
* rejects single commands but not `multi().exec()`, which would wait for a
|
|
25122
|
+
* reconnect attempt to fail or succeed. Nothing may `await` between
|
|
25123
|
+
* `client.multi()` and `.exec()`, or a transaction could get past this check.
|
|
25124
|
+
*
|
|
25125
|
+
* A closed store isn't "unavailable": it falls through to node-redis's own
|
|
25126
|
+
* "The client is closed" rejection.
|
|
25127
|
+
*/
|
|
25128
|
+
get client() {
|
|
25129
|
+
if (this.closing) throw new Error("The client is closed");
|
|
25130
|
+
if (this.connection.isOpen !== false && this.connection.isReady === false) {
|
|
25131
|
+
throw new AxlError(
|
|
25132
|
+
"REDIS_UNAVAILABLE",
|
|
25133
|
+
"RedisStore: not connected to Redis, so the command was not sent. The store reconnects automatically; retry once Redis is back."
|
|
25134
|
+
);
|
|
25135
|
+
}
|
|
25136
|
+
return this.connection;
|
|
25137
|
+
}
|
|
24806
25138
|
/**
|
|
24807
25139
|
* History kinds with a prune running in this process. A save skips its
|
|
24808
25140
|
* prune while another one of the same kind is in flight: the running one
|
|
@@ -24810,6 +25142,8 @@ var RedisStore = class _RedisStore {
|
|
|
24810
25142
|
* instance needs no constructor state.
|
|
24811
25143
|
*/
|
|
24812
25144
|
prunesInFlight;
|
|
25145
|
+
/** Stops the `responseTimeoutMs` health checks; unset when the option is off. */
|
|
25146
|
+
stopResponseWatch;
|
|
24813
25147
|
/**
|
|
24814
25148
|
* Supports `listExecutions(limit, { cursor })`. A getter rather than a
|
|
24815
25149
|
* field so it lives on the prototype.
|
|
@@ -24859,6 +25193,17 @@ var RedisStore = class _RedisStore {
|
|
|
24859
25193
|
'RedisStore: keyPrefix cannot be empty string. Omit it for the default "axl:" prefix.'
|
|
24860
25194
|
);
|
|
24861
25195
|
}
|
|
25196
|
+
const responseTimeoutMs = opts.responseTimeoutMs;
|
|
25197
|
+
if (responseTimeoutMs !== void 0 && !(Number.isFinite(responseTimeoutMs) && responseTimeoutMs > 0)) {
|
|
25198
|
+
throw new Error(
|
|
25199
|
+
`RedisStore: responseTimeoutMs must be a positive number of milliseconds, got ${responseTimeoutMs}.`
|
|
25200
|
+
);
|
|
25201
|
+
}
|
|
25202
|
+
if (opts.waitForConnection === false && responseTimeoutMs === void 0) {
|
|
25203
|
+
throw new Error(
|
|
25204
|
+
"RedisStore: waitForConnection: false requires responseTimeoutMs, so a server that accepts the connection but never answers cannot leave it connecting forever."
|
|
25205
|
+
);
|
|
25206
|
+
}
|
|
24862
25207
|
let createClient;
|
|
24863
25208
|
try {
|
|
24864
25209
|
const mod = require("redis");
|
|
@@ -24872,8 +25217,7 @@ var RedisStore = class _RedisStore {
|
|
|
24872
25217
|
if (err instanceof Error && err.message.includes("createClient")) throw err;
|
|
24873
25218
|
throw new Error("redis is required for RedisStore. Install it with: npm install redis");
|
|
24874
25219
|
}
|
|
24875
|
-
const client =
|
|
24876
|
-
await client.connect();
|
|
25220
|
+
const { client, stopResponseWatch } = await connectClient(createClient, opts);
|
|
24877
25221
|
const resolveCategory = (override) => {
|
|
24878
25222
|
if (override === null) return null;
|
|
24879
25223
|
if (typeof override === "number") return override;
|
|
@@ -24895,13 +25239,48 @@ var RedisStore = class _RedisStore {
|
|
|
24895
25239
|
streamingEvents: resolveStreamingEvents(opts.ttls?.streamingEvents)
|
|
24896
25240
|
};
|
|
24897
25241
|
const store = new _RedisStore(client, keyPrefix, effectiveTtls);
|
|
25242
|
+
store.stopResponseWatch = stopResponseWatch;
|
|
24898
25243
|
warnIfRedisExecutionRetentionUnset(opts);
|
|
25244
|
+
if (opts.waitForConnection === false) {
|
|
25245
|
+
if (!opts.skipMigration) store.backfillOnceConnected(client);
|
|
25246
|
+
return store;
|
|
25247
|
+
}
|
|
24899
25248
|
if (!opts.skipMigration) {
|
|
24900
|
-
|
|
24901
|
-
|
|
25249
|
+
try {
|
|
25250
|
+
await store.backfillExecutionIndex();
|
|
25251
|
+
await store.backfillEvalIndex();
|
|
25252
|
+
} catch (error) {
|
|
25253
|
+
stopResponseWatch?.();
|
|
25254
|
+
abandonConnection(client);
|
|
25255
|
+
throw error;
|
|
25256
|
+
}
|
|
24902
25257
|
}
|
|
24903
25258
|
return store;
|
|
24904
25259
|
}
|
|
25260
|
+
/**
|
|
25261
|
+
* Run the startup backfill when a connection becomes ready, until it succeeds.
|
|
25262
|
+
* Nothing is waiting on it, and listings work without it, so a failure is
|
|
25263
|
+
* reported and the next connection tries again.
|
|
25264
|
+
*/
|
|
25265
|
+
backfillOnceConnected(client) {
|
|
25266
|
+
let state = "waiting";
|
|
25267
|
+
client.on("ready", () => {
|
|
25268
|
+
if (state !== "waiting") return;
|
|
25269
|
+
state = "running";
|
|
25270
|
+
this.backfillExecutionIndex().then(() => this.backfillEvalIndex()).then(
|
|
25271
|
+
() => {
|
|
25272
|
+
state = "done";
|
|
25273
|
+
},
|
|
25274
|
+
(err) => {
|
|
25275
|
+
state = "waiting";
|
|
25276
|
+
if (client.isReady === false || client.isOpen === false) return;
|
|
25277
|
+
console.warn(
|
|
25278
|
+
`[axl] RedisStore: the startup index backfill failed (${errorMessage(err)}). Listings stay correct meanwhile.`
|
|
25279
|
+
);
|
|
25280
|
+
}
|
|
25281
|
+
);
|
|
25282
|
+
});
|
|
25283
|
+
}
|
|
24905
25284
|
/**
|
|
24906
25285
|
* Backfill the execution-history sorted-set from the legacy ID set.
|
|
24907
25286
|
* Idempotent — runs only if the ZSET is missing entries the SET has.
|
|
@@ -24909,36 +25288,30 @@ var RedisStore = class _RedisStore {
|
|
|
24909
25288
|
* `skipMigration: true`.
|
|
24910
25289
|
*/
|
|
24911
25290
|
async backfillExecutionIndex() {
|
|
24912
|
-
await this.backfillHistoryIndex(
|
|
24913
|
-
|
|
24914
|
-
|
|
24915
|
-
|
|
24916
|
-
(raw) => {
|
|
24917
|
-
const exec = JSON.parse(raw);
|
|
24918
|
-
return typeof exec.startedAt === "number" ? { score: exec.startedAt, value: exec.executionId } : null;
|
|
24919
|
-
},
|
|
24920
|
-
"execution-history"
|
|
24921
|
-
);
|
|
25291
|
+
await this.backfillHistoryIndex(this.historyIndex("execution"), (raw) => {
|
|
25292
|
+
const exec = JSON.parse(raw);
|
|
25293
|
+
return typeof exec.startedAt === "number" ? { score: exec.startedAt, value: exec.executionId } : null;
|
|
25294
|
+
});
|
|
24922
25295
|
}
|
|
24923
25296
|
/** Backfill the eval-history sorted-set. Symmetric to `backfillExecutionIndex`. */
|
|
24924
25297
|
async backfillEvalIndex() {
|
|
24925
|
-
await this.backfillHistoryIndex(
|
|
24926
|
-
|
|
24927
|
-
|
|
24928
|
-
|
|
24929
|
-
(raw) => {
|
|
24930
|
-
const entry = JSON.parse(raw);
|
|
24931
|
-
return typeof entry.timestamp === "number" ? { score: entry.timestamp, value: entry.id } : null;
|
|
24932
|
-
},
|
|
24933
|
-
"eval-history"
|
|
24934
|
-
);
|
|
25298
|
+
await this.backfillHistoryIndex(this.historyIndex("eval"), (raw) => {
|
|
25299
|
+
const entry = JSON.parse(raw);
|
|
25300
|
+
return typeof entry.timestamp === "number" ? { score: entry.timestamp, value: entry.id } : null;
|
|
25301
|
+
});
|
|
24935
25302
|
}
|
|
24936
25303
|
/**
|
|
24937
25304
|
* Shared implementation for both backfill paths. Reads SET members,
|
|
24938
25305
|
* batch-MGETs their data blobs in chunks, and ZADDs each into the new
|
|
24939
25306
|
* sorted-set inside a single MULTI per chunk.
|
|
25307
|
+
*
|
|
25308
|
+
* Ids with no data blob are removed from the id set. Left there, the set stays
|
|
25309
|
+
* larger than the sorted set, so every startup repeats this walk and listing
|
|
25310
|
+
* stays on its slow path.
|
|
24940
25311
|
*/
|
|
24941
|
-
async backfillHistoryIndex(
|
|
25312
|
+
async backfillHistoryIndex(index, scoreOf) {
|
|
25313
|
+
const setKey = index.ids;
|
|
25314
|
+
const zsetKey = index.time;
|
|
24942
25315
|
const [setSize, zsetSize] = await Promise.all([
|
|
24943
25316
|
this.client.sCard(setKey),
|
|
24944
25317
|
this.client.zCard(zsetKey)
|
|
@@ -24949,9 +25322,10 @@ var RedisStore = class _RedisStore {
|
|
|
24949
25322
|
const CHUNK_SIZE = 500;
|
|
24950
25323
|
let migrated = 0;
|
|
24951
25324
|
let skipped = 0;
|
|
25325
|
+
const missing = [];
|
|
24952
25326
|
for (let i = 0; i < allIds.length; i += CHUNK_SIZE) {
|
|
24953
25327
|
const chunk = allIds.slice(i, i + CHUNK_SIZE);
|
|
24954
|
-
const dataKeys = chunk.map((id) => dataKey(id));
|
|
25328
|
+
const dataKeys = chunk.map((id) => index.dataKey(id));
|
|
24955
25329
|
const raws = await this.client.mGet(dataKeys);
|
|
24956
25330
|
const tx = this.client.multi();
|
|
24957
25331
|
let queuedInChunk = 0;
|
|
@@ -24959,6 +25333,7 @@ var RedisStore = class _RedisStore {
|
|
|
24959
25333
|
const raw = raws[j];
|
|
24960
25334
|
if (raw == null) {
|
|
24961
25335
|
skipped++;
|
|
25336
|
+
missing.push(chunk[j]);
|
|
24962
25337
|
continue;
|
|
24963
25338
|
}
|
|
24964
25339
|
try {
|
|
@@ -24976,9 +25351,14 @@ var RedisStore = class _RedisStore {
|
|
|
24976
25351
|
}
|
|
24977
25352
|
if (queuedInChunk > 0) await tx.exec();
|
|
24978
25353
|
}
|
|
25354
|
+
if (missing.length > 0) {
|
|
25355
|
+
await this.runPruneScript(index, missing).catch(
|
|
25356
|
+
(err) => this.warnPruneFailed(index, err)
|
|
25357
|
+
);
|
|
25358
|
+
}
|
|
24979
25359
|
if (migrated > 0 || skipped > 0) {
|
|
24980
25360
|
console.log(
|
|
24981
|
-
`[axl] RedisStore: backfilled ${migrated} ${label} entries into sorted-set index` + (skipped > 0 ? ` (${skipped} skipped \u2014 missing or malformed)` : "")
|
|
25361
|
+
`[axl] RedisStore: backfilled ${migrated} ${index.label} entries into sorted-set index` + (skipped > 0 ? ` (${skipped} skipped \u2014 missing or malformed)` : "")
|
|
24982
25362
|
);
|
|
24983
25363
|
}
|
|
24984
25364
|
}
|
|
@@ -25104,6 +25484,51 @@ var RedisStore = class _RedisStore {
|
|
|
25104
25484
|
JSON.stringify(normalizePersistedSessionHistory(history)),
|
|
25105
25485
|
setOptions
|
|
25106
25486
|
).sAdd(this.sessionIdsKey(), sessionId).exec();
|
|
25487
|
+
await this.sweepSessionIds();
|
|
25488
|
+
}
|
|
25489
|
+
async updateSession(sessionId, update) {
|
|
25490
|
+
if (this.saveSession !== _RedisStore.prototype.saveSession || this.getSession !== _RedisStore.prototype.getSession) {
|
|
25491
|
+
return updateSessionThroughOverrides(this, "RedisStore", sessionId, update);
|
|
25492
|
+
}
|
|
25493
|
+
const key = this.sessionKey(sessionId);
|
|
25494
|
+
const ttl = this.ttlFor("session");
|
|
25495
|
+
for (let attempt = 0; attempt < UPDATE_SESSION_ATTEMPTS; attempt++) {
|
|
25496
|
+
const raw = await this.client.get(key);
|
|
25497
|
+
const next = update(raw ? JSON.parse(raw) : null);
|
|
25498
|
+
if (next === null) return;
|
|
25499
|
+
let written;
|
|
25500
|
+
try {
|
|
25501
|
+
written = await this.client.eval(UPDATE_SESSION_SCRIPT, {
|
|
25502
|
+
keys: [key, this.sessionIdsKey()],
|
|
25503
|
+
arguments: [
|
|
25504
|
+
raw === null ? "" : (0, import_node_crypto6.createHash)("sha1").update(raw).digest("hex"),
|
|
25505
|
+
JSON.stringify(normalizePersistedSessionHistory(next)),
|
|
25506
|
+
ttl === void 0 ? "" : String(ttl),
|
|
25507
|
+
sessionId
|
|
25508
|
+
]
|
|
25509
|
+
});
|
|
25510
|
+
} catch (err) {
|
|
25511
|
+
if (!errorMessage(err).includes("NOPERM")) throw err;
|
|
25512
|
+
warnDiagnosticOnce(
|
|
25513
|
+
"redis_update_session_denied",
|
|
25514
|
+
"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.",
|
|
25515
|
+
void 0,
|
|
25516
|
+
"docs/api-reference.md#concurrency-and-races"
|
|
25517
|
+
);
|
|
25518
|
+
await this.saveSession(sessionId, next);
|
|
25519
|
+
return;
|
|
25520
|
+
}
|
|
25521
|
+
if (Number(written) === 1) {
|
|
25522
|
+
await this.sweepSessionIds();
|
|
25523
|
+
return;
|
|
25524
|
+
}
|
|
25525
|
+
const backoffMs = Math.min(20 * 2 ** attempt, 200);
|
|
25526
|
+
await new Promise((resolve2) => setTimeout(resolve2, Math.random() * backoffMs));
|
|
25527
|
+
}
|
|
25528
|
+
throw new AxlError(
|
|
25529
|
+
"SESSION_CONFLICT",
|
|
25530
|
+
`RedisStore: gave up saving session "${sessionId}" after ${UPDATE_SESSION_ATTEMPTS} conflicting writes from other instances.`
|
|
25531
|
+
);
|
|
25107
25532
|
}
|
|
25108
25533
|
async getSession(sessionId) {
|
|
25109
25534
|
const raw = await this.client.get(this.sessionKey(sessionId));
|
|
@@ -25167,7 +25592,7 @@ var RedisStore = class _RedisStore {
|
|
|
25167
25592
|
}
|
|
25168
25593
|
}
|
|
25169
25594
|
if (dead.length > 0) {
|
|
25170
|
-
this.
|
|
25595
|
+
this.connection.sRem(this.pendingExecSetKey(), dead).catch((err) => {
|
|
25171
25596
|
console.error(
|
|
25172
25597
|
`[axl] RedisStore: failed to prune ${dead.length} stale pending-exec ids: ` + (err instanceof Error ? err.message : String(err))
|
|
25173
25598
|
);
|
|
@@ -25213,7 +25638,7 @@ var RedisStore = class _RedisStore {
|
|
|
25213
25638
|
}
|
|
25214
25639
|
const ttl = this.ttlFor("memory");
|
|
25215
25640
|
if (ttl !== void 0) {
|
|
25216
|
-
this.
|
|
25641
|
+
this.connection.expire(this.memoryKey(scope), ttl).catch((err) => {
|
|
25217
25642
|
console.error(
|
|
25218
25643
|
`[axl] RedisStore: failed to set TTL on migrated memory key ${this.memoryKey(scope)}: ` + (err instanceof Error ? err.message : String(err))
|
|
25219
25644
|
);
|
|
@@ -25505,16 +25930,20 @@ var RedisStore = class _RedisStore {
|
|
|
25505
25930
|
try {
|
|
25506
25931
|
await this.pruneHistory(index);
|
|
25507
25932
|
} catch (err) {
|
|
25508
|
-
|
|
25509
|
-
`redis_history_prune_failed\0${index.kind}`,
|
|
25510
|
-
`RedisStore: removing expired ${index.label} index entries failed (${err instanceof Error ? err.message : String(err)}). The record was saved; the next save retries the cleanup. Listings stay correct meanwhile. The cleanup runs a Lua script, so the Redis user needs permission to run EVAL.`,
|
|
25511
|
-
void 0,
|
|
25512
|
-
"docs/api-reference.md#redisstore-options"
|
|
25513
|
-
);
|
|
25933
|
+
this.warnPruneFailed(index, err);
|
|
25514
25934
|
} finally {
|
|
25515
25935
|
inFlight.delete(index.kind);
|
|
25516
25936
|
}
|
|
25517
25937
|
}
|
|
25938
|
+
warnPruneFailed(index, err) {
|
|
25939
|
+
if (!this.isConnected) return;
|
|
25940
|
+
warnDiagnosticOnce(
|
|
25941
|
+
`redis_history_prune_failed\0${index.kind}`,
|
|
25942
|
+
`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.`,
|
|
25943
|
+
void 0,
|
|
25944
|
+
"docs/api-reference.md#redisstore-options"
|
|
25945
|
+
);
|
|
25946
|
+
}
|
|
25518
25947
|
async pruneHistory(index) {
|
|
25519
25948
|
const candidates = await this.client.zRange(index.expiry, "-inf", Date.now(), {
|
|
25520
25949
|
BY: "SCORE",
|
|
@@ -25669,7 +26098,69 @@ var RedisStore = class _RedisStore {
|
|
|
25669
26098
|
}
|
|
25670
26099
|
// ── Sessions (Studio introspection) ────────────────────────────────────
|
|
25671
26100
|
async listSessions() {
|
|
25672
|
-
|
|
26101
|
+
const live = /* @__PURE__ */ new Set();
|
|
26102
|
+
let cursor = "0";
|
|
26103
|
+
do {
|
|
26104
|
+
const page = await this.client.sScan(this.sessionIdsKey(), cursor, {
|
|
26105
|
+
COUNT: SESSION_ID_BATCH
|
|
26106
|
+
});
|
|
26107
|
+
cursor = page.cursor;
|
|
26108
|
+
const removed = new Set(await this.pruneSessionIds(page.members));
|
|
26109
|
+
for (const id of page.members) if (!removed.has(id)) live.add(id);
|
|
26110
|
+
} while (cursor !== "0");
|
|
26111
|
+
return [...live];
|
|
26112
|
+
}
|
|
26113
|
+
/**
|
|
26114
|
+
* Check a few listed sessions after a save, so ids of expired sessions go
|
|
26115
|
+
* away for apps that never list. Sessions without a TTL don't expire, so
|
|
26116
|
+
* there's nothing to do. The save has already succeeded, so failing here
|
|
26117
|
+
* can't fail it.
|
|
26118
|
+
*/
|
|
26119
|
+
async sweepSessionIds() {
|
|
26120
|
+
if (this.ttlFor("session") === void 0 || this.sessionSweepRunning) return;
|
|
26121
|
+
this.sessionSweepRunning = true;
|
|
26122
|
+
try {
|
|
26123
|
+
const page = await this.client.sScan(this.sessionIdsKey(), this.sessionSweepCursor, {
|
|
26124
|
+
COUNT: SESSION_SWEEP_COUNT
|
|
26125
|
+
});
|
|
26126
|
+
this.sessionSweepCursor = page.cursor;
|
|
26127
|
+
await this.pruneSessionIds(page.members);
|
|
26128
|
+
} catch (err) {
|
|
26129
|
+
this.warnSessionPruneFailed(err);
|
|
26130
|
+
} finally {
|
|
26131
|
+
this.sessionSweepRunning = false;
|
|
26132
|
+
}
|
|
26133
|
+
}
|
|
26134
|
+
/** Returns the ids removed. Without permission to run the script, removes none and warns. */
|
|
26135
|
+
async pruneSessionIds(ids) {
|
|
26136
|
+
const removed = [];
|
|
26137
|
+
for (let start = 0; start < ids.length; start += SESSION_ID_BATCH) {
|
|
26138
|
+
const batch = ids.slice(start, start + SESSION_ID_BATCH);
|
|
26139
|
+
try {
|
|
26140
|
+
const result = await this.client.eval(PRUNE_SESSION_IDS_SCRIPT, {
|
|
26141
|
+
keys: [
|
|
26142
|
+
this.sessionIdsKey(),
|
|
26143
|
+
...batch.flatMap((id) => [this.sessionKey(id), this.sessionMetaKey(id)])
|
|
26144
|
+
],
|
|
26145
|
+
arguments: batch
|
|
26146
|
+
});
|
|
26147
|
+
removed.push(...result);
|
|
26148
|
+
} catch (err) {
|
|
26149
|
+
if (!errorMessage(err).includes("NOPERM")) throw err;
|
|
26150
|
+
this.warnSessionPruneFailed(err);
|
|
26151
|
+
return removed;
|
|
26152
|
+
}
|
|
26153
|
+
}
|
|
26154
|
+
return removed;
|
|
26155
|
+
}
|
|
26156
|
+
warnSessionPruneFailed(err) {
|
|
26157
|
+
if (!this.isConnected) return;
|
|
26158
|
+
warnDiagnosticOnce(
|
|
26159
|
+
"redis_session_ids_prune_failed",
|
|
26160
|
+
`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.`,
|
|
26161
|
+
void 0,
|
|
26162
|
+
"docs/api-reference.md#redisstore-options"
|
|
26163
|
+
);
|
|
25673
26164
|
}
|
|
25674
26165
|
// ── Streaming events (for state.persist: 'streaming') ──────────────────
|
|
25675
26166
|
async appendStreamingEvents(executionId, events) {
|
|
@@ -25709,10 +26200,34 @@ var RedisStore = class _RedisStore {
|
|
|
25709
26200
|
* must not get a rejected promise for it. `MemoryStore.close()` and
|
|
25710
26201
|
* better-sqlite3's `close()` are both no-ops the second time; node-redis
|
|
25711
26202
|
* instead rejects with "The client is closed", so absorb that here.
|
|
26203
|
+
*
|
|
26204
|
+
* Bounded, so a shutdown can't hang on Redis. Replies arrive in order, so a
|
|
26205
|
+
* reply to this PING means every earlier command has finished.
|
|
26206
|
+
*
|
|
26207
|
+
* Not `quit()`: it marks the client closed before the server answers, and
|
|
26208
|
+
* `destroy()` then throws without touching the socket, so a stalled server's
|
|
26209
|
+
* socket could never be closed. There's no other public way to close it. A
|
|
26210
|
+
* closed socket is all Redis needs to clean up, so nothing is lost.
|
|
25712
26211
|
*/
|
|
25713
26212
|
async close() {
|
|
25714
|
-
|
|
25715
|
-
|
|
26213
|
+
this.closing = true;
|
|
26214
|
+
this.stopResponseWatch?.();
|
|
26215
|
+
if (this.connection.isOpen === false) return;
|
|
26216
|
+
if (this.connection.isReady !== false) {
|
|
26217
|
+
let timer;
|
|
26218
|
+
try {
|
|
26219
|
+
await Promise.race([
|
|
26220
|
+
this.connection.ping(),
|
|
26221
|
+
new Promise((_resolve, reject) => {
|
|
26222
|
+
timer = setTimeout(() => reject(new Error("PING timed out")), CLOSE_TIMEOUT_MS);
|
|
26223
|
+
})
|
|
26224
|
+
]);
|
|
26225
|
+
} catch {
|
|
26226
|
+
} finally {
|
|
26227
|
+
clearTimeout(timer);
|
|
26228
|
+
}
|
|
26229
|
+
}
|
|
26230
|
+
abandonConnection(this.connection);
|
|
25716
26231
|
}
|
|
25717
26232
|
async deleteCheckpoints(executionId) {
|
|
25718
26233
|
await this.client.del(this.checkpointKey(executionId));
|