lark-relay 0.4.15 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -12
- package/bin/lark-relay.js +54 -55
- package/lib/collect.js +29 -12
- package/lib/cursors.js +156 -88
- package/lib/help.js +46 -38
- package/lib/status.js +22 -18
- package/lib/store.js +33 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -80,22 +80,24 @@ lark-relay take --app <app> --chats oc_xxx --render text
|
|
|
80
80
|
多消费者**共享 store、各持游标**,不做「处理完即删」-- 谁都不能替别人删。
|
|
81
81
|
盯同一 app 请用不同 `--name` 隔离游标。
|
|
82
82
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
那个 N 会随过期删除而变小(**看着像追上了,其实是消息没了**)。
|
|
87
|
-
|
|
88
|
-
一次性监听、验完的排查、不再值守的任务,收尾时:
|
|
83
|
+
**游标会自动回收**:空闲超过 2 天的游标由 collect 删掉(每小时自查一次,
|
|
84
|
+
与 store 的日期目录回收同一个 gc)。判据是「多久没人用过它」——
|
|
85
|
+
不需要你记得收尾,也不需要你判断该不该删。
|
|
89
86
|
|
|
90
87
|
```bash
|
|
91
|
-
lark-relay cursors
|
|
92
|
-
lark-relay cursors --
|
|
88
|
+
lark-relay cursors # ● 在跑 / ○ 空闲 Nh / ⚠ 空闲超期待回收
|
|
89
|
+
lark-relay cursors --forget <name> # 不等超期,现在就清
|
|
93
90
|
```
|
|
94
91
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
92
|
+
为什么是空闲时长而不是「落后多少条」:落后量是**遗弃的症状,不是反面** ——
|
|
93
|
+
被遗弃的游标落后会一路涨,拿它当判据会把该清的保护起来、该留的删掉
|
|
94
|
+
(2026-09-06 实测)。空闲时长与遗弃程度同向单调。
|
|
95
|
+
|
|
96
|
+
为什么 2 天够:take 是**实时场景**的取用口。空闲一两天以上再重挂,
|
|
97
|
+
下一次消费一定从新的开始;要历史消息有专门的接口,不靠游标回放。
|
|
98
|
+
改期限:`LARK_RELAY_CURSOR_TTL_DAYS=<天>`。
|
|
99
|
+
|
|
100
|
+
在跑的游标(有 take 持锁)一律不回收 —— take 会立刻把它写回来。
|
|
99
101
|
|
|
100
102
|
## 设计取舍
|
|
101
103
|
|
package/bin/lark-relay.js
CHANGED
|
@@ -24,7 +24,7 @@ const USAGE = `lark-relay ${VERSION} -- Lark 事件中继站(在场取用)
|
|
|
24
24
|
|
|
25
25
|
lark-relay take ... 在场取用:阻塞等一批 -> 输出 -> 退出
|
|
26
26
|
lark-relay status 谁在跑 / 各 app 积压 / 各游标位置
|
|
27
|
-
lark-relay cursors
|
|
27
|
+
lark-relay cursors 游标状态:谁在值守 / 空闲多久(超期自动回收)
|
|
28
28
|
lark-relay guide 一页用法(装完先读这个)
|
|
29
29
|
|
|
30
30
|
采集底座是独立命令(常驻服务):lark-relay-collect
|
|
@@ -145,29 +145,21 @@ async function cmdTake(argv) {
|
|
|
145
145
|
// 那个理由是错的,实测证伪:afterCursor 比的是「事件日期 > 游标日期」,
|
|
146
146
|
// 游标越老放行的事件越多。实测游标停在 D-5、窗口内有 3 条未消费时,
|
|
147
147
|
// 保留游标 -> 取到 3 条 重置到 now -> 取到 0 条
|
|
148
|
-
//
|
|
149
|
-
//
|
|
148
|
+
// 也就是说那个「自愈」每次都在**静默丢掉窗口内的全部积压**。
|
|
149
|
+
// 现在:游标一律原样保留,能取到的照常取。
|
|
150
150
|
//
|
|
151
|
-
//
|
|
152
|
-
// (
|
|
151
|
+
// 不再需要「位置早于窗口」那条告知了 —— 遗弃的游标由 collect 按空闲时长自动回收
|
|
152
|
+
// (见 lib/cursors.js),不靠位置推断,也不靠人清。
|
|
153
153
|
if (since === 'now') {
|
|
154
|
-
|
|
155
|
-
if (existing === null) {
|
|
156
|
-
store.seedCursorNow(name, a.app)
|
|
157
|
-
} else {
|
|
158
|
-
const { cursorDay, isPreWindowDay, oldestScannedDay } = require('../lib/cursors')
|
|
159
|
-
const day = cursorDay(existing)
|
|
160
|
-
if (isPreWindowDay(day)) {
|
|
161
|
-
// 说明行必须打在「监听中」**之前** —— 那一行是约定的启动信号
|
|
162
|
-
// (「没这行就是没起来」),不能被别的输出插到中间
|
|
163
|
-
process.stderr.write(
|
|
164
|
-
`游标 ${name} 停在 ${day},早于扫描窗口(最早 ${oldestScannedDay()})\n` +
|
|
165
|
-
`-> 那之前的消息已被 gc 回收、找不回;窗口内还在的会照常取\n`,
|
|
166
|
-
)
|
|
167
|
-
}
|
|
168
|
-
}
|
|
154
|
+
if (store.readCursor(name, a.app) === null) store.seedCursorNow(name, a.app)
|
|
169
155
|
}
|
|
170
156
|
|
|
157
|
+
// 标记「有人来取过」。回收判据是空闲时长,而它读 mtime --
|
|
158
|
+
// ⚠️ 必须在这里显式 touch:take 的正常路径只在**有批次**时写游标,
|
|
159
|
+
// 空手超时/被 SIGTERM 都不写(实测起一个 take 再 kill,mtime 一动不动)。
|
|
160
|
+
// 不 touch 就会把「安静的群盯了两天没消息」误判成遗弃并回收
|
|
161
|
+
store.touchCursor(name, a.app)
|
|
162
|
+
|
|
171
163
|
// 时长参数校验:负数/非数字过去会静默退回默认值或原样生效
|
|
172
164
|
// (实测 --debounce -5 被接受并打印「防抖=-5s」,--timeout abc 悄悄变成 12 小时)
|
|
173
165
|
const dur = (val, def, label) => {
|
|
@@ -219,8 +211,15 @@ async function cmdTake(argv) {
|
|
|
219
211
|
return 0
|
|
220
212
|
}
|
|
221
213
|
|
|
214
|
+
// cursors:**只读展示 + 一个提前忘掉的逃生舱**。
|
|
215
|
+
//
|
|
216
|
+
// ⚠️ 这里曾有 --prune / --force 与一套「该不该删」的判断(2026-09-06 删)。
|
|
217
|
+
// 删的理由不是简化:那套东西要求人拿着一个数字做破坏性决定,而**判据错了两次**
|
|
218
|
+
// (先按游标日期、后按待消费数,方向都是反的 —— 见 lib/cursors.js 文件头)。
|
|
219
|
+
// 现在回收由 collect 按空闲时长自动做,人不必判断,所以那些档位和确认步骤
|
|
220
|
+
// 一起失去了存在理由。留一个 --forget 给「现在就想清干净」。
|
|
222
221
|
function cmdCursors(argv) {
|
|
223
|
-
const CURSORS_FLAGS = ['
|
|
222
|
+
const CURSORS_FLAGS = ['forget', 'json', 'help']
|
|
224
223
|
const a = parseArgs(argv, { flags: CURSORS_FLAGS, keys: [] })
|
|
225
224
|
if (a._unknown.length) return rejectUnknown('lark-relay cursors', a._unknown, CURSORS_FLAGS)
|
|
226
225
|
if (a.help) {
|
|
@@ -228,47 +227,62 @@ function cmdCursors(argv) {
|
|
|
228
227
|
return 0
|
|
229
228
|
}
|
|
230
229
|
const cursors = require('../lib/cursors')
|
|
230
|
+
const ttlDays = cursors.IDLE_TTL_DAYS
|
|
231
|
+
|
|
232
|
+
// 人话的空闲时长。回收判据就是这个数,所以它必须是最显眼的一列
|
|
233
|
+
const idleTxt = (ms) => {
|
|
234
|
+
if (!Number.isFinite(ms)) return '未知'
|
|
235
|
+
// 向下取整:这个数是回收判据,宁可少报不可多报 --
|
|
236
|
+
// Math.round 会把 2.5 天显示成「3d」,看着像已经超了 2 天的 TTL 更多
|
|
237
|
+
const m = Math.floor(ms / 60000)
|
|
238
|
+
if (m < 60) return `${m}m`
|
|
239
|
+
const h = Math.floor(m / 60)
|
|
240
|
+
return h < 48 ? `${h}h` : `${Math.floor(h / 24)}d`
|
|
241
|
+
}
|
|
231
242
|
|
|
232
|
-
//
|
|
233
|
-
if (!a.
|
|
243
|
+
// 只读模式:列清单。删数据必须显式 --forget
|
|
244
|
+
if (!a.forget) {
|
|
234
245
|
const rows = cursors.listCursors()
|
|
235
246
|
if (a.json) {
|
|
236
247
|
process.stdout.write(`${JSON.stringify(rows, null, 2)}\n`)
|
|
237
248
|
return 0
|
|
238
249
|
}
|
|
239
250
|
if (!rows.length) {
|
|
240
|
-
process.stdout.write('没有游标(还没跑过 take
|
|
251
|
+
process.stdout.write('没有游标(还没跑过 take,或已全部回收)\n')
|
|
241
252
|
return 0
|
|
242
253
|
}
|
|
243
|
-
const mark = { live: '●',
|
|
254
|
+
const mark = { live: '●', idle: '○', expiring: '⚠' }
|
|
244
255
|
const note = {
|
|
245
256
|
live: (r) => `在跑 (pid ${r.pid}, 待消费 ${r.pending})`,
|
|
246
|
-
|
|
247
|
-
|
|
257
|
+
idle: (r) => `空闲 ${idleTxt(r.idleMs)}(待消费 ${r.pending},重挂 take 接着盯)`,
|
|
258
|
+
expiring: (r) => `空闲 ${idleTxt(r.idleMs)} 超 ${ttlDays} 天 -> 下轮 gc 自动回收`,
|
|
248
259
|
}
|
|
249
260
|
for (const r of rows) {
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
261
|
+
process.stdout.write(`${mark[r.state]} ${r.name} app=${r.app} ${note[r.state](r)}\n`)
|
|
262
|
+
}
|
|
263
|
+
const expiring = rows.filter((r) => r.state === 'expiring').length
|
|
264
|
+
if (expiring) {
|
|
254
265
|
process.stdout.write(
|
|
255
|
-
|
|
266
|
+
`\n${expiring} 个已超 ${ttlDays} 天空闲,collect 每小时自查时会回收 —— 不用手动清\n`,
|
|
256
267
|
)
|
|
257
268
|
}
|
|
258
|
-
const drained = rows.filter((r) => r.state === 'drained').length
|
|
259
|
-
if (drained) process.stdout.write(`\n${drained} 个已消费完 -> lark-relay cursors --prune\n`)
|
|
260
269
|
return 0
|
|
261
270
|
}
|
|
262
271
|
|
|
263
|
-
// --
|
|
272
|
+
// --forget <name>...:不等 TTL,现在就清
|
|
264
273
|
const names = a._
|
|
265
|
-
|
|
274
|
+
if (!names.length) {
|
|
275
|
+
process.stderr.write(
|
|
276
|
+
`--forget 需要点名要忘掉哪个身份:lark-relay cursors --forget <name>...\n` +
|
|
277
|
+
`(不点名的批量清理已经不需要了 —— 空闲超 ${ttlDays} 天的由 collect 自动回收)\n`,
|
|
278
|
+
)
|
|
279
|
+
return 2
|
|
280
|
+
}
|
|
281
|
+
const res = cursors.forget(names)
|
|
266
282
|
|
|
267
283
|
// removed 的元素是 `name/app` —— 删的单位是 app,一个身份可能盯多个
|
|
268
|
-
for (const n of res.removed) process.stdout.write(
|
|
284
|
+
for (const n of res.removed) process.stdout.write(`已忘掉 ${n}\n`)
|
|
269
285
|
for (const n of res.missing) process.stderr.write(`没有这个游标身份:${n}\n`)
|
|
270
|
-
|
|
271
|
-
// 在跑的不删 -- take 会立刻把游标写回来。只有显式点名时才报告(见 cursors.prune)
|
|
272
286
|
for (const r of res.refused) {
|
|
273
287
|
process.stderr.write(
|
|
274
288
|
`跳过 ${r.name}:有 take 正在用它(pid ${r.pid})。\n` +
|
|
@@ -276,23 +290,8 @@ function cmdCursors(argv) {
|
|
|
276
290
|
)
|
|
277
291
|
}
|
|
278
292
|
|
|
279
|
-
// 还有待消费的游标,删了就丢掉那些消息 -- 要 --force 再确认一次。
|
|
280
|
-
// 2026-09-06 真实踩到:点名删一个刚退出 take 的游标,那段消息的消费位置没了
|
|
281
|
-
for (const f of res.needForce) {
|
|
282
|
-
process.stderr.write(
|
|
283
|
-
`⚠ ${f.name}/${f.app} 还有 ${f.pending} 条待消费(位置 ${f.day})\n` +
|
|
284
|
-
` 删了就丢:重挂时会按 --since now 跳过这 ${f.pending} 条已落盘的消息\n` +
|
|
285
|
-
` 想取走 -> lark-relay take --app ${f.app} --name ${f.name} --chats oc_xxx\n` +
|
|
286
|
-
` 确认不要 -> lark-relay cursors --prune ${f.name} --force\n`,
|
|
287
|
-
)
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
if (!res.removed.length && !res.missing.length && !res.refused.length && !res.needForce.length) {
|
|
291
|
-
process.stdout.write(names.length ? '没删任何东西\n' : '没有已消费完的游标,无需清理\n')
|
|
292
|
-
}
|
|
293
|
-
// 点名的目标一个都没删成才算失败
|
|
294
293
|
if (res.missing.length) return 2
|
|
295
|
-
if (
|
|
294
|
+
if (res.refused.length && !res.removed.length) return 3
|
|
296
295
|
return 0
|
|
297
296
|
}
|
|
298
297
|
|
package/lib/collect.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
const readline = require('readline')
|
|
10
10
|
const larkcli = require('./larkcli')
|
|
11
11
|
const store = require('./store')
|
|
12
|
+
const cursors = require('./cursors')
|
|
12
13
|
const { paths, ensureDir } = require('./paths')
|
|
13
14
|
const { acquireExclusive } = require('./lock')
|
|
14
15
|
const { init: logInit, logLine } = require('./log')
|
|
@@ -121,14 +122,18 @@ async function runCollect(opts) {
|
|
|
121
122
|
// 非 launchd 场景(前台调试)是 no-op,logLine 退化成裸 stderr 直写
|
|
122
123
|
logInit()
|
|
123
124
|
|
|
124
|
-
//
|
|
125
|
+
// 互斥:一个 collect 就够。
|
|
125
126
|
// ⚠️ 这比 take 的锁更要紧,而它长期没有:同一 app 服务端只放行一个 event bus,
|
|
126
127
|
// 第二个 collect 起来会把线上那个挤掉,双方按 1s->2s->...->60s 退避互抢,
|
|
127
128
|
// 实测每 app 重启约 10 次、累计约 5 分钟才稳定 —— 那 5 分钟的消息**永久丢失**。
|
|
128
129
|
// 现实诱因不是手滑:COLLECT_HELP 教「前台跑(调试)」,deploy-launchd.sh 在
|
|
129
130
|
// bootstrap 失败时更直接建议「应急顶住采集:lark-relay-collect」--
|
|
130
131
|
// 若 launchd 那个其实还活着,照做就是自己抢自己。
|
|
131
|
-
//
|
|
132
|
+
//
|
|
133
|
+
// 注:锁在 paths.run 下,即**按 LARK_RELAY_HOME 隔离**,不是全机唯一。
|
|
134
|
+
// 换 HOME 能绕过(测试就靠这个并行),但真实场景下两个进程仍抢同一个
|
|
135
|
+
// 服务端 bus —— 锁只挡住最常见的那条路,挡不住刻意换 HOME。
|
|
136
|
+
// 要全机唯一得用别的原语(如固定路径 flock),现在没有这个需求
|
|
132
137
|
const lock = acquireExclusive('collect')
|
|
133
138
|
if (!lock.ok) {
|
|
134
139
|
logLine(
|
|
@@ -191,22 +196,34 @@ async function runCollect(opts) {
|
|
|
191
196
|
const collectors = chosen.map((p) => new AppCollector(p.name, opts))
|
|
192
197
|
for (const c of collectors) c.start()
|
|
193
198
|
|
|
194
|
-
// gc
|
|
195
|
-
// 另起 gc 命令 +
|
|
196
|
-
|
|
199
|
+
// gc 在本进程内每小时自查:回收逻辑就是「删超期的东西」,
|
|
200
|
+
// 另起 gc 命令 + timer 是为几行逻辑加两个部署件。
|
|
201
|
+
// store 按天删目录,cursors 按空闲时长删 —— 同构,故同处一地
|
|
202
|
+
const runGc = () => {
|
|
197
203
|
try {
|
|
198
204
|
const s = store.gcStore(opts.retain)
|
|
199
|
-
if (s.length) {
|
|
200
|
-
|
|
205
|
+
if (s.length) logLine(`gc: 删 store ${s.length} 个日期目录`)
|
|
206
|
+
} catch (err) {
|
|
207
|
+
logLine(`gc: store 回收失败 ${err.message}`)
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
// 游标回收:空闲超 TTL 的自动删,顺带清孤儿 dedup。
|
|
211
|
+
// **这是纪律「不盯了就清游标」的落地方式** -- 那条纪律三处成文却零执行,
|
|
212
|
+
// 靠人记不住;而判据本身还错过两次(见 lib/cursors.js 文件头)。
|
|
213
|
+
// 引擎自己过期,不需要人判断
|
|
214
|
+
const c = cursors.gcCursors()
|
|
215
|
+
if (c.length) {
|
|
216
|
+
// 不写「N 个超期游标」-- 返回值里还含孤儿 dedup,笼统数字会虚报。
|
|
217
|
+
// 逐个列出来,日志是排查断线窗口的唯一依据,宁可长一点
|
|
218
|
+
logLine(`gc: 回收游标 ${c.length} 项(空闲超 ${cursors.IDLE_TTL_DAYS} 天):${c.join(' ')}`)
|
|
201
219
|
}
|
|
202
220
|
} catch (err) {
|
|
203
|
-
logLine(`gc:
|
|
221
|
+
logLine(`gc: 游标回收失败 ${err.message}`)
|
|
204
222
|
}
|
|
205
|
-
}
|
|
223
|
+
}
|
|
224
|
+
const gcTimer = setInterval(runGc, GC_INTERVAL_MS)
|
|
206
225
|
// 启动时先跑一次,别等一小时
|
|
207
|
-
|
|
208
|
-
store.gcStore(opts.retain)
|
|
209
|
-
} catch {}
|
|
226
|
+
runGc()
|
|
210
227
|
|
|
211
228
|
return await new Promise((resolve) => {
|
|
212
229
|
let shuttingDown = false
|
package/lib/cursors.js
CHANGED
|
@@ -1,50 +1,48 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
// cursors:游标治理 --
|
|
3
|
+
// cursors:游标治理 -- 只做两件事:**如实展示**每个游标的状态,
|
|
4
|
+
// 以及**按空闲时长自动回收**(由 collect 的 gc 每小时调一次)。
|
|
4
5
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// 根因不是人不自觉,是**没有反馈回路**:status 把死游标和活消费者渲染成同一个样子,
|
|
8
|
-
// 看不出谁有人在跑。所以这里给出机械判据,让状态可见、清理一键化。
|
|
6
|
+
// ⚠️ 这个模块 2026-09-06 一天内改了三版。前两版都错在同一个地方:
|
|
7
|
+
// **让人拿着一个推断量去做破坏性决定**。记下来免得再走一遍:
|
|
9
8
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
9
|
+
// ① 第一版判据「游标日期超出扫描窗口 -> 删掉无损」。
|
|
10
|
+
// 实测证伪:afterCursor 比的是「事件日期 > 游标日期」,游标越老放行的事件**越多**。
|
|
11
|
+
// 停在 D-5 的游标能取到窗口内 3 条,而 --since now 取到 0 条。
|
|
12
|
+
// 照它删/重置就是丢掉全部积压。
|
|
13
|
+
// ② 第二版改成实测量「pending = 按这个游标续接还剩几条」,以为方向对了。
|
|
14
|
+
// **仍然是反的** —— 实测两种剧本:
|
|
15
|
+
// 被遗弃两天的游标(app 还在收消息) -> pending 50 -> 判 backlog -> 保护它
|
|
16
|
+
// 活跃任务刚处理完一批、take 暂退 -> pending 0 -> 判 drained -> 删掉它
|
|
17
|
+
// 该清的被保护,该留的被删。因为**落后量是遗弃的症状,不是遗弃的反面**。
|
|
18
|
+
// 更糟:只要 app 还在收消息,被遗弃游标的 pending 单调增长,永不归零 --
|
|
19
|
+
// 裸 prune 对真正该清的东西**永久无效**(实测 gc 删掉游标所在那天后 pending 仍不变)。
|
|
18
20
|
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
21
|
+
// **正解:判据是「多久没人用过它」,不是「它落后多少」。**
|
|
22
|
+
// mtime 一直都在,从来没被读过。这个方向与遗弃程度同向单调:越久没碰越该清。
|
|
23
|
+
// 而且它让清理**不再需要人做判断** -- 前两次犯错都发生在
|
|
24
|
+
// 「人拿着一个数字决定删不删」这一步,这一版把那一步整个删掉:引擎自己过期。
|
|
23
25
|
const fs = require('fs')
|
|
24
26
|
const path = require('path')
|
|
25
27
|
const store = require('./store')
|
|
26
|
-
const { paths
|
|
28
|
+
const { paths } = require('./paths')
|
|
27
29
|
const { lockPathFor, holderPid } = require('./lock')
|
|
28
30
|
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
function isPreWindowDay(day) {
|
|
45
|
-
if (!day) return true // 读不出日期的游标,当作位置不明
|
|
46
|
-
return day < oldestScannedDay()
|
|
47
|
-
}
|
|
31
|
+
// 空闲多久算遗弃。默认 2 天。
|
|
32
|
+
//
|
|
33
|
+
// 为什么是 2 天而不是更长(2026-09-06 用户定):take 是**实时场景**的取用口 --
|
|
34
|
+
// 「关了会话过阵子接着盯」的窗口本来就短。空闲一两天以上再重挂,
|
|
35
|
+
// 下一次消费一定是从新的开始;真要历史消息有专门的接口,不靠游标回放。
|
|
36
|
+
//
|
|
37
|
+
// 代价说清楚:超过 TTL 的游标被回收后,重挂按 --since now 起头,
|
|
38
|
+
// 跳过这段已落盘的消息。但 store 只保留 SCAN_DAYS(默认 3)天 --
|
|
39
|
+
// TTL 2 天时,被回收的游标最多也就跳过盘上还剩的那一天多,
|
|
40
|
+
// 而那种情况本身就是「两天没人管」。这个代价是可接受的,且是**显式**的
|
|
41
|
+
const IDLE_TTL_DAYS = (() => {
|
|
42
|
+
const d = Number(process.env.LARK_RELAY_CURSOR_TTL_DAYS)
|
|
43
|
+
return Number.isFinite(d) && d > 0 ? d : 2
|
|
44
|
+
})()
|
|
45
|
+
const IDLE_TTL_MS = IDLE_TTL_DAYS * 86400_000
|
|
48
46
|
|
|
49
47
|
// 活消费者判定:复用 take 已经在用的锁,不另造注册表。
|
|
50
48
|
// take 用 `take-<name>` 作锁键(bin/lark-relay.js),锁目录里的 pid 文件就是持有者
|
|
@@ -53,30 +51,36 @@ function livePidOf(name) {
|
|
|
53
51
|
}
|
|
54
52
|
|
|
55
53
|
/**
|
|
56
|
-
* 枚举 cursors/
|
|
54
|
+
* 枚举 cursors/ 下的全部游标。
|
|
57
55
|
*
|
|
58
56
|
* 一个 name 目录下可能有多个 app 文件,外加 dedup 的 <app>.seen.json 与落盘用的 .tmp --
|
|
59
57
|
* 只有「没后缀的那些」才是游标本身,别把 dedup 文件当成一个 app。
|
|
60
58
|
*
|
|
61
|
-
* @
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
59
|
+
* @param {{withPending?:boolean}} opts withPending=false 跳过 scanWindow(gc 路径用)
|
|
60
|
+
* @returns {Array<{name,app,cursor,day,state,pid,idleMs,pending}>}
|
|
61
|
+
* state: live(有 take 持锁) | idle(空闲但在 TTL 内) | expiring(超 TTL,下轮 gc 回收)
|
|
62
|
+
* idleMs: 距最后一次被使用多久 —— **回收判据**
|
|
63
|
+
* pending: 按此游标续接还剩几条 —— 仅作展示(它不是回收判据,见文件头 ②);
|
|
64
|
+
* withPending=false 时为 null
|
|
65
65
|
*/
|
|
66
|
-
function listCursors() {
|
|
66
|
+
function listCursors(opts = {}) {
|
|
67
|
+
const withPending = opts.withPending !== false
|
|
67
68
|
let names
|
|
68
69
|
try {
|
|
69
70
|
names = fs.readdirSync(paths.cursors).filter((n) => !n.startsWith('.'))
|
|
70
71
|
} catch {
|
|
71
72
|
return [] // 还没跑过任何 take
|
|
72
73
|
}
|
|
73
|
-
// 同一 app 可能被多个身份盯着,scanWindow 一次就够 ——
|
|
74
|
+
// 同一 app 可能被多个身份盯着,scanWindow 一次就够 —— 每行都扫一遍是平方级浪费。
|
|
75
|
+
// gc 路径完全不需要它:回收只看 mtime,而扫描要读+排序全窗口的事件文件
|
|
76
|
+
// (实测真实 store 734 个文件/4ms,每小时白跑一次没道理)
|
|
74
77
|
const scanCache = new Map()
|
|
75
78
|
const scanOf = (app) => {
|
|
76
79
|
if (!scanCache.has(app)) scanCache.set(app, store.scanWindow(app))
|
|
77
80
|
return scanCache.get(app)
|
|
78
81
|
}
|
|
79
82
|
|
|
83
|
+
const now = Date.now()
|
|
80
84
|
const out = []
|
|
81
85
|
for (const name of names.sort()) {
|
|
82
86
|
const dir = path.join(paths.cursors, name)
|
|
@@ -92,12 +96,20 @@ function listCursors() {
|
|
|
92
96
|
if (f.startsWith('.') || f.endsWith('.tmp') || f.endsWith('.seen.json')) continue
|
|
93
97
|
const key = store.readCursor(name, f)
|
|
94
98
|
if (!key) continue
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
99
|
+
const mtime = store.cursorMtime(name, f)
|
|
100
|
+
const idleMs = mtime === null ? Infinity : Math.max(0, now - mtime)
|
|
101
|
+
// live 优先:take 正在跑就是在用,不看空闲时长
|
|
102
|
+
const state = pid ? 'live' : idleMs >= IDLE_TTL_MS ? 'expiring' : 'idle'
|
|
103
|
+
out.push({
|
|
104
|
+
name,
|
|
105
|
+
app: f,
|
|
106
|
+
cursor: key,
|
|
107
|
+
day: String(key).split('/')[0] || null,
|
|
108
|
+
state,
|
|
109
|
+
pid,
|
|
110
|
+
idleMs,
|
|
111
|
+
pending: withPending ? scanOf(f).filter((e) => store.afterCursor(e, key)).length : null,
|
|
112
|
+
})
|
|
101
113
|
}
|
|
102
114
|
}
|
|
103
115
|
return out
|
|
@@ -108,9 +120,9 @@ function listCursors() {
|
|
|
108
120
|
// 被误判成重复而静默丢掉(TTL 6 小时,见 dedup.js)。
|
|
109
121
|
//
|
|
110
122
|
// ⚠️ 粒度必须是 app 而不是整个身份目录:一个 --name 可以同时盯多个 app
|
|
111
|
-
// (cursors/<name>/appA、<name>/appB)
|
|
112
|
-
// 早先这里 rm -rf 整个 name 目录,于是「appA
|
|
113
|
-
// 清 appA 会把 appB
|
|
123
|
+
// (cursors/<name>/appA、<name>/appB),而空闲时长是**逐 app 判定**的。
|
|
124
|
+
// 早先这里 rm -rf 整个 name 目录,于是「appA 空闲很久、appB 昨天还在用」时
|
|
125
|
+
// 清 appA 会把 appB 一起删掉(实测复现)。
|
|
114
126
|
function removeCursorApp(name, app) {
|
|
115
127
|
const dir = path.join(paths.cursors, name)
|
|
116
128
|
let ok = false
|
|
@@ -128,7 +140,7 @@ function removeCursorApp(name, app) {
|
|
|
128
140
|
}
|
|
129
141
|
|
|
130
142
|
// 顺带清掉该身份的锁目录(若持有者已死)。
|
|
131
|
-
//
|
|
143
|
+
// 删了游标却留着 take-<name>.lock 会在 run/ 里积累孤儿锁 --
|
|
132
144
|
// 不致命(下次抢锁会夺走陈旧锁),但既然在清理就一并收干净
|
|
133
145
|
function removeStaleLock(name) {
|
|
134
146
|
if (holderPid(`take-${name}`)) return // 还活着,不碰
|
|
@@ -138,17 +150,88 @@ function removeStaleLock(name) {
|
|
|
138
150
|
}
|
|
139
151
|
|
|
140
152
|
/**
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
153
|
+
* 自动回收:删掉空闲超过 TTL 的游标。**由 collect 的 gc 每小时调一次**,
|
|
154
|
+
* 与 store.gcStore 完全同构 —— 都是「删超期的东西」,不需要人参与。
|
|
155
|
+
*
|
|
156
|
+
* 这是本模块存在的理由。前两版把回收做成「人拿着一个数字敲 --prune」,
|
|
157
|
+
* 结果纪律三处成文、零执行,而判据本身还错了两次。
|
|
158
|
+
* 引擎自己过期不需要人记得,也不需要人判断。
|
|
159
|
+
*
|
|
160
|
+
* ⚠️ 有 take 持锁的一律不动 —— 它正在用,而且它会立刻把游标写回来。
|
|
161
|
+
*
|
|
162
|
+
* @returns {string[]} 被回收的 `name/app`,以及清掉的孤儿 dedup(标 `(dedup)`)
|
|
148
163
|
*/
|
|
149
|
-
function
|
|
150
|
-
const
|
|
151
|
-
|
|
164
|
+
function gcCursors() {
|
|
165
|
+
const removed = []
|
|
166
|
+
const touchedNames = new Set()
|
|
167
|
+
// withPending: false —— 回收只看 mtime。算 pending 要读+排序全窗口事件文件
|
|
168
|
+
// (实测真实 store 734 个/4ms),在每小时的 daemon 循环里是纯浪费
|
|
169
|
+
for (const c of listCursors({ withPending: false })) {
|
|
170
|
+
if (c.state !== 'expiring') continue
|
|
171
|
+
if (removeCursorApp(c.name, c.app)) {
|
|
172
|
+
removed.push(`${c.name}/${c.app}`)
|
|
173
|
+
touchedNames.add(c.name)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
for (const n of touchedNames) removeStaleLock(n)
|
|
177
|
+
removed.push(...gcOrphanDedup())
|
|
178
|
+
return removed
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 孤儿 dedup:`<app>.seen.json` 还在,但对应的游标文件已经不在了。
|
|
182
|
+
//
|
|
183
|
+
// ⚠️ removeCursorApp 声明「连带删 dedup」,但那只覆盖**它自己删的**那些。
|
|
184
|
+
// 实测漏网路径:游标文件被手工删掉 / 旧版本清理留下的残留 / 中途失败 --
|
|
185
|
+
// 此时 listCursors 跳过整个 app(它只认游标文件),dedup 就**永久留着**,
|
|
186
|
+
// 而它是本模块唯一会无限增长的东西(游标 43 字节,seen.json 实测已到 2.3K)。
|
|
187
|
+
//
|
|
188
|
+
// 判据不用 mtime:游标不在了,这个文件按定义就是垃圾 --
|
|
189
|
+
// dedup 记录本身只有 6 小时 TTL(见 dedup.js),留着没有任何用途。
|
|
190
|
+
function gcOrphanDedup() {
|
|
191
|
+
const removed = []
|
|
192
|
+
let names
|
|
193
|
+
try {
|
|
194
|
+
names = fs.readdirSync(paths.cursors).filter((n) => !n.startsWith('.'))
|
|
195
|
+
} catch {
|
|
196
|
+
return removed
|
|
197
|
+
}
|
|
198
|
+
for (const name of names) {
|
|
199
|
+
const dir = path.join(paths.cursors, name)
|
|
200
|
+
let files
|
|
201
|
+
try {
|
|
202
|
+
if (!fs.statSync(dir).isDirectory()) continue
|
|
203
|
+
files = fs.readdirSync(dir)
|
|
204
|
+
} catch {
|
|
205
|
+
continue
|
|
206
|
+
}
|
|
207
|
+
for (const f of files) {
|
|
208
|
+
if (!f.endsWith('.seen.json')) continue
|
|
209
|
+
const app = f.slice(0, -'.seen.json'.length)
|
|
210
|
+
if (files.includes(app)) continue // 游标还在,dedup 有主
|
|
211
|
+
try {
|
|
212
|
+
fs.rmSync(path.join(dir, f), { force: true })
|
|
213
|
+
removed.push(`${name}/${app} (dedup)`)
|
|
214
|
+
} catch {}
|
|
215
|
+
}
|
|
216
|
+
// 只剩空目录就收掉
|
|
217
|
+
try {
|
|
218
|
+
if (!fs.readdirSync(dir).length) fs.rmdirSync(dir)
|
|
219
|
+
} catch {}
|
|
220
|
+
}
|
|
221
|
+
return removed
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* 逃生舱:立刻忘掉指定身份(该身份下全部 app),不等 TTL。
|
|
226
|
+
* 用于「刚验完、现在就想清干净」。
|
|
227
|
+
*
|
|
228
|
+
* 不设 --force 二次确认:回收本来就会自动发生,人只是提前几天,
|
|
229
|
+
* 没有「删了会丢消息」的额外判断负担 —— 那正是前两版的坑。
|
|
230
|
+
*
|
|
231
|
+
* @returns {{removed:string[], refused:Array<{name,pid}>, missing:string[]}}
|
|
232
|
+
*/
|
|
233
|
+
function forget(names = []) {
|
|
234
|
+
const all = listCursors({ withPending: false }) // 删不看落后量
|
|
152
235
|
const byName = new Map()
|
|
153
236
|
for (const c of all) {
|
|
154
237
|
if (!byName.has(c.name)) byName.set(c.name, [])
|
|
@@ -158,11 +241,8 @@ function prune(names = [], opts = {}) {
|
|
|
158
241
|
const removed = []
|
|
159
242
|
const refused = []
|
|
160
243
|
const missing = []
|
|
161
|
-
const needForce = []
|
|
162
244
|
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
for (const name of targets) {
|
|
245
|
+
for (const name of names) {
|
|
166
246
|
const rows = byName.get(name)
|
|
167
247
|
if (!rows) {
|
|
168
248
|
missing.push(name)
|
|
@@ -170,24 +250,12 @@ function prune(names = [], opts = {}) {
|
|
|
170
250
|
}
|
|
171
251
|
const live = rows.find((r) => r.state === 'live')
|
|
172
252
|
if (live) {
|
|
173
|
-
//
|
|
174
|
-
|
|
175
|
-
// 报告它是噪音(实测:3 个 take 在跑时,一次日常清理吐 3 段无关提示)
|
|
176
|
-
if (names.length) refused.push({ name, pid: live.pid })
|
|
253
|
+
// 在跑的不删:take 会立刻把游标写回来,删了只是骗自己
|
|
254
|
+
refused.push({ name, pid: live.pid })
|
|
177
255
|
continue
|
|
178
256
|
}
|
|
179
|
-
// 显式点名 = 清该身份的全部 app(用户明确不盯了);
|
|
180
|
-
// 裸 prune = 只清已消费完的,有积压的留着
|
|
181
|
-
const victims = names.length ? rows : rows.filter((r) => r.state === 'drained')
|
|
182
257
|
let deletedAny = false
|
|
183
|
-
for (const r of
|
|
184
|
-
// ⚠️ 还有积压 = **删了就丢掉这 pending 条**(重挂时会按 --since now 跳过它们)。
|
|
185
|
-
// drained 删掉无损,backlog 不是 -- 故 backlog 要 --force 兜一道。
|
|
186
|
-
// 2026-09-06 真实踩到:点名删一个刚退出 take 的游标,消费位置就此丢失
|
|
187
|
-
if (r.state === 'backlog' && !opts.force) {
|
|
188
|
-
needForce.push({ name, app: r.app, day: r.day, pending: r.pending })
|
|
189
|
-
continue
|
|
190
|
-
}
|
|
258
|
+
for (const r of rows) {
|
|
191
259
|
if (removeCursorApp(name, r.app)) {
|
|
192
260
|
removed.push(`${name}/${r.app}`)
|
|
193
261
|
deletedAny = true
|
|
@@ -196,14 +264,14 @@ function prune(names = [], opts = {}) {
|
|
|
196
264
|
if (deletedAny) removeStaleLock(name)
|
|
197
265
|
}
|
|
198
266
|
|
|
199
|
-
return { removed, refused, missing
|
|
267
|
+
return { removed, refused, missing }
|
|
200
268
|
}
|
|
201
269
|
|
|
202
270
|
module.exports = {
|
|
271
|
+
IDLE_TTL_MS,
|
|
272
|
+
IDLE_TTL_DAYS,
|
|
203
273
|
listCursors,
|
|
204
|
-
|
|
274
|
+
gcCursors,
|
|
275
|
+
forget,
|
|
205
276
|
removeCursorApp,
|
|
206
|
-
cursorDay,
|
|
207
|
-
isPreWindowDay,
|
|
208
|
-
oldestScannedDay,
|
|
209
277
|
}
|
package/lib/help.js
CHANGED
|
@@ -48,10 +48,8 @@ function takeHelp(apps) {
|
|
|
48
48
|
最佳实践
|
|
49
49
|
· 一直等不到消息 -> 九成是 bot 不在群,回第二步验(最常见故障)
|
|
50
50
|
· 多个消费者盯同一 app 用不同 --name,游标互不干扰
|
|
51
|
-
·
|
|
52
|
-
|
|
53
|
-
一直挂「有 N 条待消费」,而 store 只保留几天 -- N 会随过期删除而变小,
|
|
54
|
-
看着像追上了,其实是消息没了`
|
|
51
|
+
· **不用手动清游标**:空闲超 2 天的由 collect 自动回收。
|
|
52
|
+
想立刻清干净:lark-relay cursors --forget <name>`
|
|
55
53
|
}
|
|
56
54
|
|
|
57
55
|
const COLLECT_HELP = `把全部 lark-cli profile 的事件收下来,原子落盘。零参数、零配置。
|
|
@@ -67,10 +65,13 @@ app 列表实时读 \`lark-cli profile list\`,新增 profile 自动纳入,无需
|
|
|
67
65
|
tokenStatus 为 expired 的 profile 自动跳过并 warn(永久失败,重试是死循环)。
|
|
68
66
|
用户重新 login 后下次启动自动纳入。
|
|
69
67
|
|
|
70
|
-
⚠️
|
|
68
|
+
⚠️ **一个 collect 就够,第二个会拒绝启动并报出持有者 pid**。
|
|
71
69
|
不是洁癖:同一 app 服务端只放行一个 event bus,两个 collect 会互相挤掉并按
|
|
72
70
|
1s->2s->...->60s 退避重连,实测约 5 分钟才稳定 -- 那段时间的消息永久丢失。
|
|
73
|
-
|
|
71
|
+
所以上面那条「前台跑(调试)」在常驻服务已在跑时会直接退出,不会抢它。
|
|
72
|
+
注:互斥锁在 $LARK_RELAY_HOME/run/ 下,即**按 HOME 隔离**。
|
|
73
|
+
换个 HOME 起第二个能绕过锁,但那样两个进程仍会抢同一个服务端 event bus --
|
|
74
|
+
别这么用(测试用不同 HOME 并行是安全的,它们不连真实 bus)。
|
|
74
75
|
|
|
75
76
|
为什么必须常驻:lark 事件是流式的,进程不在的时刻消息永久丢失(实测:消息发出
|
|
76
77
|
8s 后才起 consumer,收到 0 条)。collect 独立常驻,才能让 take 侧
|
|
@@ -82,7 +83,7 @@ tokenStatus 为 expired 的 profile 自动跳过并 warn(永久失败,重试是
|
|
|
82
83
|
日志行带 ISO 时间戳;超 16MB 自动轮转,旧的在 .1/.2/.3
|
|
83
84
|
|
|
84
85
|
落盘:~/.lark-relay/store/<app>/<YYYY-MM-DD>/<纳秒>_<pid>_<seq>.json
|
|
85
|
-
|
|
86
|
+
回收在本进程内每小时自查一次(不另起 gc 单元/timer),细则见下「回收」。
|
|
86
87
|
|
|
87
88
|
参数
|
|
88
89
|
--exclude <apps> 排除指定 profile(逗号分隔)
|
|
@@ -93,6 +94,13 @@ tokenStatus 为 expired 的 profile 自动跳过并 warn(永久失败,重试是
|
|
|
93
94
|
环境变量
|
|
94
95
|
LARK_RELAY_LOG_MAX_MB 单个日志文件上限(默认 16),超了轮转
|
|
95
96
|
LARK_RELAY_LOG_KEEP 保留几代旧日志(默认 3)
|
|
97
|
+
LARK_RELAY_CURSOR_TTL_DAYS 游标空闲多久算遗弃(默认 2),超了本进程的 gc 回收
|
|
98
|
+
|
|
99
|
+
回收(每小时自查一次,启动时先跑一次):
|
|
100
|
+
· store:删超过 --retain 天的整个日期目录
|
|
101
|
+
· 游标:删空闲超 TTL 的(见上),顺带清没有主的 dedup 残留。
|
|
102
|
+
有 take 在跑的一律不动 —— 判据是「多久没人用过它」,不是「落后多少条」
|
|
103
|
+
(后者方向反了,详见 lib/cursors.js 文件头记的三版判据)
|
|
96
104
|
|
|
97
105
|
常驻部署(macOS / launchd)
|
|
98
106
|
要点只有三条,plist 自己写十来行就够,不必找模板:
|
|
@@ -112,44 +120,46 @@ const STATUS_HELP = `一眼看清全局:谁在跑、积压多少、游标在哪
|
|
|
112
120
|
lark-relay status --json # 机器可读
|
|
113
121
|
|
|
114
122
|
消费者状态:
|
|
115
|
-
● 在跑
|
|
116
|
-
○
|
|
117
|
-
⚠
|
|
123
|
+
● 在跑 有 take 持锁,pid 是它
|
|
124
|
+
○ 空闲 Nh 没人在跑,但最近用过 —— 重挂 take 接着盯
|
|
125
|
+
⚠ 空闲 Nd 超过空闲期,下轮 gc 自动回收(不用手动清)
|
|
118
126
|
|
|
119
|
-
|
|
120
|
-
|
|
127
|
+
判据是**空闲多久**,不是落后多少条。落后量只作展示:
|
|
128
|
+
它是遗弃的症状而不是反面 —— 被遗弃的游标落后会一路涨,
|
|
129
|
+
拿它当清理判据会把该清的保护起来、该留的删掉(2026-09-06 实测)。
|
|
121
130
|
|
|
122
|
-
排障入口:「○
|
|
131
|
+
排障入口:「○ 空闲」而你以为它在盯 -> 它的 take 没起来或早退了;
|
|
123
132
|
「无消费者」= 白采,可考虑 --exclude。
|
|
124
|
-
清理已消费完的游标:lark-relay cursors --prune
|
|
125
133
|
collect 行不是 running -> tail -f ~/.lark-relay/logs/collect.err.log
|
|
126
134
|
(launchd 不进统一日志),collect 停摆的每一秒都在丢消息。`
|
|
127
135
|
|
|
128
|
-
const CURSORS_HELP =
|
|
136
|
+
const CURSORS_HELP = `游标状态:谁在值守、空闲多久。**清理是自动的,不用你记。**
|
|
129
137
|
|
|
130
138
|
lark-relay cursors # 列出全部游标及状态
|
|
131
|
-
lark-relay cursors --
|
|
132
|
-
lark-relay cursors --
|
|
133
|
-
|
|
139
|
+
lark-relay cursors --json # 机器可读
|
|
140
|
+
lark-relay cursors --forget <name>... # 不等超期,现在就清掉
|
|
141
|
+
|
|
142
|
+
状态:● 在跑 / ○ 空闲(最近用过)/ ⚠ 空闲超期(下轮 gc 回收)
|
|
134
143
|
|
|
135
|
-
|
|
144
|
+
**空闲超过 2 天的游标由 collect 自动回收**(每小时自查一次,与 store 的
|
|
145
|
+
日期目录回收同一个 gc)。判据是「多久没人用过它」-- 与遗弃程度同向单调,
|
|
146
|
+
且不需要人做判断。默认 2 天:take 是实时场景的取用口,
|
|
147
|
+
空闲一两天以上再重挂,下一次消费一定从新的开始;要历史消息有专门的接口。
|
|
148
|
+
改期限:LARK_RELAY_CURSOR_TTL_DAYS=<天>
|
|
136
149
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
○ 已消费完 直接清。剩 0 条,删掉无损
|
|
140
|
-
⚠ 有 N 条待消费 要 --force。删了那 N 条就跳过了;想要就重挂 take 取走
|
|
150
|
+
⚠️ 判据**不是**「落后多少条」,也不是「游标停在哪天」 -- 这两个都试过、都错了
|
|
151
|
+
(方向反的,见下)。落后量只作展示。
|
|
141
152
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
按日期删游标或重置它,会静默丢掉这些消息。
|
|
153
|
+
● 在跑的一律不回收 —— take 会立刻把游标写回来
|
|
154
|
+
被回收后重挂 = 按 --since now 起头,跳过那段已落盘的消息。
|
|
155
|
+
这正是「两天没人管」该有的行为;真要接着取,在超期前重挂就行
|
|
146
156
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
157
|
+
--forget 是逃生舱(验完了、现在就想清干净)。它不需要二次确认 --
|
|
158
|
+
回收本来会自动发生,人只是提前几天。
|
|
159
|
+
删的单位是 app 目录下的每个 app,连带删该 app 的 dedup 记录
|
|
160
|
+
(<app>.seen.json),不留会让重挂后头几条被误判重复。
|
|
150
161
|
|
|
151
|
-
退出码:0 正常,2 参数错或点名的身份不存在,
|
|
152
|
-
3 点名的目标一个都没清掉(在跑,或需要 --force)`
|
|
162
|
+
退出码:0 正常,2 参数错或点名的身份不存在,3 点名的在跑(没清掉)`
|
|
153
163
|
|
|
154
164
|
const GUIDE = `lark-relay -- Lark 事件中继站。两件事:收下来 / 我来取。
|
|
155
165
|
|
|
@@ -172,12 +182,10 @@ take 四步(照做)
|
|
|
172
182
|
起来后 stderr 有一行「监听中」-- 没有就是没起来,看 stderr。
|
|
173
183
|
一批处理完再起一个。不要自己写 while 循环 -- 进程退出会通知你。
|
|
174
184
|
退出码:0=有一批,4=超时没消息(正常,再起一个),2=参数错,3=游标被占用。
|
|
175
|
-
4.
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
不清的代价:status 里一直挂着积压数(它会随 store 过期变小,
|
|
180
|
-
看着像追上了,其实是消息永久没了)。
|
|
185
|
+
4. 不盯了就走开 什么都不用做
|
|
186
|
+
空闲超 2 天的游标由 collect 自动回收(每小时自查),不用你记得清理。
|
|
187
|
+
想立刻清干净:lark-relay cursors --forget <name>
|
|
188
|
+
看状态:lark-relay cursors(○ 空闲 / ⚠ 超期待回收 / ● 在跑)
|
|
181
189
|
|
|
182
190
|
常见错误
|
|
183
191
|
· 拿不到消息 -> 九成是 bot 不在群。验 bot 必须 --as user,
|
package/lib/status.js
CHANGED
|
@@ -84,10 +84,9 @@ async function buildStatus() {
|
|
|
84
84
|
latestMs = Number(ev?.timestamp || ev?.create_time) || null
|
|
85
85
|
}
|
|
86
86
|
const cs = (byApp.get(app) || []).map((c) => {
|
|
87
|
-
// lag
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
// (它以为老游标取不到东西,而老游标恰好全部放行)。现在只有一个口径
|
|
87
|
+
// lag = 实测待消费条数。它是**展示量,不是治理判据** ——
|
|
88
|
+
// 判据是空闲时长(见 lib/cursors.js):落后量是遗弃的症状,不是遗弃的反面,
|
|
89
|
+
// 拿它决定删不删会把「该清的保护起来、该留的删掉」(2026-09-06 实测)
|
|
91
90
|
return {
|
|
92
91
|
name: c.name,
|
|
93
92
|
lag: c.pending,
|
|
@@ -95,7 +94,7 @@ async function buildStatus() {
|
|
|
95
94
|
pid: c.pid,
|
|
96
95
|
cursor: c.cursor,
|
|
97
96
|
day: c.day,
|
|
98
|
-
|
|
97
|
+
idleMs: c.idleMs,
|
|
99
98
|
}
|
|
100
99
|
})
|
|
101
100
|
rows.push({ app, today: store.countToday(app), latestMs, consumers: cs })
|
|
@@ -130,7 +129,16 @@ function formatStatus(s) {
|
|
|
130
129
|
}
|
|
131
130
|
const w = (str, n) => String(str) + ' '.repeat(Math.max(1, n - dispWidth(str)))
|
|
132
131
|
out.push(`${w('app', 8)}${w('今日事件', 10)}${w('最新事件', 13)}消费者`)
|
|
133
|
-
let
|
|
132
|
+
let expiringCount = 0
|
|
133
|
+
const idleTxt = (ms) => {
|
|
134
|
+
if (!Number.isFinite(ms)) return '未知'
|
|
135
|
+
// 向下取整:这个数是回收判据,宁可少报不可多报 --
|
|
136
|
+
// Math.round 会把 2.5 天显示成「3d」,看着像已经超了 2 天的 TTL 更多
|
|
137
|
+
const m = Math.floor(ms / 60000)
|
|
138
|
+
if (m < 60) return `${m}m`
|
|
139
|
+
const h = Math.floor(m / 60)
|
|
140
|
+
return h < 48 ? `${h}h` : `${Math.floor(h / 24)}d`
|
|
141
|
+
}
|
|
134
142
|
for (const r of s.apps) {
|
|
135
143
|
// 消费者各占一行:状态要一眼分得清,挤在一行里 ●/○/⚠ 会被淹掉。
|
|
136
144
|
// 「谁在跑」是排障第一问,以前得靠猜(全都长成 `name(落后 N 条)`)
|
|
@@ -145,24 +153,20 @@ function formatStatus(s) {
|
|
|
145
153
|
if (c.state === 'live') {
|
|
146
154
|
mark = '●'
|
|
147
155
|
note = `在跑 (pid ${c.pid}, ${c.lag === 0 ? '游标追平' : `落后 ${c.lag} 条`})`
|
|
148
|
-
} else if (c.state === '
|
|
149
|
-
|
|
150
|
-
mark = '○'
|
|
151
|
-
note = '已消费完 (没人在跑,删掉无损)'
|
|
152
|
-
} else {
|
|
156
|
+
} else if (c.state === 'expiring') {
|
|
157
|
+
expiringCount++
|
|
153
158
|
mark = '⚠'
|
|
154
|
-
note =
|
|
159
|
+
note = `空闲 ${idleTxt(c.idleMs)} —— 已超期,下轮 gc 自动回收`
|
|
160
|
+
} else {
|
|
161
|
+
mark = '○'
|
|
162
|
+
note = `空闲 ${idleTxt(c.idleMs)} (待消费 ${c.lag},重挂 take 接着盯)`
|
|
155
163
|
}
|
|
156
|
-
// 位置早于扫描窗口:那之前的一段已被 gc 回收、找不回。
|
|
157
|
-
// 与「能不能删」无关(判据是上面的待消费数),但它是真实损失,必须说
|
|
158
|
-
if (c.lostSpan) note += `;${c.day} 之前的已被 gc 回收`
|
|
159
164
|
out.push(` ${w(c.name, 24)}${mark} ${note}`)
|
|
160
165
|
}
|
|
161
166
|
}
|
|
162
|
-
if (
|
|
167
|
+
if (expiringCount) {
|
|
163
168
|
out.push('')
|
|
164
|
-
out.push(
|
|
165
|
-
out.push(' 清理:lark-relay cursors --prune')
|
|
169
|
+
out.push(`⚠ ${expiringCount} 个游标已超空闲期 —— collect 每小时自查时回收,不用手动清`)
|
|
166
170
|
}
|
|
167
171
|
|
|
168
172
|
return out.join('\n')
|
package/lib/store.js
CHANGED
|
@@ -11,8 +11,8 @@ const { paths, ensureDir, eventFilename, dayKey, dayKeyOffset } = require('./pat
|
|
|
11
11
|
//
|
|
12
12
|
// ⚠️ 必须**等于** store 保留天数,不是「>= 就行」。两边差 1 会造出一个三不管的日子:
|
|
13
13
|
// 2026-09-06 实测 SCAN_DAYS=4 / retain=3 时,D-3 被 gcStore 整目录删掉了,
|
|
14
|
-
// 却仍被算作「窗口内」->
|
|
15
|
-
//
|
|
14
|
+
// 却仍被算作「窗口内」-> 停在那天的游标被当成「还能续接」,而那天的数据一条都不在了。
|
|
15
|
+
// 方向反过来(窗口 < 保留)则是「留着却扫不到」。
|
|
16
16
|
// 两个方向都错,所以判据不是不等式而是等号 —— 默认值就按等号配。
|
|
17
17
|
//
|
|
18
18
|
// 与 collect 的 --retain 是两个进程里的两个值,没法在运行时互相读取,
|
|
@@ -128,6 +128,35 @@ function writeCursor(name, app, key) {
|
|
|
128
128
|
fs.renameSync(tmp, file)
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
// 把游标标记成「刚被用过」。回收判据是空闲时长(见 lib/cursors.js),
|
|
132
|
+
// 而它读的是 mtime —— 所以「有人在用」必须能刷新 mtime。
|
|
133
|
+
//
|
|
134
|
+
// ⚠️ 为什么不能只靠 writeCursor 顺带刷新:take 的 abort 路径是
|
|
135
|
+
// `if (batch.length && commit) writeCursor` —— **空手被 SIGTERM 时不写**。
|
|
136
|
+
// 实测:游标写入后起一个 take、3 秒后 kill,mtime 一动不动。
|
|
137
|
+
// 于是「一个安静的群盯了两天没消息」会被误判成遗弃并回收。
|
|
138
|
+
// 故 take 启动时显式 touch 一次,让「有人来取过」这件事本身就算活动
|
|
139
|
+
function touchCursor(name, app) {
|
|
140
|
+
const file = paths.cursor(name, app)
|
|
141
|
+
try {
|
|
142
|
+
const now = new Date()
|
|
143
|
+
fs.utimesSync(file, now, now)
|
|
144
|
+
return true
|
|
145
|
+
} catch {
|
|
146
|
+
return false // 还没有游标文件(首次跑),seedCursorNow 会建
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 游标最后一次被使用的时间(ms)。读不到返回 null。
|
|
151
|
+
// mtime 而非 atime:atime 会被 status/cursors 的只读扫描刷新,那不算「在用」
|
|
152
|
+
function cursorMtime(name, app) {
|
|
153
|
+
try {
|
|
154
|
+
return fs.statSync(paths.cursor(name, app)).mtimeMs
|
|
155
|
+
} catch {
|
|
156
|
+
return null
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
131
160
|
// --since now:游标落到当前最新,不重放历史。
|
|
132
161
|
// 默认值反过来就消灭了旧架构 adhoc SOP 的「预热游标」那一步。
|
|
133
162
|
function seedCursorNow(name, app) {
|
|
@@ -190,6 +219,8 @@ module.exports = {
|
|
|
190
219
|
readEvent,
|
|
191
220
|
readCursor,
|
|
192
221
|
writeCursor,
|
|
222
|
+
touchCursor,
|
|
223
|
+
cursorMtime,
|
|
193
224
|
seedCursorNow,
|
|
194
225
|
afterCursor,
|
|
195
226
|
entryKey,
|