@qjp88995/agent-runtime-redis 0.0.0-stage → 0.1.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/dist/coordination.d.ts +97 -0
- package/dist/coordination.d.ts.map +1 -0
- package/dist/coordination.js +221 -0
- package/dist/coordination.js.map +1 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +32 -0
- package/dist/index.js.map +1 -0
- package/dist/keys.d.ts +21 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +12 -0
- package/dist/keys.js.map +1 -0
- package/dist/stream-bus.d.ts +80 -0
- package/dist/stream-bus.d.ts.map +1 -0
- package/dist/stream-bus.js +285 -0
- package/dist/stream-bus.js.map +1 -0
- package/package.json +46 -3
- package/src/coordination.ts +329 -0
- package/src/index.ts +77 -0
- package/src/keys.ts +32 -0
- package/src/stream-bus.ts +343 -0
- package/README.md +0 -3
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { InProcessStreamBus, } from '@qjp88995/agent-runtime';
|
|
2
|
+
import { redisKeys } from './keys.js';
|
|
3
|
+
/**
|
|
4
|
+
* 兜底上限,不是常态:落库的段落会从流里退役(`retireUpTo`),流里常态只剩尚未落库的那一段。
|
|
5
|
+
* 它只挡进程崩溃留下的孤儿轮次、以及一段长到落库之前就写出上万条的极端情况。
|
|
6
|
+
*/
|
|
7
|
+
const MAXLEN = 10_000;
|
|
8
|
+
const STREAM_TTL_SECONDS = 3600;
|
|
9
|
+
const XREAD_BLOCK_MS = 5_000;
|
|
10
|
+
/**
|
|
11
|
+
* 一次 XREAD 最多取多少条。首连的重放段吃这个数(游标缺省 `'0'`,100 一批就是上百次往返);
|
|
12
|
+
* 活跃期一批本来也就一两条,调大不影响。
|
|
13
|
+
*/
|
|
14
|
+
const XREAD_COUNT = 500;
|
|
15
|
+
/**
|
|
16
|
+
* 非 abort 的 XREAD 失败后的退避。畸形游标会让 XREAD **立即**拒绝,没有退避就是 CPU 热循环兼
|
|
17
|
+
* 疯狂重连 Redis。
|
|
18
|
+
*/
|
|
19
|
+
const XREAD_RETRY_DELAY_MS = 250;
|
|
20
|
+
/**
|
|
21
|
+
* 写侧合并窗口:同一段里连续的 `text-delta` / `reasoning-delta` 攒这么久再 XADD 成一条。流条目
|
|
22
|
+
* 不可变,合并只能在写入前做;前端本来就按渲染帧合帧,多攒两帧看不出来,条目数少一个数量级。
|
|
23
|
+
*/
|
|
24
|
+
const XADD_BATCH_MS = 50;
|
|
25
|
+
/**
|
|
26
|
+
* 落库后条目在流里的宽限期。前端打开会话是「先拉历史、后订阅」两步,一段恰好在两步之间落库时,
|
|
27
|
+
* 它在库快照里没有、流里又删了——留一段让重放还能送到(前端按 `flushedSeq` 去重)。
|
|
28
|
+
*/
|
|
29
|
+
const RETIRE_GRACE_MS = 30_000;
|
|
30
|
+
const XDEL_CHUNK = 500;
|
|
31
|
+
const SWEEP_INTERVAL_MS = 60_000;
|
|
32
|
+
function parseEntry(fields) {
|
|
33
|
+
const i = fields.indexOf('data');
|
|
34
|
+
if (i < 0 || i + 1 >= fields.length)
|
|
35
|
+
return null;
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(fields[i + 1]);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function isMergeableDelta(e) {
|
|
44
|
+
return e.type === 'text-delta' || e.type === 'reasoning-delta';
|
|
45
|
+
}
|
|
46
|
+
function textOf(e) {
|
|
47
|
+
const p = e.payload;
|
|
48
|
+
return typeof p?.text === 'string' ? p.text : '';
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* 「紧邻的同一段」:同 root、同 run、同 messageId、同 type、同深度。退役边界永远落在类型切换处,
|
|
52
|
+
* 所以一条合并条目不会横跨边界,`seq ≤ 边界` 对合并条目(seq 取最后一条)仍然精确。
|
|
53
|
+
*/
|
|
54
|
+
function sameSegment(a, b) {
|
|
55
|
+
return (a.rootSessionId === b.rootSessionId &&
|
|
56
|
+
a.runId === b.runId &&
|
|
57
|
+
a.messageId === b.messageId &&
|
|
58
|
+
a.type === b.type &&
|
|
59
|
+
a.depth === b.depth);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* 多实例事件总线:**per-stream 面留在进程内**(继承 `InProcessStreamBus`:seq、buffer、取消信号——
|
|
63
|
+
* 消费它们的 pump 与 `streamText` 都在跑这一轮的那个进程里),**会话面换成 Redis Streams**
|
|
64
|
+
* (键 `agent-session:<root>`),跨实例、断线可按游标续订。搬自 agent-base `RedisStreamBus`。
|
|
65
|
+
*
|
|
66
|
+
* ## 会话面只保留尚未落库的尾巴
|
|
67
|
+
*
|
|
68
|
+
* - **落库即退役**:宿主落库成功后调 `retireUpTo(runId, seq)`,宽限期后 XDEL 这一轮 seq ≤ 边界的条目;
|
|
69
|
+
* `run-finish` / `error` 宽限期后整轮清掉。条目 id 从 XADD 回值登记在进程内存里(写这轮事件的
|
|
70
|
+
* 就是跑这轮的进程);别的实例补发的 `run-finish` 在本进程查不到表,删除是 no-op,孤儿条目由
|
|
71
|
+
* TTL / MAXLEN 兜底。
|
|
72
|
+
* - **写侧合并**:连续的同段 delta 攒一个窗口再写;任何不可合并的事件先把攒着的吐掉,顺序不变。
|
|
73
|
+
*
|
|
74
|
+
* 会话面的写是 fire-and-forget:观测不承重,序列化失败或 Redis 出错只丢这一条,绝不让一轮对话失败。
|
|
75
|
+
*
|
|
76
|
+
* ## 与 agent-base 的差别
|
|
77
|
+
*
|
|
78
|
+
* - `publish` 只在流活着时写会话面(agent-base 不看流状态;这里与进程内实现、端口约定对齐:
|
|
79
|
+
* 「流未 open / 已 close 时丢弃」)。
|
|
80
|
+
* - 测试用的 `configureForTest` 改成构造参数 `batchMs` / `retireGraceMs`;加 `dispose()`。
|
|
81
|
+
* - 键可加前缀(`keyPrefix`)。
|
|
82
|
+
*
|
|
83
|
+
* **停止按钮仍是进程内的**:`abort(streamId)` 只对本进程跑着的流生效,与 agent-base 一样——
|
|
84
|
+
* 停止请求落到别的实例上时,宿主要自己把它路由到跑这轮的实例(或者走会话锁所在实例)。
|
|
85
|
+
*/
|
|
86
|
+
export class RedisStreamBus extends InProcessStreamBus {
|
|
87
|
+
redis;
|
|
88
|
+
keys;
|
|
89
|
+
batchMs;
|
|
90
|
+
graceMs;
|
|
91
|
+
maxLen;
|
|
92
|
+
streamTtlSeconds;
|
|
93
|
+
xreadBlockMs;
|
|
94
|
+
pendingByRoot = new Map();
|
|
95
|
+
entriesByRun = new Map();
|
|
96
|
+
timers = new Set();
|
|
97
|
+
runTableTimer;
|
|
98
|
+
constructor(redis, options = {}) {
|
|
99
|
+
super(options);
|
|
100
|
+
this.redis = redis;
|
|
101
|
+
this.keys = redisKeys(options);
|
|
102
|
+
this.batchMs =
|
|
103
|
+
options.batchMs === undefined ? XADD_BATCH_MS : options.batchMs;
|
|
104
|
+
this.graceMs = options.retireGraceMs ?? RETIRE_GRACE_MS;
|
|
105
|
+
this.maxLen = options.maxLen ?? MAXLEN;
|
|
106
|
+
this.streamTtlSeconds = options.streamTtlSeconds ?? STREAM_TTL_SECONDS;
|
|
107
|
+
this.xreadBlockMs = options.xreadBlockMs ?? XREAD_BLOCK_MS;
|
|
108
|
+
this.runTableTimer = setInterval(() => this.sweepRunTables(), SWEEP_INTERVAL_MS);
|
|
109
|
+
this.runTableTimer.unref();
|
|
110
|
+
}
|
|
111
|
+
publish(event) {
|
|
112
|
+
// 只喂进程内的 per-stream 面,绝不走基类的进程内会话面(那块在这里永远是空的)。
|
|
113
|
+
if (this.publishToStream(event))
|
|
114
|
+
this.enqueueSession(event);
|
|
115
|
+
}
|
|
116
|
+
publishSessionEvent(event) {
|
|
117
|
+
this.enqueueSession(event);
|
|
118
|
+
}
|
|
119
|
+
retireUpTo(runId, uptoSeq) {
|
|
120
|
+
this.later(() => this.deleteEntries(runId, e => e.seq <= uptoSeq));
|
|
121
|
+
}
|
|
122
|
+
/** 宽限期后执行;`dispose()` 会清掉还没到点的。 */
|
|
123
|
+
later(fn) {
|
|
124
|
+
const timer = setTimeout(() => {
|
|
125
|
+
this.timers.delete(timer);
|
|
126
|
+
fn();
|
|
127
|
+
}, this.graceMs);
|
|
128
|
+
timer.unref();
|
|
129
|
+
this.timers.add(timer);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* 会话面入口。序列化在进入 fire-and-forget 链之前同步做完(见 `xaddSession`),非 JSON-safe
|
|
133
|
+
* 载荷不会经 `publish` 冒进 `runTurn`。
|
|
134
|
+
*/
|
|
135
|
+
enqueueSession(event) {
|
|
136
|
+
const root = event.rootSessionId;
|
|
137
|
+
const pending = this.pendingByRoot.get(root);
|
|
138
|
+
if (this.batchMs !== null && isMergeableDelta(event)) {
|
|
139
|
+
if (pending && sameSegment(pending.event, event)) {
|
|
140
|
+
pending.event = {
|
|
141
|
+
...pending.event,
|
|
142
|
+
seq: event.seq,
|
|
143
|
+
ts: event.ts,
|
|
144
|
+
payload: { text: textOf(pending.event) + textOf(event) },
|
|
145
|
+
};
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (pending)
|
|
149
|
+
this.flushPending(root);
|
|
150
|
+
const timer = setTimeout(() => this.flushPending(root), this.batchMs);
|
|
151
|
+
timer.unref();
|
|
152
|
+
this.pendingByRoot.set(root, { event, timer });
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (pending)
|
|
156
|
+
this.flushPending(root);
|
|
157
|
+
const written = this.xaddSession(event);
|
|
158
|
+
// 宽限期从这条自己登记完才开始算:XADD 回包后才把 id 记进登记表,同步起表的话回包慢过
|
|
159
|
+
// 宽限期时删除先跑——边界事件本身漏删,回包再登记出一张没人清的表。
|
|
160
|
+
if (event.type === 'run-finish' || event.type === 'error') {
|
|
161
|
+
void written.then(() => this.later(() => {
|
|
162
|
+
this.deleteEntries(event.runId, () => true);
|
|
163
|
+
this.entriesByRun.delete(event.runId);
|
|
164
|
+
}));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
flushPending(root) {
|
|
168
|
+
const pending = this.pendingByRoot.get(root);
|
|
169
|
+
if (!pending)
|
|
170
|
+
return;
|
|
171
|
+
clearTimeout(pending.timer);
|
|
172
|
+
this.pendingByRoot.delete(root);
|
|
173
|
+
void this.xaddSession(pending.event);
|
|
174
|
+
}
|
|
175
|
+
/** 写一条并登记;返回的 promise 在登记完(或失败被吞)后落定,从不 reject。 */
|
|
176
|
+
xaddSession(event) {
|
|
177
|
+
let data;
|
|
178
|
+
try {
|
|
179
|
+
data = JSON.stringify(event);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
return Promise.resolve();
|
|
183
|
+
}
|
|
184
|
+
const key = this.keys.sessionStream(event.rootSessionId);
|
|
185
|
+
// XADD + 刷新 TTL 一次往返。会话级事件(seq 0)不登记——它们不属于任何会落库的段落。
|
|
186
|
+
return this.redis
|
|
187
|
+
.multi()
|
|
188
|
+
.xadd(key, 'MAXLEN', '~', String(this.maxLen), '*', 'data', data)
|
|
189
|
+
.expire(key, this.streamTtlSeconds)
|
|
190
|
+
.exec()
|
|
191
|
+
.then(res => {
|
|
192
|
+
const id = res?.[0]?.[1];
|
|
193
|
+
if (typeof id !== 'string' || event.seq <= 0)
|
|
194
|
+
return;
|
|
195
|
+
let run = this.entriesByRun.get(event.runId);
|
|
196
|
+
if (!run) {
|
|
197
|
+
run = { key, ids: [], lastActivity: Date.now() };
|
|
198
|
+
this.entriesByRun.set(event.runId, run);
|
|
199
|
+
}
|
|
200
|
+
run.ids.push({ seq: event.seq, id });
|
|
201
|
+
run.lastActivity = Date.now();
|
|
202
|
+
})
|
|
203
|
+
.catch(() => undefined);
|
|
204
|
+
}
|
|
205
|
+
deleteEntries(runId, pick) {
|
|
206
|
+
const run = this.entriesByRun.get(runId);
|
|
207
|
+
if (!run)
|
|
208
|
+
return;
|
|
209
|
+
const doomed = run.ids.filter(pick).map(e => e.id);
|
|
210
|
+
if (doomed.length === 0)
|
|
211
|
+
return;
|
|
212
|
+
run.ids = run.ids.filter(e => !pick(e));
|
|
213
|
+
run.lastActivity = Date.now();
|
|
214
|
+
for (let i = 0; i < doomed.length; i += XDEL_CHUNK) {
|
|
215
|
+
void this.redis
|
|
216
|
+
.xdel(run.key, ...doomed.slice(i, i + XDEL_CHUNK))
|
|
217
|
+
.catch(() => undefined);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/** 轮次登记表兜底清理:正常路径由 run-finish / error 清;崩溃或别的实例收尾的轮次靠这里。 */
|
|
221
|
+
sweepRunTables() {
|
|
222
|
+
const now = Date.now();
|
|
223
|
+
const ttlMs = this.streamTtlSeconds * 1000;
|
|
224
|
+
for (const [runId, run] of this.entriesByRun) {
|
|
225
|
+
if (now - run.lastActivity > ttlMs)
|
|
226
|
+
this.entriesByRun.delete(runId);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* 每个订阅一条独立连接(XREAD BLOCK 会占住连接);`signal` abort 时断开它,在途的 XREAD 随之结束。
|
|
231
|
+
*/
|
|
232
|
+
async *subscribeSession(rootSessionId, fromId, signal) {
|
|
233
|
+
if (signal.aborted)
|
|
234
|
+
return;
|
|
235
|
+
const sub = this.redis.duplicate();
|
|
236
|
+
const onAbort = () => void sub.disconnect();
|
|
237
|
+
signal.addEventListener('abort', onAbort);
|
|
238
|
+
const key = this.keys.sessionStream(rootSessionId);
|
|
239
|
+
let cursor = fromId && fromId.length > 0 ? fromId : '0';
|
|
240
|
+
try {
|
|
241
|
+
for (;;) {
|
|
242
|
+
if (signal.aborted)
|
|
243
|
+
return;
|
|
244
|
+
let res;
|
|
245
|
+
try {
|
|
246
|
+
res = (await sub.xread('COUNT', XREAD_COUNT, 'BLOCK', this.xreadBlockMs, 'STREAMS', key, cursor));
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
if (signal.aborted)
|
|
250
|
+
return;
|
|
251
|
+
await new Promise(resolve => setTimeout(resolve, XREAD_RETRY_DELAY_MS));
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (!res)
|
|
255
|
+
continue;
|
|
256
|
+
for (const [, entries] of res) {
|
|
257
|
+
for (const [id, fields] of entries) {
|
|
258
|
+
cursor = id;
|
|
259
|
+
const event = parseEntry(fields);
|
|
260
|
+
if (event)
|
|
261
|
+
yield { id, event };
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
finally {
|
|
267
|
+
signal.removeEventListener('abort', onAbort);
|
|
268
|
+
await sub.quit().catch(() => undefined);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* 停掉定时器:攒着的 delta 立刻写出去(不丢),还没到点的退役放弃(条目由 TTL / MAXLEN 兜底)。
|
|
273
|
+
* 不关宿主给的连接。
|
|
274
|
+
*/
|
|
275
|
+
dispose() {
|
|
276
|
+
super.dispose();
|
|
277
|
+
clearInterval(this.runTableTimer);
|
|
278
|
+
for (const root of [...this.pendingByRoot.keys()])
|
|
279
|
+
this.flushPending(root);
|
|
280
|
+
for (const timer of this.timers)
|
|
281
|
+
clearTimeout(timer);
|
|
282
|
+
this.timers.clear();
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
//# sourceMappingURL=stream-bus.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"stream-bus.js","sourceRoot":"","sources":["../src/stream-bus.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qjp88995/agent-runtime-redis",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Redis adapters for @qjp88995/agent-runtime: event bus, session lock, wake/idle channel, subagent await lease and tree budget, for running on more than one instance.",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public",
|
|
9
|
+
"registry": "https://registry.npmjs.org/"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"src",
|
|
14
|
+
"!src/**/*.spec.ts",
|
|
15
|
+
"!src/test-redis.ts"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=22.12"
|
|
19
|
+
},
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/qjp88995/agent-kit.git",
|
|
23
|
+
"directory": "packages/runtime-redis"
|
|
24
|
+
},
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"default": "./dist/index.js"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"ioredis": "^5.4.0",
|
|
33
|
+
"@qjp88995/agent-runtime": "^0.1.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/node": "^25.9.5",
|
|
37
|
+
"ioredis": "^5.11.1",
|
|
38
|
+
"typescript": "^5.9.3",
|
|
39
|
+
"vitest": "^4.1.10",
|
|
40
|
+
"@qjp88995/agent-runtime": "0.1.0"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "rm -rf dist *.tsbuildinfo && tsc -p tsconfig.build.json",
|
|
44
|
+
"lint": "eslint .",
|
|
45
|
+
"typecheck": "tsc --noEmit",
|
|
46
|
+
"test": "pnpm build && vitest run",
|
|
47
|
+
"clean": "rm -rf dist .turbo *.tsbuildinfo"
|
|
48
|
+
}
|
|
6
49
|
}
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import type { Redis } from 'ioredis';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
AWAIT_LEASE_TTL_MS,
|
|
6
|
+
consoleLogger,
|
|
7
|
+
LOCK_TTL_MS,
|
|
8
|
+
type Logger,
|
|
9
|
+
type LoggerFactory,
|
|
10
|
+
type SessionLock,
|
|
11
|
+
type SubagentAwaitLease,
|
|
12
|
+
type TreeBudget,
|
|
13
|
+
type WakeChannel,
|
|
14
|
+
} from '@qjp88995/agent-runtime';
|
|
15
|
+
|
|
16
|
+
import { type RedisKeyOptions, type RedisKeys, redisKeys } from './keys.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 会话锁、唤醒 / 空闲广播、子 Agent 等待租约、树级名额的 Redis 实现。
|
|
20
|
+
* 搬自 agent-base `agent-core/loop/{session-lock,session-wake}.service.ts`、`session-idle.ts`、
|
|
21
|
+
* `agent-core/subagent/{subagent-await,tree-budget}.service.ts`,语义见 runtime 的 `ports/coordination`。
|
|
22
|
+
*
|
|
23
|
+
* 锁 / 租约 / 名额都直接用宿主给的共享连接(全是非阻塞的常规命令),只有唤醒频道要一条
|
|
24
|
+
* 独立的订阅连接(进了 subscribe 模式的连接不能再发普通命令)。
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** DEL 前比对 token,避免误删「TTL 过期后被别人重新抢到」的锁。 */
|
|
28
|
+
const RELEASE_LUA =
|
|
29
|
+
"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
|
|
30
|
+
/** PEXPIRE 前比对 token,只续自己的锁。 */
|
|
31
|
+
const RENEW_LUA =
|
|
32
|
+
"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('pexpire', KEYS[1], ARGV[2]) else return 0 end";
|
|
33
|
+
|
|
34
|
+
export interface RedisSessionLockOptions extends RedisKeyOptions {
|
|
35
|
+
/** 缺省 `LOCK_TTL_MS`(60s)。 */
|
|
36
|
+
ttlMs?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** SET NX PX 抢锁,Lua 比对 token 再 DEL / PEXPIRE。TTL 兜底持有者崩溃。 */
|
|
40
|
+
export class RedisSessionLock implements SessionLock {
|
|
41
|
+
private readonly keys: RedisKeys;
|
|
42
|
+
private readonly ttlMs: number;
|
|
43
|
+
|
|
44
|
+
constructor(
|
|
45
|
+
private readonly redis: Redis,
|
|
46
|
+
options: RedisSessionLockOptions = {}
|
|
47
|
+
) {
|
|
48
|
+
this.keys = redisKeys(options);
|
|
49
|
+
this.ttlMs = options.ttlMs ?? LOCK_TTL_MS;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async acquire(sessionId: string): Promise<string | null> {
|
|
53
|
+
const token = randomUUID();
|
|
54
|
+
const res = await this.redis.set(
|
|
55
|
+
this.keys.lock(sessionId),
|
|
56
|
+
token,
|
|
57
|
+
'PX',
|
|
58
|
+
this.ttlMs,
|
|
59
|
+
'NX'
|
|
60
|
+
);
|
|
61
|
+
return res === 'OK' ? token : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async renew(sessionId: string, token: string): Promise<boolean> {
|
|
65
|
+
const res = await this.redis.eval(
|
|
66
|
+
RENEW_LUA,
|
|
67
|
+
1,
|
|
68
|
+
this.keys.lock(sessionId),
|
|
69
|
+
token,
|
|
70
|
+
String(this.ttlMs)
|
|
71
|
+
);
|
|
72
|
+
return res === 1;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async release(sessionId: string, token: string): Promise<void> {
|
|
76
|
+
await this.redis.eval(RELEASE_LUA, 1, this.keys.lock(sessionId), token);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async isHeld(sessionId: string): Promise<boolean> {
|
|
80
|
+
return (await this.redis.exists(this.keys.lock(sessionId))) === 1;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface RedisWakeChannelOptions extends RedisKeyOptions {
|
|
85
|
+
/**
|
|
86
|
+
* 订阅用的连接。缺省 `redis.duplicate()`,由本对象负责关闭;宿主自己给的不关。
|
|
87
|
+
*/
|
|
88
|
+
subscriber?: Redis;
|
|
89
|
+
logger?: LoggerFactory;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
type Handler = (sessionId: string) => void;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 唤醒 / 空闲广播走 Redis pub/sub(at-most-once,订阅前发的收不到——与端口约定一致)。
|
|
96
|
+
*
|
|
97
|
+
* 用 `RedisWakeChannel.create()` 构造:它等 SUBSCRIBE 应答回来才返回,之后 `onWake` / `onIdle`
|
|
98
|
+
* 只是登记本地处理函数,同步生效。否则「刚订阅就有人 wake」会落在 SUBSCRIBE 应答之前、静默丢失。
|
|
99
|
+
* 断线重连后 ioredis 会自动重新订阅(`autoResubscribe` 缺省开);断线期间的广播照样丢,
|
|
100
|
+
* 这由 sweeper 的启动补唤醒与消费方自己的兜底扫描覆盖。
|
|
101
|
+
*
|
|
102
|
+
* 一个处理函数抛错只记日志,不影响别的处理函数,也不冒进 ioredis 的事件循环。
|
|
103
|
+
*/
|
|
104
|
+
export class RedisWakeChannel implements WakeChannel {
|
|
105
|
+
private readonly wakeHandlers = new Set<Handler>();
|
|
106
|
+
private readonly idleHandlers = new Set<Handler>();
|
|
107
|
+
private disposed = false;
|
|
108
|
+
|
|
109
|
+
private constructor(
|
|
110
|
+
private readonly redis: Redis,
|
|
111
|
+
private readonly sub: Redis,
|
|
112
|
+
private readonly ownsSub: boolean,
|
|
113
|
+
private readonly keys: RedisKeys,
|
|
114
|
+
private readonly logger: Logger
|
|
115
|
+
) {}
|
|
116
|
+
|
|
117
|
+
static async create(
|
|
118
|
+
redis: Redis,
|
|
119
|
+
options: RedisWakeChannelOptions = {}
|
|
120
|
+
): Promise<RedisWakeChannel> {
|
|
121
|
+
const sub = options.subscriber ?? redis.duplicate();
|
|
122
|
+
const channel = new RedisWakeChannel(
|
|
123
|
+
redis,
|
|
124
|
+
sub,
|
|
125
|
+
!options.subscriber,
|
|
126
|
+
redisKeys(options),
|
|
127
|
+
(options.logger ?? consoleLogger)('RedisWakeChannel')
|
|
128
|
+
);
|
|
129
|
+
sub.on('message', (ch: string, message: string) =>
|
|
130
|
+
channel.dispatch(ch, message)
|
|
131
|
+
);
|
|
132
|
+
await sub.subscribe(channel.keys.wakeChannel, channel.keys.idleChannel);
|
|
133
|
+
return channel;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private dispatch(ch: string, message: string): void {
|
|
137
|
+
if (ch === this.keys.wakeChannel) {
|
|
138
|
+
this.fanOut(this.wakeHandlers, message, 'wake');
|
|
139
|
+
} else if (ch === this.keys.idleChannel) {
|
|
140
|
+
let sessionId: unknown;
|
|
141
|
+
try {
|
|
142
|
+
sessionId = (JSON.parse(message) as { sessionId?: unknown }).sessionId;
|
|
143
|
+
} catch {
|
|
144
|
+
sessionId = undefined;
|
|
145
|
+
}
|
|
146
|
+
if (typeof sessionId !== 'string') {
|
|
147
|
+
this.logger.warn(`dropping malformed idle message: ${message}`);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
this.fanOut(this.idleHandlers, sessionId, 'idle');
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private fanOut(handlers: Set<Handler>, sessionId: string, kind: string) {
|
|
155
|
+
for (const handler of [...handlers]) {
|
|
156
|
+
try {
|
|
157
|
+
handler(sessionId);
|
|
158
|
+
} catch (err) {
|
|
159
|
+
this.logger.error(
|
|
160
|
+
`${kind} handler failed for ${sessionId}: ${String(err)}`,
|
|
161
|
+
err instanceof Error ? err.stack : undefined
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async wake(sessionId: string): Promise<void> {
|
|
168
|
+
await this.redis.publish(this.keys.wakeChannel, sessionId);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
onWake(handler: Handler): () => void {
|
|
172
|
+
this.wakeHandlers.add(handler);
|
|
173
|
+
return () => this.wakeHandlers.delete(handler);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async idle(sessionId: string): Promise<void> {
|
|
177
|
+
await this.redis.publish(
|
|
178
|
+
this.keys.idleChannel,
|
|
179
|
+
JSON.stringify({ sessionId })
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
onIdle(handler: Handler): () => void {
|
|
184
|
+
this.idleHandlers.add(handler);
|
|
185
|
+
return () => this.idleHandlers.delete(handler);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** 摘掉所有处理函数;订阅连接是本对象建的就关掉。 */
|
|
189
|
+
async dispose(): Promise<void> {
|
|
190
|
+
if (this.disposed) return;
|
|
191
|
+
this.disposed = true;
|
|
192
|
+
this.wakeHandlers.clear();
|
|
193
|
+
this.idleHandlers.clear();
|
|
194
|
+
if (this.ownsSub) {
|
|
195
|
+
await this.sub.quit().catch(() => undefined);
|
|
196
|
+
} else {
|
|
197
|
+
await this.sub
|
|
198
|
+
.unsubscribe(this.keys.wakeChannel, this.keys.idleChannel)
|
|
199
|
+
.catch(() => undefined);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export interface RedisSubagentAwaitLeaseOptions extends RedisKeyOptions {
|
|
205
|
+
/** 缺省 `AWAIT_LEASE_TTL_MS`(60s)。 */
|
|
206
|
+
ttlMs?: number;
|
|
207
|
+
logger?: LoggerFactory;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* 等待租约:一个带 TTL 的键。`claim` 是一条原子 DEL,父与 finalizer 抢同一条,恰有一方胜出。
|
|
212
|
+
* Redis 出错时按端口约定一律退化成异步(见各方法)。
|
|
213
|
+
*/
|
|
214
|
+
export class RedisSubagentAwaitLease implements SubagentAwaitLease {
|
|
215
|
+
private readonly keys: RedisKeys;
|
|
216
|
+
private readonly ttlMs: number;
|
|
217
|
+
private readonly logger: Logger;
|
|
218
|
+
|
|
219
|
+
constructor(
|
|
220
|
+
private readonly redis: Redis,
|
|
221
|
+
options: RedisSubagentAwaitLeaseOptions = {}
|
|
222
|
+
) {
|
|
223
|
+
this.keys = redisKeys(options);
|
|
224
|
+
this.ttlMs = options.ttlMs ?? AWAIT_LEASE_TTL_MS;
|
|
225
|
+
this.logger = (options.logger ?? consoleLogger)('RedisSubagentAwaitLease');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** 失败返回 false:没有租约就没有互斥,调用方退回异步模式。 */
|
|
229
|
+
async begin(childSessionId: string): Promise<boolean> {
|
|
230
|
+
try {
|
|
231
|
+
await this.redis.set(
|
|
232
|
+
this.keys.awaited(childSessionId),
|
|
233
|
+
'1',
|
|
234
|
+
'PX',
|
|
235
|
+
this.ttlMs
|
|
236
|
+
);
|
|
237
|
+
return true;
|
|
238
|
+
} catch (err) {
|
|
239
|
+
this.logger.warn(
|
|
240
|
+
`await lease begin(${childSessionId}) failed, falling back to async: ${String(err)}`
|
|
241
|
+
);
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** 只续已存在的租约(PEXPIRE 对不存在的键是 no-op)。失败不抛。 */
|
|
247
|
+
async renew(childSessionId: string): Promise<void> {
|
|
248
|
+
try {
|
|
249
|
+
await this.redis.pexpire(this.keys.awaited(childSessionId), this.ttlMs);
|
|
250
|
+
} catch {
|
|
251
|
+
// 续租失败不打断等待:最坏是租约过期、finalizer 改走回投,父随后超时降级。
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** 拿不准当作没人等:猜「有人等」会让该回投的产出被跳过,那是静默丢失。 */
|
|
256
|
+
async isAwaited(childSessionId: string): Promise<boolean> {
|
|
257
|
+
try {
|
|
258
|
+
return (await this.redis.exists(this.keys.awaited(childSessionId))) === 1;
|
|
259
|
+
} catch (err) {
|
|
260
|
+
this.logger.warn(
|
|
261
|
+
`await lease isAwaited(${childSessionId}) failed, assuming none: ${String(err)}`
|
|
262
|
+
);
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** 拿不准时父得 true(放弃等待)、finalizer 得 false(照常回投),合起来就是退化成异步。 */
|
|
268
|
+
async claim(
|
|
269
|
+
childSessionId: string,
|
|
270
|
+
by: 'parent' | 'finalizer'
|
|
271
|
+
): Promise<boolean> {
|
|
272
|
+
try {
|
|
273
|
+
return (await this.redis.del(this.keys.awaited(childSessionId))) === 1;
|
|
274
|
+
} catch (err) {
|
|
275
|
+
this.logger.warn(
|
|
276
|
+
`await lease claim(${childSessionId}, ${by}) failed: ${String(err)}`
|
|
277
|
+
);
|
|
278
|
+
return by === 'parent';
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** 只有当前值 < 上限才 INCR(并刷新 TTL),否则不动。 */
|
|
284
|
+
const INCR_IF_UNDER_LUA =
|
|
285
|
+
"local v=tonumber(redis.call('get',KEYS[1]) or '0') if v<tonumber(ARGV[1]) then redis.call('incr',KEYS[1]) redis.call('expire',KEYS[1],ARGV[2]) return 1 else return 0 end";
|
|
286
|
+
/** DECR 后 ≤ 0 就 DEL,不留负值。 */
|
|
287
|
+
const DECR_LUA =
|
|
288
|
+
"local v=redis.call('decr',KEYS[1]) if v<=0 then redis.call('del',KEYS[1]) end return v";
|
|
289
|
+
|
|
290
|
+
export interface RedisTreeBudgetOptions extends RedisKeyOptions {
|
|
291
|
+
/** 计数键的 TTL,兜底「整棵树都没了、计数还留着」。缺省 1 天。 */
|
|
292
|
+
ttlSeconds?: number;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** 树级名额:Lua 里做「读比较写」,并发 `tryIncr` 不超发。 */
|
|
296
|
+
export class RedisTreeBudget implements TreeBudget {
|
|
297
|
+
private readonly keys: RedisKeys;
|
|
298
|
+
private readonly ttlSeconds: number;
|
|
299
|
+
|
|
300
|
+
constructor(
|
|
301
|
+
private readonly redis: Redis,
|
|
302
|
+
options: RedisTreeBudgetOptions = {}
|
|
303
|
+
) {
|
|
304
|
+
this.keys = redisKeys(options);
|
|
305
|
+
this.ttlSeconds = options.ttlSeconds ?? 86_400;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async tryIncr(rootSessionId: string, limit: number): Promise<boolean> {
|
|
309
|
+
const res = await this.redis.eval(
|
|
310
|
+
INCR_IF_UNDER_LUA,
|
|
311
|
+
1,
|
|
312
|
+
this.keys.liveSubs(rootSessionId),
|
|
313
|
+
String(limit),
|
|
314
|
+
String(this.ttlSeconds)
|
|
315
|
+
);
|
|
316
|
+
return res === 1;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async decr(rootSessionId: string): Promise<void> {
|
|
320
|
+
await this.redis.eval(DECR_LUA, 1, this.keys.liveSubs(rootSessionId));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** 与进程内实现一致:n ≤ 0 直接删键。 */
|
|
324
|
+
async set(rootSessionId: string, n: number): Promise<void> {
|
|
325
|
+
const key = this.keys.liveSubs(rootSessionId);
|
|
326
|
+
if (n <= 0) await this.redis.del(key);
|
|
327
|
+
else await this.redis.set(key, String(n), 'EX', this.ttlSeconds);
|
|
328
|
+
}
|
|
329
|
+
}
|