lark-relay 0.4.15 → 0.5.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/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
- 代价是不再用的游标会一直留在 `status` 里显示「有 N 条待消费」,而 store 只保留几天,
86
- 那个 N 会随过期删除而变小(**看着像追上了,其实是消息没了**)。
87
-
88
- 一次性监听、验完的排查、不再值守的任务,收尾时:
83
+ **游标会自动回收**:空闲超过 2 天的游标由 collect 删掉(每小时自查一次,
84
+ store 的日期目录回收同一个 gc)。判据是「多久没人用过它」——
85
+ 不需要你记得收尾,也不需要你判断该不该删。
89
86
 
90
87
  ```bash
91
- lark-relay cursors # ● 在跑 / ○ 已消费完 / ⚠ 有积压没人跑
92
- lark-relay cursors --prune # 清「已消费完」的(删掉无损)
88
+ lark-relay cursors # ● 在跑 / ○ 空闲 Nh / ⚠ 空闲超期待回收
89
+ lark-relay cursors --forget <name> # 不等超期,现在就清
93
90
  ```
94
91
 
95
- 判据只有一条:按这个游标续接还剩几条没消费。剩 0 条才无条件可清;
96
- 还有积压的要 `--prune <name> --force` 明确确认(删了就跳过那些消息)。
97
- 别手工 `rm -rf` 游标目录 -- 那会漏掉同名的 `<app>.seen.json`(去重记录),
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 游标治理:看谁还在值守 / --prune 清失效的
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
- // 还打一行「那段消息已被 gc 回收」宽慰你。老游标本身工作得很好,不需要治。
148
+ // 也就是说那个「自愈」每次都在**静默丢掉窗口内的全部积压**。
149
+ // 现在:游标一律原样保留,能取到的照常取。
150
150
  //
151
- // 位置早于窗口确实要说一句,但说的是「D-x D-y 那段已被 gc 回收、找不回」
152
- // (告知既成事实),而不是动用户的游标 —— 剩下能取到的照常取。
151
+ // 不再需要「位置早于窗口」那条告知了 —— 遗弃的游标由 collect 按空闲时长自动回收
152
+ // (见 lib/cursors.js),不靠位置推断,也不靠人清。
153
153
  if (since === 'now') {
154
- const existing = store.readCursor(name, a.app)
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 = ['prune', 'force', 'json', 'help']
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 = Math.round(cursors.IDLE_TTL_MS / 86400000)
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
- // 只读模式:列清单。默认不删任何东西 —— 删数据必须显式 --prune
233
- if (!a.prune) {
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,或已全部清理)\n')
251
+ process.stdout.write('没有游标(还没跑过 take,或已全部回收)\n')
241
252
  return 0
242
253
  }
243
- const mark = { live: '●', drained: '○', backlog: '⚠' }
254
+ const mark = { live: '●', idle: '○', expiring: '⚠' }
244
255
  const note = {
245
256
  live: (r) => `在跑 (pid ${r.pid}, 待消费 ${r.pending})`,
246
- drained: () => '已消费完 -> 删掉无损',
247
- backlog: (r) => `有 ${r.pending} 条待消费但没人在跑 -> 重挂 take 能接着取`,
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
- // 位置早于扫描窗口 = 那之前的一段已被 gc 回收、找不回。
251
- // ⚠️ 这**不是**可删许可(早先的 stale 判据就错在这一步):
252
- // 窗口内还在的事件,老游标照样全部放行。能否删只看待消费数
253
- const lost = r.lostSpan ? `,${r.day} 之前的已被 gc 回收` : ''
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
- `${mark[r.state]} ${r.name} app=${r.app} 位置=${r.day} ${note[r.state](r)}${lost}\n`,
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
- // --prune:位置参数是要删的身份;没给就只清 stale 的那些
272
+ // --forget <name>...:不等 TTL,现在就清
264
273
  const names = a._
265
- const res = cursors.prune(names, { force: !!a.force })
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(`已删 ${n}\n`)
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 ((res.refused.length || res.needForce.length) && !res.removed.length) return 3
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')
@@ -191,22 +192,34 @@ async function runCollect(opts) {
191
192
  const collectors = chosen.map((p) => new AppCollector(p.name, opts))
192
193
  for (const c of collectors) c.start()
193
194
 
194
- // gc 在本进程内每小时自查:回收逻辑就是「删超期日期目录」一句话,
195
- // 另起 gc 命令 + systemd timer 是为一行逻辑加两个部署件。
196
- const gcTimer = setInterval(() => {
195
+ // gc 在本进程内每小时自查:回收逻辑就是「删超期的东西」,
196
+ // 另起 gc 命令 + timer 是为几行逻辑加两个部署件。
197
+ // store 按天删目录,cursors 按空闲时长删 —— 同构,故同处一地
198
+ const runGc = () => {
197
199
  try {
198
200
  const s = store.gcStore(opts.retain)
199
- if (s.length) {
200
- logLine(`gc: store ${s.length} 个日期目录`)
201
+ if (s.length) logLine(`gc: 删 store ${s.length} 个日期目录`)
202
+ } catch (err) {
203
+ logLine(`gc: store 回收失败 ${err.message}`)
204
+ }
205
+ try {
206
+ // 游标回收:空闲超 TTL 的自动删。**这是纪律「不盯了就清游标」的落地方式** --
207
+ // 那条纪律三处成文却零执行,靠人记不住;而判据本身还错过两次
208
+ // (见 lib/cursors.js 文件头)。引擎自己过期,不需要人判断
209
+ const c = cursors.gcCursors()
210
+ if (c.length) {
211
+ logLine(
212
+ `gc: 回收 ${c.length} 个空闲超 ${Math.round(cursors.IDLE_TTL_MS / 86400000)} 天的游标:` +
213
+ `${c.join(' ')}`,
214
+ )
201
215
  }
202
216
  } catch (err) {
203
- logLine(`gc: 失败 ${err.message}`)
217
+ logLine(`gc: 游标回收失败 ${err.message}`)
204
218
  }
205
- }, GC_INTERVAL_MS)
219
+ }
220
+ const gcTimer = setInterval(runGc, GC_INTERVAL_MS)
206
221
  // 启动时先跑一次,别等一小时
207
- try {
208
- store.gcStore(opts.retain)
209
- } catch {}
222
+ runGc()
210
223
 
211
224
  return await new Promise((resolve) => {
212
225
  let shuttingDown = false
package/lib/cursors.js CHANGED
@@ -1,50 +1,47 @@
1
1
  'use strict'
2
2
 
3
- // cursors:游标治理 -- 判定每个游标能不能删,并支持清理。
3
+ // cursors:游标治理 -- 只做两件事:**如实展示**每个游标的状态,
4
+ // 以及**按空闲时长自动回收**(由 collect 的 gc 每小时调一次)。
4
5
  //
5
- // 为什么需要这个模块:纪律「不盯了就删游标」写在 AGENTS.md help 三处,
6
- // 却从落地到 2026-09-06 一次都没被执行 -- 8 个游标全留着,0 个在值守。
7
- // 根因不是人不自觉,是**没有反馈回路**:status 把死游标和活消费者渲染成同一个样子,
8
- // 看不出谁有人在跑。所以这里给出机械判据,让状态可见、清理一键化。
6
+ // ⚠️ 这个模块 2026-09-06 一天内改了三版。前两版都错在同一个地方:
7
+ // **让人拿着一个推断量去做破坏性决定**。记下来免得再走一遍:
9
8
  //
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
- // 「续接取不到东西」正好说反了,照它删就是丢掉窗口内的全部积压。
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
- // 按这个游标续接还能扫到几条(pending)。pending==0 才是真的删掉无损。
21
- // 游标日期早于窗口是另一回事:它说明**那段已被 gc 回收、永久找不回**,
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, dayKeyOffset } = require('./paths')
28
+ const { paths } = require('./paths')
27
29
  const { lockPathFor, holderPid } = require('./lock')
28
30
 
29
- // 扫描窗口最老的一天。比它更早的日期,collect gc 已经整目录删了。
30
- // ⚠️ 这个值只用来**告知「那段找不回了」**,不参与「能否删除」的判定 --
31
- // 那正是上一版把两件事混在一起犯的错
32
- function oldestScannedDay() {
33
- return dayKeyOffset(-(store.SCAN_DAYS - 1))
34
- }
35
-
36
- // 游标值形如 `2026-09-04/<stamp>_<pid>_<seq>.json`,取日期段。
37
- // YYYY-MM-DD 字典序 == 时间序,故可直接字符串比较(store.afterCursor 已在用这个性质)
38
- function cursorDay(key) {
39
- return String(key || '').split('/')[0] || null
40
- }
41
-
42
- // 游标位置是否早于扫描窗口 = 它与当前位置之间有一段已被 gc 回收、永久找不回。
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_MS = (() => {
42
+ const d = Number(process.env.LARK_RELAY_CURSOR_TTL_DAYS)
43
+ return (Number.isFinite(d) && d > 0 ? d : 2) * 86400_000
44
+ })()
48
45
 
49
46
  // 活消费者判定:复用 take 已经在用的锁,不另造注册表。
50
47
  // take 用 `take-<name>` 作锁键(bin/lark-relay.js),锁目录里的 pid 文件就是持有者
@@ -53,15 +50,15 @@ function livePidOf(name) {
53
50
  }
54
51
 
55
52
  /**
56
- * 枚举 cursors/ 下的全部游标,并实测每个的待消费条数。
53
+ * 枚举 cursors/ 下的全部游标。
57
54
  *
58
55
  * 一个 name 目录下可能有多个 app 文件,外加 dedup 的 <app>.seen.json 与落盘用的 .tmp --
59
56
  * 只有「没后缀的那些」才是游标本身,别把 dedup 文件当成一个 app。
60
57
  *
61
- * @returns {Array<{name,app,cursor,day,state,pid,pending,lostSpan}>}
62
- * state: live(有 take 在跑) | backlog(有待消费,删了就丢) | drained(消费完了,删掉无损)
63
- * pending: 按此游标续接还能扫到几条 —— **能否删除的唯一判据**
64
- * lostSpan: 位置早于扫描窗口,中间那段已被 gc 回收、找不回(与能否删除无关,仅告知)
58
+ * @returns {Array<{name,app,cursor,day,state,pid,idleMs,pending}>}
59
+ * state: live(有 take 持锁) | idle(空闲但在 TTL 内) | expiring(超 TTL,下轮 gc 回收)
60
+ * idleMs: 距最后一次被使用多久 —— **回收判据**
61
+ * pending: 按此游标续接还剩几条 —— 仅作展示(它不是回收判据,见文件头 ②)
65
62
  */
66
63
  function listCursors() {
67
64
  let names
@@ -77,6 +74,7 @@ function listCursors() {
77
74
  return scanCache.get(app)
78
75
  }
79
76
 
77
+ const now = Date.now()
80
78
  const out = []
81
79
  for (const name of names.sort()) {
82
80
  const dir = path.join(paths.cursors, name)
@@ -92,12 +90,20 @@ function listCursors() {
92
90
  if (f.startsWith('.') || f.endsWith('.tmp') || f.endsWith('.seen.json')) continue
93
91
  const key = store.readCursor(name, f)
94
92
  if (!key) continue
95
- const day = cursorDay(key)
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) })
93
+ const mtime = store.cursorMtime(name, f)
94
+ const idleMs = mtime === null ? Infinity : Math.max(0, now - mtime)
95
+ // live 优先:take 正在跑就是在用,不看空闲时长
96
+ const state = pid ? 'live' : idleMs >= IDLE_TTL_MS ? 'expiring' : 'idle'
97
+ out.push({
98
+ name,
99
+ app: f,
100
+ cursor: key,
101
+ day: String(key).split('/')[0] || null,
102
+ state,
103
+ pid,
104
+ idleMs,
105
+ pending: scanOf(f).filter((e) => store.afterCursor(e, key)).length,
106
+ })
101
107
  }
102
108
  }
103
109
  return out
@@ -108,9 +114,9 @@ function listCursors() {
108
114
  // 被误判成重复而静默丢掉(TTL 6 小时,见 dedup.js)。
109
115
  //
110
116
  // ⚠️ 粒度必须是 app 而不是整个身份目录:一个 --name 可以同时盯多个 app
111
- // (cursors/<name>/appA、<name>/appB),而状态是**逐 app 判定**的。
112
- // 早先这里 rm -rf 整个 name 目录,于是「appA 已消费完、appB 还有积压」时
113
- // 清 appA 会把 appB 那个**还有积压**的游标一起删掉(实测复现)。
117
+ // (cursors/<name>/appA、<name>/appB),而空闲时长是**逐 app 判定**的。
118
+ // 早先这里 rm -rf 整个 name 目录,于是「appA 空闲很久、appB 昨天还在用」时
119
+ // 清 appA 会把 appB 一起删掉(实测复现)。
114
120
  function removeCursorApp(name, app) {
115
121
  const dir = path.join(paths.cursors, name)
116
122
  let ok = false
@@ -128,7 +134,7 @@ function removeCursorApp(name, app) {
128
134
  }
129
135
 
130
136
  // 顺带清掉该身份的锁目录(若持有者已死)。
131
- // prune 删了游标却留着 take-<name>.lock,会在 run/ 里积累孤儿锁 --
137
+ // 删了游标却留着 take-<name>.lock 会在 run/ 里积累孤儿锁 --
132
138
  // 不致命(下次抢锁会夺走陈旧锁),但既然在清理就一并收干净
133
139
  function removeStaleLock(name) {
134
140
  if (holderPid(`take-${name}`)) return // 还活着,不碰
@@ -138,17 +144,42 @@ function removeStaleLock(name) {
138
144
  }
139
145
 
140
146
  /**
141
- * 清理游标。
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 遇到在跑的游标是静默跳过,不算例外
147
+ * 自动回收:删掉空闲超过 TTL 的游标。**由 collect 的 gc 每小时调一次**,
148
+ * store.gcStore 完全同构 —— 都是「删超期的东西」,不需要人参与。
149
+ *
150
+ * 这是本模块存在的理由。前两版把回收做成「人拿着一个数字敲 --prune」,
151
+ * 结果纪律三处成文、零执行,而判据本身还错了两次。
152
+ * 引擎自己过期不需要人记得,也不需要人判断。
153
+ *
154
+ * ⚠️ 有 take 持锁的一律不动 —— 它正在用,而且它会立刻把游标写回来。
155
+ *
156
+ * @returns {string[]} 被回收的 `name/app`
148
157
  */
149
- function prune(names = [], opts = {}) {
158
+ function gcCursors() {
159
+ const removed = []
160
+ const touchedNames = new Set()
161
+ for (const c of listCursors()) {
162
+ if (c.state !== 'expiring') continue
163
+ if (removeCursorApp(c.name, c.app)) {
164
+ removed.push(`${c.name}/${c.app}`)
165
+ touchedNames.add(c.name)
166
+ }
167
+ }
168
+ for (const n of touchedNames) removeStaleLock(n)
169
+ return removed
170
+ }
171
+
172
+ /**
173
+ * 逃生舱:立刻忘掉指定身份(该身份下全部 app),不等 TTL。
174
+ * 用于「刚验完、现在就想清干净」。
175
+ *
176
+ * 不设 --force 二次确认:回收本来就会自动发生,人只是提前几天,
177
+ * 没有「删了会丢消息」的额外判断负担 —— 那正是前两版的坑。
178
+ *
179
+ * @returns {{removed:string[], refused:Array<{name,pid}>, missing:string[]}}
180
+ */
181
+ function forget(names = []) {
150
182
  const all = listCursors()
151
- // 一个 name 可能有多个 app 行,按身份归并 -- live 判定是按身份的(锁以 name 为键)
152
183
  const byName = new Map()
153
184
  for (const c of all) {
154
185
  if (!byName.has(c.name)) byName.set(c.name, [])
@@ -158,11 +189,8 @@ function prune(names = [], opts = {}) {
158
189
  const removed = []
159
190
  const refused = []
160
191
  const missing = []
161
- const needForce = []
162
192
 
163
- const targets = names.length ? names : [...byName.keys()]
164
-
165
- for (const name of targets) {
193
+ for (const name of names) {
166
194
  const rows = byName.get(name)
167
195
  if (!rows) {
168
196
  missing.push(name)
@@ -170,24 +198,12 @@ function prune(names = [], opts = {}) {
170
198
  }
171
199
  const live = rows.find((r) => r.state === 'live')
172
200
  if (live) {
173
- // 显式点名时必须解释为什么没删(用户明确要求了);
174
- // 而裸 prune 的意图是「清掉已经没用的」-- 在跑的游标本就不在这个视野里,
175
- // 报告它是噪音(实测:3 个 take 在跑时,一次日常清理吐 3 段无关提示)
176
- if (names.length) refused.push({ name, pid: live.pid })
201
+ // 在跑的不删:take 会立刻把游标写回来,删了只是骗自己
202
+ refused.push({ name, pid: live.pid })
177
203
  continue
178
204
  }
179
- // 显式点名 = 清该身份的全部 app(用户明确不盯了);
180
- // 裸 prune = 只清已消费完的,有积压的留着
181
- const victims = names.length ? rows : rows.filter((r) => r.state === 'drained')
182
205
  let deletedAny = false
183
- for (const r of victims) {
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
- }
206
+ for (const r of rows) {
191
207
  if (removeCursorApp(name, r.app)) {
192
208
  removed.push(`${name}/${r.app}`)
193
209
  deletedAny = true
@@ -196,14 +212,13 @@ function prune(names = [], opts = {}) {
196
212
  if (deletedAny) removeStaleLock(name)
197
213
  }
198
214
 
199
- return { removed, refused, missing, needForce }
215
+ return { removed, refused, missing }
200
216
  }
201
217
 
202
218
  module.exports = {
219
+ IDLE_TTL_MS,
203
220
  listCursors,
204
- prune,
221
+ gcCursors,
222
+ forget,
205
223
  removeCursorApp,
206
- cursorDay,
207
- isPreWindowDay,
208
- oldestScannedDay,
209
224
  }
package/lib/help.js CHANGED
@@ -48,10 +48,8 @@ function takeHelp(apps) {
48
48
  最佳实践
49
49
  · 一直等不到消息 -> 九成是 bot 不在群,回第二步验(最常见故障)
50
50
  · 多个消费者盯同一 app 用不同 --name,游标互不干扰
51
- · 不盯了就清游标:lark-relay cursors --prune
52
- 引擎不主动删还有积压的游标(它是跨会话接着盯的凭据)。留着会在 status
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 的事件收下来,原子落盘。零参数、零配置。
@@ -112,44 +110,46 @@ const STATUS_HELP = `一眼看清全局:谁在跑、积压多少、游标在哪
112
110
  lark-relay status --json # 机器可读
113
111
 
114
112
  消费者状态:
115
- ● 在跑 有 take 持锁,pid 是它
116
- 已消费完 没人在跑且没有待消费 -- 不盯了就清掉
117
- N 条待消费 没人在跑但还有积压 -- 重挂一个 take 就能取走
113
+ ● 在跑 有 take 持锁,pid 是它
114
+ 空闲 Nh 没人在跑,但最近用过 —— 重挂 take 接着盯
115
+ 空闲 Nd 超过空闲期,下轮 gc 自动回收(不用手动清)
118
116
 
119
- 游标位置早于扫描窗口时会附一句「<日期> 之前的已被 gc 回收」:
120
- 那是既成损失(store 只保留几天),但**不影响窗口内还能取到的部分**。
117
+ 判据是**空闲多久**,不是落后多少条。落后量只作展示:
118
+ 它是遗弃的症状而不是反面 —— 被遗弃的游标落后会一路涨,
119
+ 拿它当清理判据会把该清的保护起来、该留的删掉(2026-09-06 实测)。
121
120
 
122
- 排障入口:「○ 已消费完」而你以为它在盯 -> 它的 take 没起来或早退了;
121
+ 排障入口:「○ 空闲」而你以为它在盯 -> 它的 take 没起来或早退了;
123
122
  「无消费者」= 白采,可考虑 --exclude。
124
- 清理已消费完的游标:lark-relay cursors --prune
125
123
  collect 行不是 running -> tail -f ~/.lark-relay/logs/collect.err.log
126
124
  (launchd 不进统一日志),collect 停摆的每一秒都在丢消息。`
127
125
 
128
- const CURSORS_HELP = `游标治理:看清哪些还在值守,清掉已经没用的。
126
+ const CURSORS_HELP = `游标状态:谁在值守、空闲多久。**清理是自动的,不用你记。**
129
127
 
130
128
  lark-relay cursors # 列出全部游标及状态
131
- lark-relay cursors --prune # 清掉全部「已消费完」的
132
- lark-relay cursors --prune <name>... # 清指定身份的全部 app
133
- lark-relay cursors --prune <name> --force # 连「有积压」的一起清
129
+ lark-relay cursors --json # 机器可读
130
+ lark-relay cursors --forget <name>... # 不等超期,现在就清掉
134
131
 
135
- 状态:● 在跑 / ○ 已消费完 / ⚠ N 条待消费但没人在跑
132
+ 状态:● 在跑 / ○ 空闲(最近用过)/ ⚠ 空闲超期(下轮 gc 回收)
136
133
 
137
- **判据只有一条:按这个游标续接还剩几条没消费。**
138
- 在跑 不删。take 会立刻把游标写回来
139
- 已消费完 直接清。剩 0 条,删掉无损
140
- ⚠ 有 N 条待消费 要 --force。删了那 N 条就跳过了;想要就重挂 take 取走
134
+ **空闲超过 2 天的游标由 collect 自动回收**(每小时自查一次,与 store 的
135
+ 日期目录回收同一个 gc)。判据是「多久没人用过它」-- 与遗弃程度同向单调,
136
+ 且不需要人做判断。默认 2 天:take 是实时场景的取用口,
137
+ 空闲一两天以上再重挂,下一次消费一定从新的开始;要历史消息有专门的接口。
138
+ 改期限:LARK_RELAY_CURSOR_TTL_DAYS=<天>
141
139
 
142
- ⚠️ 游标位置早于扫描窗口**不是**可删的理由(2026-09-06 实测纠正):
143
- 那只说明「更早的一段已被 gc 回收」,而窗口内还在的事件老游标照样全部放行 --
144
- 实测停在 D-5 的游标能取到窗口内 3 条,而 --since now 取到 0 条。
145
- 按日期删游标或重置它,会静默丢掉这些消息。
140
+ ⚠️ 判据**不是**「落后多少条」,也不是「游标停在哪天」 -- 这两个都试过、都错了
141
+ (方向反的,见下)。落后量只作展示。
146
142
 
147
- 删的单位是 **app**,不是身份:一个 --name 可以同时盯多个 app,
148
- --prune 只清其中已消费完的那些(在跑的静默跳过)。
149
- 连带删该 app 的 dedup 记录(<app>.seen.json),不留会让重挂后头几条被误判重复。
143
+ 在跑的一律不回收 —— take 会立刻把游标写回来
144
+ 被回收后重挂 = 按 --since now 起头,跳过那段已落盘的消息。
145
+ 这正是「两天没人管」该有的行为;真要接着取,在超期前重挂就行
150
146
 
151
- 退出码:0 正常,2 参数错或点名的身份不存在,
152
- 3 点名的目标一个都没清掉(在跑,或需要 --force)`
147
+ --forget 是逃生舱(验完了、现在就想清干净)。它不需要二次确认 --
148
+ 回收本来会自动发生,人只是提前几天。
149
+ 删的单位是 app 目录下的每个 app,连带删该 app 的 dedup 记录
150
+ (<app>.seen.json),不留会让重挂后头几条被误判重复。
151
+
152
+ 退出码:0 正常,2 参数错或点名的身份不存在,3 点名的在跑(没清掉)`
153
153
 
154
154
  const GUIDE = `lark-relay -- Lark 事件中继站。两件事:收下来 / 我来取。
155
155
 
@@ -172,12 +172,10 @@ take 四步(照做)
172
172
  起来后 stderr 有一行「监听中」-- 没有就是没起来,看 stderr。
173
173
  一批处理完再起一个。不要自己写 while 循环 -- 进程退出会通知你。
174
174
  退出码:0=有一批,4=超时没消息(正常,再起一个),2=参数错,3=游标被占用。
175
- 4. 不盯了收尾 lark-relay cursors --prune
176
- 游标为「关了会话、过阵子接着盯」而存在,引擎不主动删还有积压的游标。
177
- --prune 只清「已消费完」的(删掉无损);还有待消费的会拦下来告诉你
178
- 会丢几条,确认不要才 --prune <name> --force。
179
- 不清的代价:status 里一直挂着积压数(它会随 store 过期变小,
180
- 看着像追上了,其实是消息永久没了)。
175
+ 4. 不盯了就走开 什么都不用做
176
+ 空闲超 2 天的游标由 collect 自动回收(每小时自查),不用你记得清理。
177
+ 想立刻清干净:lark-relay cursors --forget <name>
178
+ 看状态:lark-relay cursors(○ 空闲 / ⚠ 超期待回收 / ● 在跑)
181
179
 
182
180
  常见错误
183
181
  · 拿不到消息 -> 九成是 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 直接用 listCursors 实测出来的 pending —— 它就是「按这个游标续接还剩几条」。
88
- // ⚠️ 早先这里对 stale 游标刻意不算 lag,理由是「那个数字误导」。
89
- // 实际反了:pending 一直是真实可取条数,误导的是当时那个 stale 判据本身
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
- lostSpan: c.lostSpan,
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 drainedCount = 0
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 === 'drained') {
149
- drainedCount++
150
- mark = '○'
151
- note = '已消费完 (没人在跑,删掉无损)'
152
- } else {
156
+ } else if (c.state === 'expiring') {
157
+ expiringCount++
153
158
  mark = '⚠'
154
- note = `有 ${c.lag} 条待消费但没人在跑 —— 重挂 take 能接着取`
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 (drainedCount) {
167
+ if (expiringCount) {
163
168
  out.push('')
164
- out.push(`○ ${drainedCount} 个游标已消费完且没人在跑 —— 不盯了就清掉,留着只是噪音`)
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
- // 却仍被算作「窗口内」-> 停在那天的游标判成「可续接」、prune 还要 --force 才肯删,
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lark-relay",
3
- "version": "0.4.15",
3
+ "version": "0.5.0",
4
4
  "description": "Lark event relay: collect events to disk, take a batch when you need it.",
5
5
  "keywords": [
6
6
  "lark",