lark-relay 0.4.13 → 0.4.15
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 +15 -4
- package/bin/lark-relay-collect.js +12 -8
- package/bin/lark-relay.js +52 -35
- package/lib/args.js +9 -43
- package/lib/collect.js +21 -0
- package/lib/cursors.js +79 -38
- package/lib/help.js +41 -26
- package/lib/larkcli.js +5 -22
- package/lib/render.js +0 -0
- package/lib/status.js +30 -26
- package/lib/store.js +10 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -63,7 +63,7 @@ lark-relay take --app <app> --chats oc_xxx --render text
|
|
|
63
63
|
空参数跑一下会输出完整的照做指导(查群 ID -> 验 bot 在群 -> 监听三步),exit 0。
|
|
64
64
|
起来后 stderr 先打一行「监听中」-- 没这行就是没起来,看 stderr 报错。
|
|
65
65
|
退出码:0=有一批,4=超时没消息(正常,再起一个),2=参数错,3=游标被占用。
|
|
66
|
-
参数拼错(如 `--chat-id`)
|
|
66
|
+
参数拼错(如 `--chat-id`)会硬失败并给出正确写法,不静默吞掉。
|
|
67
67
|
|
|
68
68
|
业务过滤只有 `--filter`(jq 表达式)一个口子,不为每个业务加参数。
|
|
69
69
|
|
|
@@ -81,10 +81,21 @@ lark-relay take --app <app> --chats oc_xxx --render text
|
|
|
81
81
|
盯同一 app 请用不同 `--name` 隔离游标。
|
|
82
82
|
|
|
83
83
|
**游标要自己收尾**:它是「关了会话、过阵子接着盯」的续接凭据,所以引擎**不会**
|
|
84
|
-
|
|
85
|
-
代价是不再用的游标会一直留在 `status`
|
|
84
|
+
自动清理还有积压的游标 -- 自动清掉就等于下次重挂跳过这段已落盘的消息。
|
|
85
|
+
代价是不再用的游标会一直留在 `status` 里显示「有 N 条待消费」,而 store 只保留几天,
|
|
86
86
|
那个 N 会随过期删除而变小(**看着像追上了,其实是消息没了**)。
|
|
87
|
-
|
|
87
|
+
|
|
88
|
+
一次性监听、验完的排查、不再值守的任务,收尾时:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
lark-relay cursors # ● 在跑 / ○ 已消费完 / ⚠ 有积压没人跑
|
|
92
|
+
lark-relay cursors --prune # 清「已消费完」的(删掉无损)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
判据只有一条:按这个游标续接还剩几条没消费。剩 0 条才无条件可清;
|
|
96
|
+
还有积压的要 `--prune <name> --force` 明确确认(删了就跳过那些消息)。
|
|
97
|
+
别手工 `rm -rf` 游标目录 -- 那会漏掉同名的 `<app>.seen.json`(去重记录),
|
|
98
|
+
残留会让重挂后头几条消息被误判成重复而静默丢掉。
|
|
88
99
|
|
|
89
100
|
## 设计取舍
|
|
90
101
|
|
|
@@ -45,7 +45,7 @@ async function main() {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
const a = parseArgs(argv, { flags: ['help'], keys: KEYS })
|
|
48
|
-
if (a._unknown.length) return rejectUnknown(PROG,
|
|
48
|
+
if (a._unknown.length) return rejectUnknown(PROG, a._unknown, KEYS)
|
|
49
49
|
if (a.help) {
|
|
50
50
|
process.stdout.write(`${help.COLLECT_HELP}\n`)
|
|
51
51
|
return 0
|
|
@@ -64,14 +64,18 @@ async function main() {
|
|
|
64
64
|
return 2
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
// retain
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
67
|
+
// retain 必须**正好等于**消费侧的扫描窗口。两个方向都是事故:
|
|
68
|
+
// retain > 窗口:事件留在盘上却永远扫不到(store.js 那条注释警告的)
|
|
69
|
+
// retain < 窗口:窗口最老那几天已被 gc 删空,却仍被算作「窗口内」——
|
|
70
|
+
// 停在那里的游标判成「可续接」,而数据一条都不在了
|
|
71
|
+
// (2026-09-06 实测 retain=3 / 窗口=4 的 D-3 就是这种空档)
|
|
72
|
+
// 两个值在两个进程里,只能在这里把不变量钉死:装完新版直接起不来,好过悄悄丢
|
|
73
|
+
const retain = numStrict(a.retain, store.SCAN_DAYS)
|
|
74
|
+
if (retain === null || retain !== store.SCAN_DAYS) {
|
|
72
75
|
process.stderr.write(
|
|
73
|
-
`--retain
|
|
74
|
-
|
|
76
|
+
`--retain 必须等于消费侧扫描窗口 ${store.SCAN_DAYS} 天,当前:${a.retain}\n` +
|
|
77
|
+
`要改保留期就同时改两边:LARK_RELAY_SCAN_DAYS=<N> 且 --retain <N>。\n` +
|
|
78
|
+
`留得比窗口久 -> 事件在盘上却扫不到;短于窗口 -> 空目录被当成「还能续接」\n`,
|
|
75
79
|
)
|
|
76
80
|
return 2
|
|
77
81
|
}
|
package/bin/lark-relay.js
CHANGED
|
@@ -17,10 +17,8 @@ const COMMANDS = ['take', 'status', 'cursors', 'guide']
|
|
|
17
17
|
|
|
18
18
|
// collect 搬家提示。能力是**搬走了不是删了**,所以不能只说「未知命令」
|
|
19
19
|
const MOVED_HINT =
|
|
20
|
-
`collect 已拆成独立命令
|
|
21
|
-
` lark-relay-collect
|
|
22
|
-
` lark-relay-collect --help # 参数与常驻部署\n` +
|
|
23
|
-
`同一个包就带,不用另外装。\n`
|
|
20
|
+
`collect 已拆成独立命令(同一个包就带,不用另外装):\n` +
|
|
21
|
+
` lark-relay-collect --help # 前台调试与常驻部署\n`
|
|
24
22
|
|
|
25
23
|
const USAGE = `lark-relay ${VERSION} -- Lark 事件中继站(在场取用)
|
|
26
24
|
|
|
@@ -68,7 +66,7 @@ async function main() {
|
|
|
68
66
|
default: {
|
|
69
67
|
// 拼错 collect(collectt / colect)同样走搬家提示 —— 意图明显是采集,
|
|
70
68
|
// 给「未知命令」等于让人以为功能没了。判据:编辑距离 <=2 且不像任何现有命令
|
|
71
|
-
if (
|
|
69
|
+
if (suggest(cmd, ['collect']) === 'collect' && !suggest(cmd, COMMANDS)) {
|
|
72
70
|
process.stderr.write(`未知命令:${cmd}。${MOVED_HINT}`)
|
|
73
71
|
return 2
|
|
74
72
|
}
|
|
@@ -81,7 +79,7 @@ async function main() {
|
|
|
81
79
|
|
|
82
80
|
async function cmdTake(argv) {
|
|
83
81
|
const a = parseArgs(argv, { flags: ['help'], keys: TAKE_KEYS })
|
|
84
|
-
if (a._unknown.length) return rejectUnknown('lark-relay take',
|
|
82
|
+
if (a._unknown.length) return rejectUnknown('lark-relay take', a._unknown, TAKE_KEYS)
|
|
85
83
|
const larkcli = require('../lib/larkcli')
|
|
86
84
|
|
|
87
85
|
// 空参数输出完整照做指导,而非报错 -- AI 的真实行为是「先空参数跑一下看看」。
|
|
@@ -140,28 +138,32 @@ async function cmdTake(argv) {
|
|
|
140
138
|
process.stderr.write(`--since 只能是 now 或 all,当前:${since}\n`)
|
|
141
139
|
return 2
|
|
142
140
|
}
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
141
|
+
// 游标初始化:没有游标就按 --since now 起头(消灭旧架构「预热游标」那一步)。
|
|
142
|
+
//
|
|
143
|
+
// ⚠️ 2026-09-06 删掉了这里的「stale 自愈」。它曾在游标位置超出扫描窗口时
|
|
144
|
+
// 把游标**重置到当前最新**,理由写的是「续接它和 seed now 行为完全一样」。
|
|
145
|
+
// 那个理由是错的,实测证伪:afterCursor 比的是「事件日期 > 游标日期」,
|
|
146
|
+
// 游标越老放行的事件越多。实测游标停在 D-5、窗口内有 3 条未消费时,
|
|
147
|
+
// 保留游标 -> 取到 3 条 重置到 now -> 取到 0 条
|
|
148
|
+
// 也就是说那个「自愈」每次都在**静默丢掉窗口内的全部积压**,
|
|
149
|
+
// 还打一行「那段消息已被 gc 回收」宽慰你。老游标本身工作得很好,不需要治。
|
|
150
|
+
//
|
|
151
|
+
// 位置早于窗口确实要说一句,但说的是「D-x 到 D-y 那段已被 gc 回收、找不回」
|
|
152
|
+
// (告知既成事实),而不是动用户的游标 —— 剩下能取到的照常取。
|
|
150
153
|
if (since === 'now') {
|
|
151
154
|
const existing = store.readCursor(name, a.app)
|
|
152
155
|
if (existing === null) {
|
|
153
156
|
store.seedCursorNow(name, a.app)
|
|
154
157
|
} else {
|
|
155
|
-
const { cursorDay,
|
|
158
|
+
const { cursorDay, isPreWindowDay, oldestScannedDay } = require('../lib/cursors')
|
|
156
159
|
const day = cursorDay(existing)
|
|
157
|
-
if (
|
|
160
|
+
if (isPreWindowDay(day)) {
|
|
158
161
|
// 说明行必须打在「监听中」**之前** —— 那一行是约定的启动信号
|
|
159
162
|
// (「没这行就是没起来」),不能被别的输出插到中间
|
|
160
163
|
process.stderr.write(
|
|
161
|
-
`游标 ${name} 停在 ${day}
|
|
162
|
-
`->
|
|
164
|
+
`游标 ${name} 停在 ${day},早于扫描窗口(最早 ${oldestScannedDay()})\n` +
|
|
165
|
+
`-> 那之前的消息已被 gc 回收、找不回;窗口内还在的会照常取\n`,
|
|
163
166
|
)
|
|
164
|
-
store.seedCursorNow(name, a.app)
|
|
165
167
|
}
|
|
166
168
|
}
|
|
167
169
|
}
|
|
@@ -218,9 +220,9 @@ async function cmdTake(argv) {
|
|
|
218
220
|
}
|
|
219
221
|
|
|
220
222
|
function cmdCursors(argv) {
|
|
221
|
-
const CURSORS_FLAGS = ['prune', 'json', 'help']
|
|
223
|
+
const CURSORS_FLAGS = ['prune', 'force', 'json', 'help']
|
|
222
224
|
const a = parseArgs(argv, { flags: CURSORS_FLAGS, keys: [] })
|
|
223
|
-
if (a._unknown.length) return rejectUnknown('lark-relay cursors',
|
|
225
|
+
if (a._unknown.length) return rejectUnknown('lark-relay cursors', a._unknown, CURSORS_FLAGS)
|
|
224
226
|
if (a.help) {
|
|
225
227
|
process.stdout.write(`${help.CURSORS_HELP}\n`)
|
|
226
228
|
return 0
|
|
@@ -238,50 +240,65 @@ function cmdCursors(argv) {
|
|
|
238
240
|
process.stdout.write('没有游标(还没跑过 take,或已全部清理)\n')
|
|
239
241
|
return 0
|
|
240
242
|
}
|
|
241
|
-
const mark = { live: '●',
|
|
243
|
+
const mark = { live: '●', drained: '○', backlog: '⚠' }
|
|
242
244
|
const note = {
|
|
243
|
-
live: (r) => `在跑 (pid ${r.pid})`,
|
|
244
|
-
|
|
245
|
-
|
|
245
|
+
live: (r) => `在跑 (pid ${r.pid}, 待消费 ${r.pending})`,
|
|
246
|
+
drained: () => '已消费完 -> 删掉无损',
|
|
247
|
+
backlog: (r) => `有 ${r.pending} 条待消费但没人在跑 -> 重挂 take 能接着取`,
|
|
246
248
|
}
|
|
247
249
|
for (const r of rows) {
|
|
250
|
+
// 位置早于扫描窗口 = 那之前的一段已被 gc 回收、找不回。
|
|
251
|
+
// ⚠️ 这**不是**可删许可(早先的 stale 判据就错在这一步):
|
|
252
|
+
// 窗口内还在的事件,老游标照样全部放行。能否删只看待消费数
|
|
253
|
+
const lost = r.lostSpan ? `,${r.day} 之前的已被 gc 回收` : ''
|
|
248
254
|
process.stdout.write(
|
|
249
|
-
`${mark[r.state]} ${r.name} app=${r.app} 位置=${r.day} ${note[r.state](r)}\n`,
|
|
255
|
+
`${mark[r.state]} ${r.name} app=${r.app} 位置=${r.day} ${note[r.state](r)}${lost}\n`,
|
|
250
256
|
)
|
|
251
257
|
}
|
|
252
|
-
const
|
|
253
|
-
if (
|
|
258
|
+
const drained = rows.filter((r) => r.state === 'drained').length
|
|
259
|
+
if (drained) process.stdout.write(`\n${drained} 个已消费完 -> lark-relay cursors --prune\n`)
|
|
254
260
|
return 0
|
|
255
261
|
}
|
|
256
262
|
|
|
257
263
|
// --prune:位置参数是要删的身份;没给就只清 stale 的那些
|
|
258
264
|
const names = a._
|
|
259
|
-
const res = cursors.prune(names)
|
|
265
|
+
const res = cursors.prune(names, { force: !!a.force })
|
|
260
266
|
|
|
261
267
|
// removed 的元素是 `name/app` —— 删的单位是 app,一个身份可能盯多个
|
|
262
268
|
for (const n of res.removed) process.stdout.write(`已删 ${n}\n`)
|
|
263
269
|
for (const n of res.missing) process.stderr.write(`没有这个游标身份:${n}\n`)
|
|
264
270
|
|
|
265
|
-
//
|
|
271
|
+
// 在跑的不删 -- take 会立刻把游标写回来。只有显式点名时才报告(见 cursors.prune)
|
|
266
272
|
for (const r of res.refused) {
|
|
267
273
|
process.stderr.write(
|
|
268
274
|
`跳过 ${r.name}:有 take 正在用它(pid ${r.pid})。\n` +
|
|
269
275
|
` 要清它先停掉那个 take(kill ${r.pid}),否则它会把游标立刻写回来。\n`,
|
|
270
276
|
)
|
|
271
277
|
}
|
|
272
|
-
|
|
273
|
-
|
|
278
|
+
|
|
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')
|
|
274
292
|
}
|
|
275
|
-
//
|
|
276
|
-
// 裸 prune 顺带跳过一个在跑的游标不是错误,那次清理是成功的
|
|
293
|
+
// 点名的目标一个都没删成才算失败
|
|
277
294
|
if (res.missing.length) return 2
|
|
278
|
-
if (res.refused.length
|
|
295
|
+
if ((res.refused.length || res.needForce.length) && !res.removed.length) return 3
|
|
279
296
|
return 0
|
|
280
297
|
}
|
|
281
298
|
|
|
282
299
|
async function cmdStatus(argv) {
|
|
283
300
|
const a = parseArgs(argv, { flags: ['json', 'help'], keys: [] })
|
|
284
|
-
if (a._unknown.length) return rejectUnknown('lark-relay status',
|
|
301
|
+
if (a._unknown.length) return rejectUnknown('lark-relay status', a._unknown, ['json'])
|
|
285
302
|
if (a.help) {
|
|
286
303
|
process.stdout.write(`${help.STATUS_HELP}\n`)
|
|
287
304
|
return 0
|
package/lib/args.js
CHANGED
|
@@ -104,14 +104,18 @@ function suggest(unknown, allowed) {
|
|
|
104
104
|
return null
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
// 未知参数 -> stderr 短错误 +
|
|
107
|
+
// 未知参数 -> stderr 短错误 + 候选提示,exit 2。
|
|
108
108
|
// 不打印完整用法 -- 那看着像 --help 成功,会被当成「正常退出但没消息」
|
|
109
109
|
//
|
|
110
110
|
// ⚠️ `prog` 必须由调用方传入,不能写死 'lark-relay':两个 bin 共用这段,
|
|
111
|
-
//
|
|
111
|
+
// 而提示里那行是要照抄执行的 —— 打出一个不存在的命令
|
|
112
112
|
// (如已拆走的 `lark-relay collect`)会把人引向死路。
|
|
113
113
|
// lark-relay 传 `lark-relay <子命令>`,lark-relay-collect 传自己的名字。
|
|
114
|
-
|
|
114
|
+
//
|
|
115
|
+
// 注:2026-09-06 删掉了 rewriteArgv(把整条 argv 重写成可照跑的命令行,约 35 行)。
|
|
116
|
+
// 它是纯投机 —— 参数总共 9 个,`suggest` 已经点名了该写哪个,
|
|
117
|
+
// 再拼一条完整命令行属于替调用方猜意图,而那 35 行要长期跟着参数表走
|
|
118
|
+
function rejectUnknown(prog, unknown, allowed, out = process.stderr) {
|
|
115
119
|
const names = [...new Set(unknown)]
|
|
116
120
|
const shown = names.map((k) => `--${k}`).join(', ')
|
|
117
121
|
let hint = ''
|
|
@@ -122,46 +126,8 @@ function rejectUnknown(prog, argv, unknown, allowed, out = process.stderr) {
|
|
|
122
126
|
break
|
|
123
127
|
}
|
|
124
128
|
}
|
|
125
|
-
|
|
126
|
-
const tail = rewrite.length ? `\n你是不是想跑这个?\n ${prog} ${rewrite.join(' ')}` : ''
|
|
127
|
-
out.write(`未知参数:${shown}${hint}${tail}\n跑 \`${prog}\` 看完整用法\n`)
|
|
129
|
+
out.write(`未知参数:${shown}${hint}\n跑 \`${prog}\` 看完整用法\n`)
|
|
128
130
|
return 2
|
|
129
131
|
}
|
|
130
132
|
|
|
131
|
-
|
|
132
|
-
function rewriteArgv(argv, unknown, allowed) {
|
|
133
|
-
const map = new Map()
|
|
134
|
-
for (const k of unknown) {
|
|
135
|
-
const s = suggest(k, allowed)
|
|
136
|
-
if (s && !map.has(k)) map.set(k, s)
|
|
137
|
-
}
|
|
138
|
-
if (!map.size) return []
|
|
139
|
-
const out = []
|
|
140
|
-
for (let i = 0; i < argv.length; i++) {
|
|
141
|
-
const a = argv[i]
|
|
142
|
-
if (a.startsWith('--') && a !== '--') {
|
|
143
|
-
const eq = a.indexOf('=')
|
|
144
|
-
const k = eq !== -1 ? a.slice(2, eq) : a.slice(2)
|
|
145
|
-
if (map.has(k)) {
|
|
146
|
-
out.push(eq !== -1 ? `--${map.get(k)}=${a.slice(eq + 1)}` : `--${map.get(k)}`)
|
|
147
|
-
if (eq === -1) {
|
|
148
|
-
const next = argv[i + 1]
|
|
149
|
-
if (next !== undefined && !next.startsWith('--')) {
|
|
150
|
-
out.push(next)
|
|
151
|
-
i++
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
continue
|
|
155
|
-
}
|
|
156
|
-
if (eq === -1) {
|
|
157
|
-
const next = argv[i + 1]
|
|
158
|
-
if (next !== undefined && !next.startsWith('--')) i++
|
|
159
|
-
}
|
|
160
|
-
continue
|
|
161
|
-
}
|
|
162
|
-
out.push(a)
|
|
163
|
-
}
|
|
164
|
-
return out
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
module.exports = { parseArgs, numStrict, list, suggest, rejectUnknown, rewriteArgv }
|
|
133
|
+
module.exports = { parseArgs, numStrict, list, suggest, rejectUnknown }
|
package/lib/collect.js
CHANGED
|
@@ -10,6 +10,7 @@ const readline = require('readline')
|
|
|
10
10
|
const larkcli = require('./larkcli')
|
|
11
11
|
const store = require('./store')
|
|
12
12
|
const { paths, ensureDir } = require('./paths')
|
|
13
|
+
const { acquireExclusive } = require('./lock')
|
|
13
14
|
const { init: logInit, logLine } = require('./log')
|
|
14
15
|
|
|
15
16
|
const EVENT_KEY = process.env.LARK_RELAY_EVENT_KEY || 'im.message.receive_v1'
|
|
@@ -120,6 +121,26 @@ async function runCollect(opts) {
|
|
|
120
121
|
// 非 launchd 场景(前台调试)是 no-op,logLine 退化成裸 stderr 直写
|
|
121
122
|
logInit()
|
|
122
123
|
|
|
124
|
+
// 互斥:同一台机器只该有一个 collect。
|
|
125
|
+
// ⚠️ 这比 take 的锁更要紧,而它长期没有:同一 app 服务端只放行一个 event bus,
|
|
126
|
+
// 第二个 collect 起来会把线上那个挤掉,双方按 1s->2s->...->60s 退避互抢,
|
|
127
|
+
// 实测每 app 重启约 10 次、累计约 5 分钟才稳定 —— 那 5 分钟的消息**永久丢失**。
|
|
128
|
+
// 现实诱因不是手滑:COLLECT_HELP 教「前台跑(调试)」,deploy-launchd.sh 在
|
|
129
|
+
// bootstrap 失败时更直接建议「应急顶住采集:lark-relay-collect」--
|
|
130
|
+
// 若 launchd 那个其实还活着,照做就是自己抢自己。
|
|
131
|
+
// 拿不到锁就退出并给出判据,不留「看着在跑其实在互抢」的中间态
|
|
132
|
+
const lock = acquireExclusive('collect')
|
|
133
|
+
if (!lock.ok) {
|
|
134
|
+
logLine(
|
|
135
|
+
`error: 已有一个 lark-relay-collect 在跑(pid ${lock.holder}),本进程退出。\n` +
|
|
136
|
+
`同一 app 服务端只放行一个 event bus —— 两个 collect 会互相挤掉并退避重连,\n` +
|
|
137
|
+
`实测约 5 分钟才稳定,那段时间的消息永久丢失。\n` +
|
|
138
|
+
`确认是谁在跑: launchctl print system/com.adaex.lark-relay-collect\n` +
|
|
139
|
+
`要接管请先停掉它:sudo launchctl bootout system/com.adaex.lark-relay-collect`,
|
|
140
|
+
)
|
|
141
|
+
return 1
|
|
142
|
+
}
|
|
143
|
+
|
|
123
144
|
const exclude = new Set(opts.exclude || [])
|
|
124
145
|
let profiles
|
|
125
146
|
try {
|
package/lib/cursors.js
CHANGED
|
@@ -1,27 +1,34 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
// cursors:游标治理 --
|
|
3
|
+
// cursors:游标治理 -- 判定每个游标能不能删,并支持清理。
|
|
4
4
|
//
|
|
5
5
|
// 为什么需要这个模块:纪律「不盯了就删游标」写在 AGENTS.md 和 help 三处,
|
|
6
6
|
// 却从落地到 2026-09-06 一次都没被执行 -- 8 个游标全留着,0 个在值守。
|
|
7
|
-
// 根因不是人不自觉,是**没有反馈回路**:
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
7
|
+
// 根因不是人不自觉,是**没有反馈回路**:status 把死游标和活消费者渲染成同一个样子,
|
|
8
|
+
// 看不出谁有人在跑。所以这里给出机械判据,让状态可见、清理一键化。
|
|
9
|
+
//
|
|
10
|
+
// ⚠️ 2026-09-06 重写:上一版判据是**错的**,推理反了,实测证伪。
|
|
11
|
+
// 原文写「游标日期早于扫描窗口 -> 续接它和 --since now 完全等价 -> 删掉无损」。
|
|
12
|
+
// 但 afterCursor 比的是「事件日期 > 游标日期」-- **游标越老,通过过滤的事件越多**。
|
|
13
|
+
// 实测:游标停在 D-5、窗口内 D-2/D-1/D-0 各有 1 条未消费,
|
|
14
|
+
// 按该游标续接 -> 扫到 3 条(全是能取到的)
|
|
15
|
+
// --since now -> 扫到 0 条
|
|
16
|
+
// gc 删掉的只是游标所在那几天,窗口内的事件一条没删,而老游标恰好全部放行。
|
|
17
|
+
// 「续接取不到东西」正好说反了,照它删就是丢掉窗口内的全部积压。
|
|
18
|
+
//
|
|
19
|
+
// **正解:别再从游标日期推断,直接量。** 能不能无损删除只取决于一件事 --
|
|
20
|
+
// 按这个游标续接还能扫到几条(pending)。pending==0 才是真的删掉无损。
|
|
21
|
+
// 游标日期早于窗口是另一回事:它说明**那段已被 gc 回收、永久找不回**,
|
|
22
|
+
// 这是要告知的事实,不是可以删除的许可。两件事必须分开,混在一起就是上一版的错。
|
|
12
23
|
const fs = require('fs')
|
|
13
24
|
const path = require('path')
|
|
14
25
|
const store = require('./store')
|
|
15
26
|
const { paths, dayKeyOffset } = require('./paths')
|
|
16
27
|
const { lockPathFor, holderPid } = require('./lock')
|
|
17
28
|
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
// afterCursor 过滤后必然为空。也就是说**删掉这种游标是无损的**,不会丢任何还能取到的消息。
|
|
22
|
-
//
|
|
23
|
-
// ⚠️ 反过来说,窗口**内**的游标不能自动删 -- 那才是「关了会话、过阵子接着盯」的续接凭据,
|
|
24
|
-
// 删了等于跳过停机期已落盘的消息。这是 idle 与 stale 必须分开的全部理由
|
|
29
|
+
// 扫描窗口最老的一天。比它更早的日期,collect 的 gc 已经整目录删了。
|
|
30
|
+
// ⚠️ 这个值只用来**告知「那段找不回了」**,不参与「能否删除」的判定 --
|
|
31
|
+
// 那正是上一版把两件事混在一起犯的错
|
|
25
32
|
function oldestScannedDay() {
|
|
26
33
|
return dayKeyOffset(-(store.SCAN_DAYS - 1))
|
|
27
34
|
}
|
|
@@ -32,8 +39,10 @@ function cursorDay(key) {
|
|
|
32
39
|
return String(key || '').split('/')[0] || null
|
|
33
40
|
}
|
|
34
41
|
|
|
35
|
-
|
|
36
|
-
|
|
42
|
+
// 游标位置是否早于扫描窗口 = 它与当前位置之间有一段已被 gc 回收、永久找不回。
|
|
43
|
+
// 注意这**不**代表游标没用了:窗口内的事件它照样全部放行(见文件头实测)
|
|
44
|
+
function isPreWindowDay(day) {
|
|
45
|
+
if (!day) return true // 读不出日期的游标,当作位置不明
|
|
37
46
|
return day < oldestScannedDay()
|
|
38
47
|
}
|
|
39
48
|
|
|
@@ -43,9 +52,17 @@ function livePidOf(name) {
|
|
|
43
52
|
return holderPid(`take-${name}`)
|
|
44
53
|
}
|
|
45
54
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
55
|
+
/**
|
|
56
|
+
* 枚举 cursors/ 下的全部游标,并实测每个的待消费条数。
|
|
57
|
+
*
|
|
58
|
+
* 一个 name 目录下可能有多个 app 文件,外加 dedup 的 <app>.seen.json 与落盘用的 .tmp --
|
|
59
|
+
* 只有「没后缀的那些」才是游标本身,别把 dedup 文件当成一个 app。
|
|
60
|
+
*
|
|
61
|
+
* @returns {Array<{name,app,cursor,day,state,pid,pending,lostSpan}>}
|
|
62
|
+
* state: live(有 take 在跑) | backlog(有待消费,删了就丢) | drained(消费完了,删掉无损)
|
|
63
|
+
* pending: 按此游标续接还能扫到几条 —— **能否删除的唯一判据**
|
|
64
|
+
* lostSpan: 位置早于扫描窗口,中间那段已被 gc 回收、找不回(与能否删除无关,仅告知)
|
|
65
|
+
*/
|
|
49
66
|
function listCursors() {
|
|
50
67
|
let names
|
|
51
68
|
try {
|
|
@@ -53,6 +70,13 @@ function listCursors() {
|
|
|
53
70
|
} catch {
|
|
54
71
|
return [] // 还没跑过任何 take
|
|
55
72
|
}
|
|
73
|
+
// 同一 app 可能被多个身份盯着,scanWindow 一次就够 —— 每行都扫一遍是平方级浪费
|
|
74
|
+
const scanCache = new Map()
|
|
75
|
+
const scanOf = (app) => {
|
|
76
|
+
if (!scanCache.has(app)) scanCache.set(app, store.scanWindow(app))
|
|
77
|
+
return scanCache.get(app)
|
|
78
|
+
}
|
|
79
|
+
|
|
56
80
|
const out = []
|
|
57
81
|
for (const name of names.sort()) {
|
|
58
82
|
const dir = path.join(paths.cursors, name)
|
|
@@ -69,10 +93,11 @@ function listCursors() {
|
|
|
69
93
|
const key = store.readCursor(name, f)
|
|
70
94
|
if (!key) continue
|
|
71
95
|
const day = cursorDay(key)
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
96
|
+
// 实测待消费数 —— 判据在这里,不在日期上
|
|
97
|
+
const pending = scanOf(f).filter((e) => store.afterCursor(e, key)).length
|
|
98
|
+
// live 优先:take 正在跑就以它为准,它自己会把游标推进
|
|
99
|
+
const state = pid ? 'live' : pending > 0 ? 'backlog' : 'drained'
|
|
100
|
+
out.push({ name, app: f, cursor: key, day, state, pid, pending, lostSpan: isPreWindowDay(day) })
|
|
76
101
|
}
|
|
77
102
|
}
|
|
78
103
|
return out
|
|
@@ -83,9 +108,9 @@ function listCursors() {
|
|
|
83
108
|
// 被误判成重复而静默丢掉(TTL 6 小时,见 dedup.js)。
|
|
84
109
|
//
|
|
85
110
|
// ⚠️ 粒度必须是 app 而不是整个身份目录:一个 --name 可以同时盯多个 app
|
|
86
|
-
// (cursors/<name>/appA、<name>/appB)
|
|
87
|
-
// 早先这里 rm -rf 整个 name 目录,于是「appA
|
|
88
|
-
// 清 appA 会把 appB
|
|
111
|
+
// (cursors/<name>/appA、<name>/appB),而状态是**逐 app 判定**的。
|
|
112
|
+
// 早先这里 rm -rf 整个 name 目录,于是「appA 已消费完、appB 还有积压」时
|
|
113
|
+
// 清 appA 会把 appB 那个**还有积压**的游标一起删掉(实测复现)。
|
|
89
114
|
function removeCursorApp(name, app) {
|
|
90
115
|
const dir = path.join(paths.cursors, name)
|
|
91
116
|
let ok = false
|
|
@@ -114,11 +139,14 @@ function removeStaleLock(name) {
|
|
|
114
139
|
|
|
115
140
|
/**
|
|
116
141
|
* 清理游标。
|
|
117
|
-
* @param {string[]} names 指定要删的身份(该身份下全部 app);空数组 =
|
|
118
|
-
* @
|
|
119
|
-
*
|
|
142
|
+
* @param {string[]} names 指定要删的身份(该身份下全部 app);空数组 = 只删已消费完的
|
|
143
|
+
* @param {{force?:boolean}} opts force 才允许删还有积压(backlog)的游标
|
|
144
|
+
* @returns {{removed:string[], refused:Array<{name,pid}>, missing:string[],
|
|
145
|
+
* needForce:Array<{name,app,day,pending}>}}
|
|
146
|
+
* removed 的元素形如 `name/app` -- 删的单位是 app,不是整个身份。
|
|
147
|
+
* refused 只在**显式点名**时填充:裸 prune 遇到在跑的游标是静默跳过,不算例外
|
|
120
148
|
*/
|
|
121
|
-
function prune(names = []) {
|
|
149
|
+
function prune(names = [], opts = {}) {
|
|
122
150
|
const all = listCursors()
|
|
123
151
|
// 一个 name 可能有多个 app 行,按身份归并 -- live 判定是按身份的(锁以 name 为键)
|
|
124
152
|
const byName = new Map()
|
|
@@ -130,6 +158,7 @@ function prune(names = []) {
|
|
|
130
158
|
const removed = []
|
|
131
159
|
const refused = []
|
|
132
160
|
const missing = []
|
|
161
|
+
const needForce = []
|
|
133
162
|
|
|
134
163
|
const targets = names.length ? names : [...byName.keys()]
|
|
135
164
|
|
|
@@ -139,23 +168,35 @@ function prune(names = []) {
|
|
|
139
168
|
missing.push(name)
|
|
140
169
|
continue
|
|
141
170
|
}
|
|
142
|
-
// live 一律不删,即使显式点名:take 还在跑,它会立刻把游标写回来 --
|
|
143
|
-
// 删了是白删,还会让人以为清理没生效
|
|
144
171
|
const live = rows.find((r) => r.state === 'live')
|
|
145
172
|
if (live) {
|
|
146
|
-
|
|
173
|
+
// 显式点名时必须解释为什么没删(用户明确要求了);
|
|
174
|
+
// 而裸 prune 的意图是「清掉已经没用的」-- 在跑的游标本就不在这个视野里,
|
|
175
|
+
// 报告它是噪音(实测:3 个 take 在跑时,一次日常清理吐 3 段无关提示)
|
|
176
|
+
if (names.length) refused.push({ name, pid: live.pid })
|
|
147
177
|
continue
|
|
148
178
|
}
|
|
149
179
|
// 显式点名 = 清该身份的全部 app(用户明确不盯了);
|
|
150
|
-
// 裸 prune =
|
|
151
|
-
const victims = names.length ? rows : rows.filter((r) => r.state === '
|
|
180
|
+
// 裸 prune = 只清已消费完的,有积压的留着
|
|
181
|
+
const victims = names.length ? rows : rows.filter((r) => r.state === 'drained')
|
|
182
|
+
let deletedAny = false
|
|
152
183
|
for (const r of victims) {
|
|
153
|
-
|
|
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
|
+
}
|
|
191
|
+
if (removeCursorApp(name, r.app)) {
|
|
192
|
+
removed.push(`${name}/${r.app}`)
|
|
193
|
+
deletedAny = true
|
|
194
|
+
}
|
|
154
195
|
}
|
|
155
|
-
if (
|
|
196
|
+
if (deletedAny) removeStaleLock(name)
|
|
156
197
|
}
|
|
157
198
|
|
|
158
|
-
return { removed, refused, missing }
|
|
199
|
+
return { removed, refused, missing, needForce }
|
|
159
200
|
}
|
|
160
201
|
|
|
161
202
|
module.exports = {
|
|
@@ -163,6 +204,6 @@ module.exports = {
|
|
|
163
204
|
prune,
|
|
164
205
|
removeCursorApp,
|
|
165
206
|
cursorDay,
|
|
166
|
-
|
|
207
|
+
isPreWindowDay,
|
|
167
208
|
oldestScannedDay,
|
|
168
209
|
}
|
package/lib/help.js
CHANGED
|
@@ -49,8 +49,8 @@ function takeHelp(apps) {
|
|
|
49
49
|
· 一直等不到消息 -> 九成是 bot 不在群,回第二步验(最常见故障)
|
|
50
50
|
· 多个消费者盯同一 app 用不同 --name,游标互不干扰
|
|
51
51
|
· 不盯了就清游标:lark-relay cursors --prune
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
引擎不主动删还有积压的游标(它是跨会话接着盯的凭据)。留着会在 status 里
|
|
53
|
+
一直挂「有 N 条待消费」,而 store 只保留几天 -- N 会随过期删除而变小,
|
|
54
54
|
看着像追上了,其实是消息没了`
|
|
55
55
|
}
|
|
56
56
|
|
|
@@ -67,6 +67,11 @@ app 列表实时读 \`lark-cli profile list\`,新增 profile 自动纳入,无需
|
|
|
67
67
|
tokenStatus 为 expired 的 profile 自动跳过并 warn(永久失败,重试是死循环)。
|
|
68
68
|
用户重新 login 后下次启动自动纳入。
|
|
69
69
|
|
|
70
|
+
⚠️ **同一台机器只能有一个 collect**,第二个会拒绝启动并报出持有者 pid。
|
|
71
|
+
不是洁癖:同一 app 服务端只放行一个 event bus,两个 collect 会互相挤掉并按
|
|
72
|
+
1s->2s->...->60s 退避重连,实测约 5 分钟才稳定 -- 那段时间的消息永久丢失。
|
|
73
|
+
所以下面那条「前台跑(调试)」在常驻服务已在跑时会直接退出,不会抢它。
|
|
74
|
+
|
|
70
75
|
为什么必须常驻:lark 事件是流式的,进程不在的时刻消息永久丢失(实测:消息发出
|
|
71
76
|
8s 后才起 consumer,收到 0 条)。collect 独立常驻,才能让 take 侧
|
|
72
77
|
崩了、AI 跑 30 分钟、会话关几小时,都不丢消息。
|
|
@@ -81,8 +86,9 @@ tokenStatus 为 expired 的 profile 自动跳过并 warn(永久失败,重试是
|
|
|
81
86
|
|
|
82
87
|
参数
|
|
83
88
|
--exclude <apps> 排除指定 profile(逗号分隔)
|
|
84
|
-
--retain N store 保留天数(默认 3
|
|
85
|
-
|
|
89
|
+
--retain N store 保留天数(默认 3,**必须等于**消费侧扫描窗口;
|
|
90
|
+
留久了「在盘上却扫不到」,短了「空目录被当成还能续接」,
|
|
91
|
+
要改就同时设 LARK_RELAY_SCAN_DAYS)
|
|
86
92
|
|
|
87
93
|
环境变量
|
|
88
94
|
LARK_RELAY_LOG_MAX_MB 单个日志文件上限(默认 16),超了轮转
|
|
@@ -105,36 +111,45 @@ const STATUS_HELP = `一眼看清全局:谁在跑、积压多少、游标在哪
|
|
|
105
111
|
lark-relay status # 概览
|
|
106
112
|
lark-relay status --json # 机器可读
|
|
107
113
|
|
|
108
|
-
|
|
109
|
-
● 在跑
|
|
110
|
-
○
|
|
111
|
-
⚠
|
|
114
|
+
消费者状态:
|
|
115
|
+
● 在跑 有 take 持锁,pid 是它
|
|
116
|
+
○ 已消费完 没人在跑且没有待消费 -- 不盯了就清掉
|
|
117
|
+
⚠ 有 N 条待消费 没人在跑但还有积压 -- 重挂一个 take 就能取走
|
|
118
|
+
|
|
119
|
+
游标位置早于扫描窗口时会附一句「<日期> 之前的已被 gc 回收」:
|
|
120
|
+
那是既成损失(store 只保留几天),但**不影响窗口内还能取到的部分**。
|
|
112
121
|
|
|
113
|
-
排障入口:「○
|
|
122
|
+
排障入口:「○ 已消费完」而你以为它在盯 -> 它的 take 没起来或早退了;
|
|
114
123
|
「无消费者」= 白采,可考虑 --exclude。
|
|
115
|
-
|
|
124
|
+
清理已消费完的游标:lark-relay cursors --prune
|
|
116
125
|
collect 行不是 running -> tail -f ~/.lark-relay/logs/collect.err.log
|
|
117
126
|
(launchd 不进统一日志),collect 停摆的每一秒都在丢消息。`
|
|
118
127
|
|
|
119
|
-
const CURSORS_HELP =
|
|
128
|
+
const CURSORS_HELP = `游标治理:看清哪些还在值守,清掉已经没用的。
|
|
120
129
|
|
|
121
130
|
lark-relay cursors # 列出全部游标及状态
|
|
122
|
-
lark-relay cursors --prune #
|
|
123
|
-
lark-relay cursors --prune <name>... # 清指定身份的全部 app
|
|
131
|
+
lark-relay cursors --prune # 清掉全部「已消费完」的
|
|
132
|
+
lark-relay cursors --prune <name>... # 清指定身份的全部 app
|
|
133
|
+
lark-relay cursors --prune <name> --force # 连「有积压」的一起清
|
|
134
|
+
|
|
135
|
+
状态:● 在跑 / ○ 已消费完 / ⚠ 有 N 条待消费但没人在跑
|
|
124
136
|
|
|
125
|
-
|
|
137
|
+
**判据只有一条:按这个游标续接还剩几条没消费。**
|
|
138
|
+
● 在跑 不删。take 会立刻把游标写回来
|
|
139
|
+
○ 已消费完 直接清。剩 0 条,删掉无损
|
|
140
|
+
⚠ 有 N 条待消费 要 --force。删了那 N 条就跳过了;想要就重挂 take 取走
|
|
141
|
+
|
|
142
|
+
⚠️ 游标位置早于扫描窗口**不是**可删的理由(2026-09-06 实测纠正):
|
|
143
|
+
那只说明「更早的一段已被 gc 回收」,而窗口内还在的事件老游标照样全部放行 --
|
|
144
|
+
实测停在 D-5 的游标能取到窗口内 3 条,而 --since now 取到 0 条。
|
|
145
|
+
按日期删游标或重置它,会静默丢掉这些消息。
|
|
126
146
|
|
|
127
147
|
删的单位是 **app**,不是身份:一个 --name 可以同时盯多个 app,
|
|
128
|
-
裸 --prune
|
|
148
|
+
裸 --prune 只清其中已消费完的那些(在跑的静默跳过)。
|
|
129
149
|
连带删该 app 的 dedup 记录(<app>.seen.json),不留会让重挂后头几条被误判重复。
|
|
130
150
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
删它不会丢任何还取得到的消息,留着只会让 status 虚报积压。
|
|
134
|
-
反过来,窗口内的「○ 暂停」不自动删 -- 那是「关了会话、过阵子接着盯」的续接凭据。
|
|
135
|
-
|
|
136
|
-
退出码:0 正常(含「跳过了在跑的那个」),2 参数错或点名的身份不存在,
|
|
137
|
-
3 点名的目标全在跑、一个都没清掉`
|
|
151
|
+
退出码:0 正常,2 参数错或点名的身份不存在,
|
|
152
|
+
3 点名的目标一个都没清掉(在跑,或需要 --force)`
|
|
138
153
|
|
|
139
154
|
const GUIDE = `lark-relay -- Lark 事件中继站。两件事:收下来 / 我来取。
|
|
140
155
|
|
|
@@ -158,11 +173,11 @@ take 四步(照做)
|
|
|
158
173
|
一批处理完再起一个。不要自己写 while 循环 -- 进程退出会通知你。
|
|
159
174
|
退出码:0=有一批,4=超时没消息(正常,再起一个),2=参数错,3=游标被占用。
|
|
160
175
|
4. 不盯了收尾 lark-relay cursors --prune
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
176
|
+
游标为「关了会话、过阵子接着盯」而存在,引擎不主动删还有积压的游标。
|
|
177
|
+
裸 --prune 只清「已消费完」的(删掉无损);还有待消费的会拦下来告诉你
|
|
178
|
+
会丢几条,确认不要才 --prune <name> --force。
|
|
179
|
+
不清的代价:status 里一直挂着积压数(它会随 store 过期变小,
|
|
164
180
|
看着像追上了,其实是消息永久没了)。
|
|
165
|
-
显式清掉某个身份:lark-relay cursors --prune <name>
|
|
166
181
|
|
|
167
182
|
常见错误
|
|
168
183
|
· 拿不到消息 -> 九成是 bot 不在群。验 bot 必须 --as user,
|
package/lib/larkcli.js
CHANGED
|
@@ -23,32 +23,15 @@ function run(args, opts = {}) {
|
|
|
23
23
|
})
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
// lark-cli 的 stdout 可能有前置非 JSON 行(如「installed successfully」自更新提示),
|
|
27
|
-
// 剥到第一个 { 起。这是 lark-cli 的输出契约,故与 run 同处一地。
|
|
28
|
-
//
|
|
29
|
-
// ⚠️ **只适用于对象响应**:数组响应会被切坏 ——
|
|
30
|
-
// stripToJson('[{"a":1}]') → '{"a":1}]' → JSON.parse 抛错。
|
|
31
|
-
// listProfiles 解析的正是数组,所以它不能用这个(它直接 JSON.parse)
|
|
32
|
-
function stripToJson(s) {
|
|
33
|
-
const i = String(s).indexOf('{')
|
|
34
|
-
return i === -1 ? '{}' : String(s).slice(i)
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// execFile 失败时错误信息散在三个字段里,取第一个有内容的
|
|
38
|
-
function errText(err) {
|
|
39
|
-
return String(err.stdout || err.stderr || err.message || err)
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// 压成单行并截断,给 stderr 日志用。行首那段才是错误码/原因所在
|
|
43
|
-
function tail(s, n = 300) {
|
|
44
|
-
return String(s).replace(/\n/g, ' ').slice(0, n)
|
|
45
|
-
}
|
|
46
|
-
|
|
47
26
|
// profile list 默认输出 JSON,无需 --json。
|
|
48
27
|
// 两类不可用,都要跳过,否则 consume 一直失败重试(死循环):
|
|
49
28
|
// - tokenStatus 为 expired:授权过期,永久失败直到用户重新 login
|
|
50
29
|
// - 没有 user 字段:该 profile 从未 auth 过(实测未登录的 profile 只有
|
|
51
30
|
// name/appId/brand/active 四个字段,无 user 也无 tokenStatus)
|
|
31
|
+
//
|
|
32
|
+
// 注:2026-09-06 删掉了 stripToJson / errText / tail 三个导出 —— 零调用方
|
|
33
|
+
// (含测试)。stripToJson 还带着 6 行「只适用于对象响应」的警告,
|
|
34
|
+
// 等于为没人调用的函数维护陷阱说明。要用再从 git 历史取
|
|
52
35
|
async function listProfiles() {
|
|
53
36
|
const { stdout } = await run(['profile', 'list'])
|
|
54
37
|
const arr = JSON.parse(stdout)
|
|
@@ -78,4 +61,4 @@ function spawnConsume(profile, eventKey, extraArgs = []) {
|
|
|
78
61
|
return spawn(CLI, args, { stdio: ['pipe', 'pipe', 'pipe'] })
|
|
79
62
|
}
|
|
80
63
|
|
|
81
|
-
module.exports = { CLI, run,
|
|
64
|
+
module.exports = { CLI, run, listProfiles, spawnConsume }
|
package/lib/render.js
CHANGED
|
Binary file
|
package/lib/status.js
CHANGED
|
@@ -50,19 +50,13 @@ function collectHint() {
|
|
|
50
50
|
return `sudo launchctl bootstrap system /Library/LaunchDaemons/${COLLECT_LABEL}.plist`
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
// 消费者 = cursors/
|
|
54
|
-
//
|
|
53
|
+
// 消费者 = cursors/ 下的目录。枚举与状态判定都在 lib/cursors.js,这里只做展示 ——
|
|
54
|
+
// 「哪些游标存在、谁还活着、还剩多少没消费」是治理逻辑,
|
|
55
|
+
// status 和 cursors 子命令必须用同一份口径
|
|
55
56
|
function listConsumers() {
|
|
56
57
|
return cursors.listCursors()
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
// 落后多少条:游标之后还剩几个事件没消费
|
|
60
|
-
function lagOf(app, cursor) {
|
|
61
|
-
const all = store.scanWindow(app)
|
|
62
|
-
if (!cursor) return all.length
|
|
63
|
-
return all.filter((e) => store.afterCursor(e, cursor)).length
|
|
64
|
-
}
|
|
65
|
-
|
|
66
60
|
async function buildStatus() {
|
|
67
61
|
const { state, pid } = serviceState()
|
|
68
62
|
|
|
@@ -90,10 +84,19 @@ async function buildStatus() {
|
|
|
90
84
|
latestMs = Number(ev?.timestamp || ev?.create_time) || null
|
|
91
85
|
}
|
|
92
86
|
const cs = (byApp.get(app) || []).map((c) => {
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
87
|
+
// lag 直接用 listCursors 实测出来的 pending —— 它就是「按这个游标续接还剩几条」。
|
|
88
|
+
// ⚠️ 早先这里对 stale 游标刻意不算 lag,理由是「那个数字误导」。
|
|
89
|
+
// 实际反了:pending 一直是真实可取条数,误导的是当时那个 stale 判据本身
|
|
90
|
+
// (它以为老游标取不到东西,而老游标恰好全部放行)。现在只有一个口径
|
|
91
|
+
return {
|
|
92
|
+
name: c.name,
|
|
93
|
+
lag: c.pending,
|
|
94
|
+
state: c.state,
|
|
95
|
+
pid: c.pid,
|
|
96
|
+
cursor: c.cursor,
|
|
97
|
+
day: c.day,
|
|
98
|
+
lostSpan: c.lostSpan,
|
|
99
|
+
}
|
|
97
100
|
})
|
|
98
101
|
rows.push({ app, today: store.countToday(app), latestMs, consumers: cs })
|
|
99
102
|
}
|
|
@@ -127,9 +130,9 @@ function formatStatus(s) {
|
|
|
127
130
|
}
|
|
128
131
|
const w = (str, n) => String(str) + ' '.repeat(Math.max(1, n - dispWidth(str)))
|
|
129
132
|
out.push(`${w('app', 8)}${w('今日事件', 10)}${w('最新事件', 13)}消费者`)
|
|
130
|
-
let
|
|
133
|
+
let drainedCount = 0
|
|
131
134
|
for (const r of s.apps) {
|
|
132
|
-
//
|
|
135
|
+
// 消费者各占一行:状态要一眼分得清,挤在一行里 ●/○/⚠ 会被淹掉。
|
|
133
136
|
// 「谁在跑」是排障第一问,以前得靠猜(全都长成 `name(落后 N 条)`)
|
|
134
137
|
if (!r.consumers.length) {
|
|
135
138
|
out.push(`${w(r.app, 8)}${w(r.today, 10)}${w(ago(r.latestMs), 13)}—(无消费者)`)
|
|
@@ -137,27 +140,28 @@ function formatStatus(s) {
|
|
|
137
140
|
}
|
|
138
141
|
out.push(`${w(r.app, 8)}${w(r.today, 10)}${w(ago(r.latestMs), 13)}`.trimEnd())
|
|
139
142
|
for (const c of r.consumers) {
|
|
140
|
-
const lagTxt = c.lag === 0 ? '游标追平' : `落后 ${c.lag} 条`
|
|
141
143
|
let mark
|
|
142
144
|
let note
|
|
143
145
|
if (c.state === 'live') {
|
|
144
146
|
mark = '●'
|
|
145
|
-
note = `在跑 (pid ${c.pid}, ${
|
|
146
|
-
} else if (c.state === '
|
|
147
|
-
|
|
148
|
-
mark = '⚠'
|
|
149
|
-
// 不给 lag:它已经不代表「还能取到多少」。给位置和原因才可行动
|
|
150
|
-
note = `stale (游标停在 ${c.day || '?'},已超 ${cursors.oldestScannedDay()} 的扫描窗口,续接取不到东西)`
|
|
151
|
-
} else {
|
|
147
|
+
note = `在跑 (pid ${c.pid}, ${c.lag === 0 ? '游标追平' : `落后 ${c.lag} 条`})`
|
|
148
|
+
} else if (c.state === 'drained') {
|
|
149
|
+
drainedCount++
|
|
152
150
|
mark = '○'
|
|
153
|
-
note =
|
|
151
|
+
note = '已消费完 (没人在跑,删掉无损)'
|
|
152
|
+
} else {
|
|
153
|
+
mark = '⚠'
|
|
154
|
+
note = `有 ${c.lag} 条待消费但没人在跑 —— 重挂 take 能接着取`
|
|
154
155
|
}
|
|
156
|
+
// 位置早于扫描窗口:那之前的一段已被 gc 回收、找不回。
|
|
157
|
+
// 与「能不能删」无关(判据是上面的待消费数),但它是真实损失,必须说
|
|
158
|
+
if (c.lostSpan) note += `;${c.day} 之前的已被 gc 回收`
|
|
155
159
|
out.push(` ${w(c.name, 24)}${mark} ${note}`)
|
|
156
160
|
}
|
|
157
161
|
}
|
|
158
|
-
if (
|
|
162
|
+
if (drainedCount) {
|
|
159
163
|
out.push('')
|
|
160
|
-
out.push(
|
|
164
|
+
out.push(`○ ${drainedCount} 个游标已消费完且没人在跑 —— 不盯了就清掉,留着只是噪音`)
|
|
161
165
|
out.push(' 清理:lark-relay cursors --prune')
|
|
162
166
|
}
|
|
163
167
|
|
package/lib/store.js
CHANGED
|
@@ -7,11 +7,17 @@ const fs = require('fs')
|
|
|
7
7
|
const path = require('path')
|
|
8
8
|
const { paths, ensureDir, eventFilename, dayKey, dayKeyOffset } = require('./paths')
|
|
9
9
|
|
|
10
|
-
// scan
|
|
11
|
-
//
|
|
12
|
-
// ⚠️
|
|
10
|
+
// scan 窗口天数。只扫窗口内是为了避免全目录排序(旧架构每轮全扫 4467 个文件)。
|
|
11
|
+
//
|
|
12
|
+
// ⚠️ 必须**等于** store 保留天数,不是「>= 就行」。两边差 1 会造出一个三不管的日子:
|
|
13
|
+
// 2026-09-06 实测 SCAN_DAYS=4 / retain=3 时,D-3 被 gcStore 整目录删掉了,
|
|
14
|
+
// 却仍被算作「窗口内」-> 停在那天的游标判成「可续接」、prune 还要 --force 才肯删,
|
|
15
|
+
// 而那天的数据其实一条都不在了。方向反过来(窗口 < 保留)则是「留着却扫不到」。
|
|
16
|
+
// 两个方向都错,所以判据不是不等式而是等号 —— 默认值就按等号配。
|
|
17
|
+
//
|
|
18
|
+
// 与 collect 的 --retain 是两个进程里的两个值,没法在运行时互相读取,
|
|
13
19
|
// 故由 collect 启动时校验(见 bin/lark-relay-collect.js),这里导出给它用
|
|
14
|
-
const SCAN_DAYS = Number(process.env.LARK_RELAY_SCAN_DAYS ||
|
|
20
|
+
const SCAN_DAYS = Number(process.env.LARK_RELAY_SCAN_DAYS || 3)
|
|
15
21
|
|
|
16
22
|
// 原子落盘:同目录 写 .tmp → rename。
|
|
17
23
|
// 这是 partial write 的正解 —— lark-cli --output-dir 先建 0 字节再填充,
|