lark-relay 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/lark-relay.js CHANGED
@@ -227,7 +227,7 @@ function cmdCursors(argv) {
227
227
  return 0
228
228
  }
229
229
  const cursors = require('../lib/cursors')
230
- const ttlDays = Math.round(cursors.IDLE_TTL_MS / 86400000)
230
+ const ttlDays = cursors.IDLE_TTL_DAYS
231
231
 
232
232
  // 人话的空闲时长。回收判据就是这个数,所以它必须是最显眼的一列
233
233
  const idleTxt = (ms) => {
package/lib/collect.js CHANGED
@@ -122,14 +122,18 @@ async function runCollect(opts) {
122
122
  // 非 launchd 场景(前台调试)是 no-op,logLine 退化成裸 stderr 直写
123
123
  logInit()
124
124
 
125
- // 互斥:同一台机器只该有一个 collect
125
+ // 互斥:一个 collect 就够。
126
126
  // ⚠️ 这比 take 的锁更要紧,而它长期没有:同一 app 服务端只放行一个 event bus,
127
127
  // 第二个 collect 起来会把线上那个挤掉,双方按 1s->2s->...->60s 退避互抢,
128
128
  // 实测每 app 重启约 10 次、累计约 5 分钟才稳定 —— 那 5 分钟的消息**永久丢失**。
129
129
  // 现实诱因不是手滑:COLLECT_HELP 教「前台跑(调试)」,deploy-launchd.sh 在
130
130
  // bootstrap 失败时更直接建议「应急顶住采集:lark-relay-collect」--
131
131
  // 若 launchd 那个其实还活着,照做就是自己抢自己。
132
- // 拿不到锁就退出并给出判据,不留「看着在跑其实在互抢」的中间态
132
+ //
133
+ // 注:锁在 paths.run 下,即**按 LARK_RELAY_HOME 隔离**,不是全机唯一。
134
+ // 换 HOME 能绕过(测试就靠这个并行),但真实场景下两个进程仍抢同一个
135
+ // 服务端 bus —— 锁只挡住最常见的那条路,挡不住刻意换 HOME。
136
+ // 要全机唯一得用别的原语(如固定路径 flock),现在没有这个需求
133
137
  const lock = acquireExclusive('collect')
134
138
  if (!lock.ok) {
135
139
  logLine(
@@ -203,15 +207,15 @@ async function runCollect(opts) {
203
207
  logLine(`gc: store 回收失败 ${err.message}`)
204
208
  }
205
209
  try {
206
- // 游标回收:空闲超 TTL 的自动删。**这是纪律「不盯了就清游标」的落地方式** --
207
- // 那条纪律三处成文却零执行,靠人记不住;而判据本身还错过两次
208
- // (见 lib/cursors.js 文件头)。引擎自己过期,不需要人判断
210
+ // 游标回收:空闲超 TTL 的自动删,顺带清孤儿 dedup。
211
+ // **这是纪律「不盯了就清游标」的落地方式** -- 那条纪律三处成文却零执行,
212
+ // 靠人记不住;而判据本身还错过两次(见 lib/cursors.js 文件头)
213
+ // 引擎自己过期,不需要人判断
209
214
  const c = cursors.gcCursors()
210
215
  if (c.length) {
211
- logLine(
212
- `gc: 回收 ${c.length} 个空闲超 ${Math.round(cursors.IDLE_TTL_MS / 86400000)} 天的游标:` +
213
- `${c.join(' ')}`,
214
- )
216
+ // 不写「N 个超期游标」-- 返回值里还含孤儿 dedup,笼统数字会虚报。
217
+ // 逐个列出来,日志是排查断线窗口的唯一依据,宁可长一点
218
+ logLine(`gc: 回收游标 ${c.length} 项(空闲超 ${cursors.IDLE_TTL_DAYS} 天):${c.join(' ')}`)
215
219
  }
216
220
  } catch (err) {
217
221
  logLine(`gc: 游标回收失败 ${err.message}`)
package/lib/cursors.js CHANGED
@@ -38,10 +38,11 @@ const { lockPathFor, holderPid } = require('./lock')
38
38
  // 跳过这段已落盘的消息。但 store 只保留 SCAN_DAYS(默认 3)天 --
39
39
  // TTL 2 天时,被回收的游标最多也就跳过盘上还剩的那一天多,
40
40
  // 而那种情况本身就是「两天没人管」。这个代价是可接受的,且是**显式**的
41
- const IDLE_TTL_MS = (() => {
41
+ const IDLE_TTL_DAYS = (() => {
42
42
  const d = Number(process.env.LARK_RELAY_CURSOR_TTL_DAYS)
43
- return (Number.isFinite(d) && d > 0 ? d : 2) * 86400_000
43
+ return Number.isFinite(d) && d > 0 ? d : 2
44
44
  })()
45
+ const IDLE_TTL_MS = IDLE_TTL_DAYS * 86400_000
45
46
 
46
47
  // 活消费者判定:复用 take 已经在用的锁,不另造注册表。
47
48
  // take 用 `take-<name>` 作锁键(bin/lark-relay.js),锁目录里的 pid 文件就是持有者
@@ -55,19 +56,24 @@ function livePidOf(name) {
55
56
  * 一个 name 目录下可能有多个 app 文件,外加 dedup 的 <app>.seen.json 与落盘用的 .tmp --
56
57
  * 只有「没后缀的那些」才是游标本身,别把 dedup 文件当成一个 app。
57
58
  *
59
+ * @param {{withPending?:boolean}} opts withPending=false 跳过 scanWindow(gc 路径用)
58
60
  * @returns {Array<{name,app,cursor,day,state,pid,idleMs,pending}>}
59
61
  * state: live(有 take 持锁) | idle(空闲但在 TTL 内) | expiring(超 TTL,下轮 gc 回收)
60
62
  * idleMs: 距最后一次被使用多久 —— **回收判据**
61
- * pending: 按此游标续接还剩几条 —— 仅作展示(它不是回收判据,见文件头 ②)
63
+ * pending: 按此游标续接还剩几条 —— 仅作展示(它不是回收判据,见文件头 ②);
64
+ * withPending=false 时为 null
62
65
  */
63
- function listCursors() {
66
+ function listCursors(opts = {}) {
67
+ const withPending = opts.withPending !== false
64
68
  let names
65
69
  try {
66
70
  names = fs.readdirSync(paths.cursors).filter((n) => !n.startsWith('.'))
67
71
  } catch {
68
72
  return [] // 还没跑过任何 take
69
73
  }
70
- // 同一 app 可能被多个身份盯着,scanWindow 一次就够 —— 每行都扫一遍是平方级浪费
74
+ // 同一 app 可能被多个身份盯着,scanWindow 一次就够 —— 每行都扫一遍是平方级浪费。
75
+ // gc 路径完全不需要它:回收只看 mtime,而扫描要读+排序全窗口的事件文件
76
+ // (实测真实 store 734 个文件/4ms,每小时白跑一次没道理)
71
77
  const scanCache = new Map()
72
78
  const scanOf = (app) => {
73
79
  if (!scanCache.has(app)) scanCache.set(app, store.scanWindow(app))
@@ -102,7 +108,7 @@ function listCursors() {
102
108
  state,
103
109
  pid,
104
110
  idleMs,
105
- pending: scanOf(f).filter((e) => store.afterCursor(e, key)).length,
111
+ pending: withPending ? scanOf(f).filter((e) => store.afterCursor(e, key)).length : null,
106
112
  })
107
113
  }
108
114
  }
@@ -153,12 +159,14 @@ function removeStaleLock(name) {
153
159
  *
154
160
  * ⚠️ 有 take 持锁的一律不动 —— 它正在用,而且它会立刻把游标写回来。
155
161
  *
156
- * @returns {string[]} 被回收的 `name/app`
162
+ * @returns {string[]} 被回收的 `name/app`,以及清掉的孤儿 dedup(标 `(dedup)`)
157
163
  */
158
164
  function gcCursors() {
159
165
  const removed = []
160
166
  const touchedNames = new Set()
161
- for (const c of listCursors()) {
167
+ // withPending: false —— 回收只看 mtime。算 pending 要读+排序全窗口事件文件
168
+ // (实测真实 store 734 个/4ms),在每小时的 daemon 循环里是纯浪费
169
+ for (const c of listCursors({ withPending: false })) {
162
170
  if (c.state !== 'expiring') continue
163
171
  if (removeCursorApp(c.name, c.app)) {
164
172
  removed.push(`${c.name}/${c.app}`)
@@ -166,6 +174,50 @@ function gcCursors() {
166
174
  }
167
175
  }
168
176
  for (const n of touchedNames) removeStaleLock(n)
177
+ removed.push(...gcOrphanDedup())
178
+ return removed
179
+ }
180
+
181
+ // 孤儿 dedup:`<app>.seen.json` 还在,但对应的游标文件已经不在了。
182
+ //
183
+ // ⚠️ removeCursorApp 声明「连带删 dedup」,但那只覆盖**它自己删的**那些。
184
+ // 实测漏网路径:游标文件被手工删掉 / 旧版本清理留下的残留 / 中途失败 --
185
+ // 此时 listCursors 跳过整个 app(它只认游标文件),dedup 就**永久留着**,
186
+ // 而它是本模块唯一会无限增长的东西(游标 43 字节,seen.json 实测已到 2.3K)。
187
+ //
188
+ // 判据不用 mtime:游标不在了,这个文件按定义就是垃圾 --
189
+ // dedup 记录本身只有 6 小时 TTL(见 dedup.js),留着没有任何用途。
190
+ function gcOrphanDedup() {
191
+ const removed = []
192
+ let names
193
+ try {
194
+ names = fs.readdirSync(paths.cursors).filter((n) => !n.startsWith('.'))
195
+ } catch {
196
+ return removed
197
+ }
198
+ for (const name of names) {
199
+ const dir = path.join(paths.cursors, name)
200
+ let files
201
+ try {
202
+ if (!fs.statSync(dir).isDirectory()) continue
203
+ files = fs.readdirSync(dir)
204
+ } catch {
205
+ continue
206
+ }
207
+ for (const f of files) {
208
+ if (!f.endsWith('.seen.json')) continue
209
+ const app = f.slice(0, -'.seen.json'.length)
210
+ if (files.includes(app)) continue // 游标还在,dedup 有主
211
+ try {
212
+ fs.rmSync(path.join(dir, f), { force: true })
213
+ removed.push(`${name}/${app} (dedup)`)
214
+ } catch {}
215
+ }
216
+ // 只剩空目录就收掉
217
+ try {
218
+ if (!fs.readdirSync(dir).length) fs.rmdirSync(dir)
219
+ } catch {}
220
+ }
169
221
  return removed
170
222
  }
171
223
 
@@ -179,7 +231,7 @@ function gcCursors() {
179
231
  * @returns {{removed:string[], refused:Array<{name,pid}>, missing:string[]}}
180
232
  */
181
233
  function forget(names = []) {
182
- const all = listCursors()
234
+ const all = listCursors({ withPending: false }) // 删不看落后量
183
235
  const byName = new Map()
184
236
  for (const c of all) {
185
237
  if (!byName.has(c.name)) byName.set(c.name, [])
@@ -217,6 +269,7 @@ function forget(names = []) {
217
269
 
218
270
  module.exports = {
219
271
  IDLE_TTL_MS,
272
+ IDLE_TTL_DAYS,
220
273
  listCursors,
221
274
  gcCursors,
222
275
  forget,
package/lib/help.js CHANGED
@@ -65,10 +65,13 @@ app 列表实时读 \`lark-cli profile list\`,新增 profile 自动纳入,无需
65
65
  tokenStatus 为 expired 的 profile 自动跳过并 warn(永久失败,重试是死循环)。
66
66
  用户重新 login 后下次启动自动纳入。
67
67
 
68
- ⚠️ **同一台机器只能有一个 collect**,第二个会拒绝启动并报出持有者 pid
68
+ ⚠️ **一个 collect 就够,第二个会拒绝启动并报出持有者 pid**。
69
69
  不是洁癖:同一 app 服务端只放行一个 event bus,两个 collect 会互相挤掉并按
70
70
  1s->2s->...->60s 退避重连,实测约 5 分钟才稳定 -- 那段时间的消息永久丢失。
71
- 所以下面那条「前台跑(调试)」在常驻服务已在跑时会直接退出,不会抢它。
71
+ 所以上面那条「前台跑(调试)」在常驻服务已在跑时会直接退出,不会抢它。
72
+ 注:互斥锁在 $LARK_RELAY_HOME/run/ 下,即**按 HOME 隔离**。
73
+ 换个 HOME 起第二个能绕过锁,但那样两个进程仍会抢同一个服务端 event bus --
74
+ 别这么用(测试用不同 HOME 并行是安全的,它们不连真实 bus)。
72
75
 
73
76
  为什么必须常驻:lark 事件是流式的,进程不在的时刻消息永久丢失(实测:消息发出
74
77
  8s 后才起 consumer,收到 0 条)。collect 独立常驻,才能让 take 侧
@@ -80,7 +83,7 @@ tokenStatus 为 expired 的 profile 自动跳过并 warn(永久失败,重试是
80
83
  日志行带 ISO 时间戳;超 16MB 自动轮转,旧的在 .1/.2/.3
81
84
 
82
85
  落盘:~/.lark-relay/store/<app>/<YYYY-MM-DD>/<纳秒>_<pid>_<seq>.json
83
- 回收由 collect 进程内每小时自查一次,删超期的整个日期目录(不另起 gc 单元/timer)
86
+ 回收在本进程内每小时自查一次(不另起 gc 单元/timer),细则见下「回收」。
84
87
 
85
88
  参数
86
89
  --exclude <apps> 排除指定 profile(逗号分隔)
@@ -91,6 +94,13 @@ tokenStatus 为 expired 的 profile 自动跳过并 warn(永久失败,重试是
91
94
  环境变量
92
95
  LARK_RELAY_LOG_MAX_MB 单个日志文件上限(默认 16),超了轮转
93
96
  LARK_RELAY_LOG_KEEP 保留几代旧日志(默认 3)
97
+ LARK_RELAY_CURSOR_TTL_DAYS 游标空闲多久算遗弃(默认 2),超了本进程的 gc 回收
98
+
99
+ 回收(每小时自查一次,启动时先跑一次):
100
+ · store:删超过 --retain 天的整个日期目录
101
+ · 游标:删空闲超 TTL 的(见上),顺带清没有主的 dedup 残留。
102
+ 有 take 在跑的一律不动 —— 判据是「多久没人用过它」,不是「落后多少条」
103
+ (后者方向反了,详见 lib/cursors.js 文件头记的三版判据)
94
104
 
95
105
  常驻部署(macOS / launchd)
96
106
  要点只有三条,plist 自己写十来行就够,不必找模板:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lark-relay",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Lark event relay: collect events to disk, take a batch when you need it.",
5
5
  "keywords": [
6
6
  "lark",