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 +14 -12
- package/bin/lark-relay.js +54 -55
- package/lib/collect.js +23 -10
- package/lib/cursors.js +100 -85
- package/lib/help.js +33 -35
- 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 = 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
|
-
//
|
|
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')
|
|
@@ -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 命令 +
|
|
196
|
-
|
|
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
|
-
|
|
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:
|
|
217
|
+
logLine(`gc: 游标回收失败 ${err.message}`)
|
|
204
218
|
}
|
|
205
|
-
}
|
|
219
|
+
}
|
|
220
|
+
const gcTimer = setInterval(runGc, GC_INTERVAL_MS)
|
|
206
221
|
// 启动时先跑一次,别等一小时
|
|
207
|
-
|
|
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
|
-
//
|
|
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_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
|
|
62
|
-
* state: live(有 take
|
|
63
|
-
*
|
|
64
|
-
*
|
|
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
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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)
|
|
112
|
-
// 早先这里 rm -rf 整个 name 目录,于是「appA
|
|
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
|
-
//
|
|
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
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
215
|
+
return { removed, refused, missing }
|
|
200
216
|
}
|
|
201
217
|
|
|
202
218
|
module.exports = {
|
|
219
|
+
IDLE_TTL_MS,
|
|
203
220
|
listCursors,
|
|
204
|
-
|
|
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
|
-
·
|
|
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 的事件收下来,原子落盘。零参数、零配置。
|
|
@@ -112,44 +110,46 @@ const STATUS_HELP = `一眼看清全局:谁在跑、积压多少、游标在哪
|
|
|
112
110
|
lark-relay status --json # 机器可读
|
|
113
111
|
|
|
114
112
|
消费者状态:
|
|
115
|
-
● 在跑
|
|
116
|
-
○
|
|
117
|
-
⚠
|
|
113
|
+
● 在跑 有 take 持锁,pid 是它
|
|
114
|
+
○ 空闲 Nh 没人在跑,但最近用过 —— 重挂 take 接着盯
|
|
115
|
+
⚠ 空闲 Nd 超过空闲期,下轮 gc 自动回收(不用手动清)
|
|
118
116
|
|
|
119
|
-
|
|
120
|
-
|
|
117
|
+
判据是**空闲多久**,不是落后多少条。落后量只作展示:
|
|
118
|
+
它是遗弃的症状而不是反面 —— 被遗弃的游标落后会一路涨,
|
|
119
|
+
拿它当清理判据会把该清的保护起来、该留的删掉(2026-09-06 实测)。
|
|
121
120
|
|
|
122
|
-
排障入口:「○
|
|
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 --
|
|
132
|
-
lark-relay cursors --
|
|
133
|
-
lark-relay cursors --prune <name> --force # 连「有积压」的一起清
|
|
129
|
+
lark-relay cursors --json # 机器可读
|
|
130
|
+
lark-relay cursors --forget <name>... # 不等超期,现在就清掉
|
|
134
131
|
|
|
135
|
-
状态:● 在跑 / ○
|
|
132
|
+
状态:● 在跑 / ○ 空闲(最近用过)/ ⚠ 空闲超期(下轮 gc 回收)
|
|
136
133
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
134
|
+
**空闲超过 2 天的游标由 collect 自动回收**(每小时自查一次,与 store 的
|
|
135
|
+
日期目录回收同一个 gc)。判据是「多久没人用过它」-- 与遗弃程度同向单调,
|
|
136
|
+
且不需要人做判断。默认 2 天:take 是实时场景的取用口,
|
|
137
|
+
空闲一两天以上再重挂,下一次消费一定从新的开始;要历史消息有专门的接口。
|
|
138
|
+
改期限:LARK_RELAY_CURSOR_TTL_DAYS=<天>
|
|
141
139
|
|
|
142
|
-
⚠️
|
|
143
|
-
|
|
144
|
-
实测停在 D-5 的游标能取到窗口内 3 条,而 --since now 取到 0 条。
|
|
145
|
-
按日期删游标或重置它,会静默丢掉这些消息。
|
|
140
|
+
⚠️ 判据**不是**「落后多少条」,也不是「游标停在哪天」 -- 这两个都试过、都错了
|
|
141
|
+
(方向反的,见下)。落后量只作展示。
|
|
146
142
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
143
|
+
● 在跑的一律不回收 —— take 会立刻把游标写回来
|
|
144
|
+
被回收后重挂 = 按 --since now 起头,跳过那段已落盘的消息。
|
|
145
|
+
这正是「两天没人管」该有的行为;真要接着取,在超期前重挂就行
|
|
150
146
|
|
|
151
|
-
|
|
152
|
-
|
|
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.
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
|
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,
|