lark-relay 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -55,7 +55,6 @@ lark-relay status # 谁在跑 / 各 app 收了多少 / 游标在哪
55
55
  日志直写 stderr,每行带 ISO 时间戳(launchd 场景重定向到
56
56
  `~/.lark-relay/logs/collect.err.log`)。不做轮转 —— 实测约 10MB/年,
57
57
  嫌大就 `> ~/.lark-relay/logs/collect.err.log` 截断(launchd 持有 fd,别 rm)。
58
-
59
58
  ### 常驻部署(macOS / launchd)
60
59
 
61
60
  要点三条,plist 自己写十来行就够:
@@ -84,6 +83,8 @@ lark-relay take --app <app> --chats oc_xxx --render text
84
83
  起来后 stderr 先打一行「监听中」-- 没这行就是没起来,看 stderr 报错。
85
84
  退出码:0=有一批,4=超时没消息(正常,再起一个),2=参数错,3=游标被占用。
86
85
  参数拼错(如 `--chat-id`)会硬失败并给出正确写法,不静默吞掉。
86
+ 枚举参数(`--render text|ndjson`、`--since now|all`)也走白名单,且在**阻塞之前**校验 --
87
+ `--render` 只在吐批时生效,拼错若不当场拦下要等满一批才看得出格式不对。
87
88
 
88
89
  业务过滤只有 `--filter`(jq 表达式)一个口子,不为每个业务加参数。
89
90
 
package/bin/lark-relay.js CHANGED
@@ -110,6 +110,13 @@ async function cmdTake(argv) {
110
110
  process.stderr.write(`--since 只能是 now 或 all,当前:${since}\n`)
111
111
  return 2
112
112
  }
113
+ // --render 同理,且必须在阻塞之前校验:`--render tex` 曾静默退回 NDJSON,
114
+ // 而那要等满一批(最长 12h)才看得出来 —— 到时候批次已经吐成了错的格式
115
+ const mode = a.render === true ? 'text' : a.render || 'ndjson'
116
+ if (mode !== 'text' && mode !== 'ndjson') {
117
+ process.stderr.write(`--render 只能是 text 或 ndjson,当前:${mode}\n`)
118
+ return 2
119
+ }
113
120
  // 没有游标就按 --since now 起头。已有的一律原样保留 ——
114
121
  // 别「自愈」到最新,那会静默丢掉全部积压(见 docs/lessons.md#游标判据)
115
122
  if (since === 'now') {
@@ -164,7 +171,6 @@ async function cmdTake(argv) {
164
171
  return res.reason === 'timeout' ? 4 : 0
165
172
  }
166
173
 
167
- const mode = a.render === true ? 'text' : a.render
168
174
  process.stdout.write(`${render(res.events, mode)}\n`)
169
175
  process.stderr.write(`${res.events.length} 条,游标身份 ${name}\n`)
170
176
  return 0
package/lib/collect.js CHANGED
@@ -17,23 +17,13 @@ const { init: logInit, logLine } = require('./log')
17
17
  const EVENT_KEY = process.env.LARK_RELAY_EVENT_KEY || 'im.message.receive_v1'
18
18
  const GC_INTERVAL_MS = 3600_000
19
19
 
20
- // 游标空闲多久算遗弃。默认 2 天:take 是实时场景的取用口,
21
- // 空闲一两天以上再重挂,下次消费一定从新的开始
22
- const CURSOR_TTL_DAYS = (() => {
23
- const d = Number(process.env.LARK_RELAY_CURSOR_TTL_DAYS)
24
- return Number.isFinite(d) && d > 0 ? d : 2
25
- })()
26
-
27
20
  class AppCollector {
28
- constructor(app, opts) {
21
+ constructor(app) {
29
22
  this.app = app
30
- this.opts = opts
31
23
  this.seq = 0
32
- this.received = 0
33
24
  this.child = null
34
25
  this.stopped = false
35
26
  this.backoffMs = 1000
36
- this.restarts = 0
37
27
  this.healthyTimer = null
38
28
  // 落盘侧去重:同一条消息在盘上只出现一次,消费侧因此完全不必去重。
39
29
  // 种子从当天已落盘的事件读回 —— 重启后 bus 重投时它是**新文件**,游标拦不住
@@ -54,7 +44,6 @@ class AppCollector {
54
44
  this.seen.add(id)
55
45
  }
56
46
  store.writeEvent(this.app, obj, this.seq++)
57
- this.received++
58
47
  return true
59
48
  }
60
49
 
@@ -107,7 +96,6 @@ class AppCollector {
107
96
  restarted = true
108
97
  clearTimeout(this.healthyTimer)
109
98
  this.child = null
110
- this.restarts++
111
99
  logLine(
112
100
  `[${this.app}] consume 退出(${why}),${Math.round(this.backoffMs / 1000)}s 后重启 ` +
113
101
  `—— 这段时间该 app 的消息会永久丢失`,
@@ -203,7 +191,7 @@ async function runCollect(opts) {
203
191
  ` event_key=${EVENT_KEY} store 保留 ${opts.retain}天`,
204
192
  )
205
193
 
206
- const collectors = chosen.map((p) => new AppCollector(p.name, opts))
194
+ const collectors = chosen.map((p) => new AppCollector(p.name))
207
195
  for (const c of collectors) c.start()
208
196
 
209
197
  // gc 在本进程内每小时自查:回收逻辑就是「删超期的东西」,
@@ -219,9 +207,11 @@ async function runCollect(opts) {
219
207
  try {
220
208
  // 游标按空闲时长自动回收 —— 引擎自己过期,不需要人判断。
221
209
  // 判据错过两次,见 docs/lessons.md#游标判据
222
- const c = store.gcCursors(CURSOR_TTL_DAYS)
210
+ const c = store.gcCursors(store.CURSOR_TTL_DAYS)
223
211
  if (c.length) {
224
- logLine(`gc: 回收游标 ${c.length} 个(空闲超 ${CURSOR_TTL_DAYS} 天):${c.join(' ')}`)
212
+ logLine(
213
+ `gc: 回收游标 ${c.length} 个(空闲超 ${store.CURSOR_TTL_DAYS} 天):${c.join(' ')}`,
214
+ )
225
215
  }
226
216
  } catch (err) {
227
217
  logLine(`gc: 游标回收失败 ${err.message}`)
package/lib/help.js CHANGED
@@ -39,7 +39,7 @@ function takeHelp(apps) {
39
39
  --debounce N 防抖秒数(默认 5;聊天场景建议 15)
40
40
  --max-wait N 从第一条起最多攒多久就吐(默认 25 秒)
41
41
  --timeout N 没消息时最多阻塞多久,到点空手退出码 4(默认 12 小时)
42
- --render text 按群分组紧凑文本(默认 NDJSON)
42
+ --render text 按群分组紧凑文本(默认 ndjson;只认 text|ndjson)
43
43
  --since now|all 起始位置(默认 now,不重放历史)
44
44
 
45
45
  退出码
package/lib/lock.js CHANGED
@@ -29,13 +29,11 @@ function acquireExclusive(key) {
29
29
 
30
30
  const holder = readPid(pidFile)
31
31
  if (holder === process.pid) return mkHandle(dir, pidFile, me) // 自己已持有,复用
32
- if (holder === null && attempt === 0) {
33
- // 要么是上面那个微秒级窗口,要么是持有者被 SIGKILL 后留下的空壳。
34
- // 等一拍再读一次:还是空就当陈旧锁清掉
35
- try {
36
- fs.rmSync(dir, { recursive: true, force: true })
37
- } catch {}
38
- continue
32
+ if (holder === null) {
33
+ // 目录在但没 pid:要么是上面那个微秒级窗口(持有者马上就写),要么是它
34
+ // 恰好在窗口内被 SIGKILL 留下的空壳。两者**无法区分**,一律拒绝 ——
35
+ // 拒绝的代价只是「再跑一次」,夺锁的代价是双实例(collect 双实例丢 5 分钟消息)
36
+ return { ok: false, file: dir, holder: null }
39
37
  }
40
38
  if (holder && alive(holder)) return { ok: false, file: dir, holder }
41
39
  // 持有者已死:清掉重试一次
package/lib/status.js CHANGED
@@ -168,7 +168,9 @@ function formatStatus(s) {
168
168
  }
169
169
  }
170
170
  out.push('')
171
- out.push(`游标 ${s.cursorsDir}(空闲超 2 天自动回收;等不及就 rm -rf 它)`)
171
+ out.push(
172
+ `游标 ${s.cursorsDir}(空闲超 ${store.CURSOR_TTL_DAYS} 天自动回收;等不及就 rm -rf 它)`,
173
+ )
172
174
 
173
175
  return out.join('\n')
174
176
  }
package/lib/store.js CHANGED
@@ -122,9 +122,7 @@ function seedIds(app, day = dayKey()) {
122
122
  function readCursor(name, app) {
123
123
  try {
124
124
  const raw = fs.readFileSync(paths.cursor(name, app), 'utf8').trim()
125
- if (!raw) return null
126
- if (raw.startsWith('{')) return JSON.parse(raw).key || null
127
- return raw
125
+ return raw || null
128
126
  } catch {
129
127
  return null
130
128
  }
@@ -212,6 +210,16 @@ function gcStore(retainDays) {
212
210
  return removed
213
211
  }
214
212
 
213
+ // 游标空闲多久算遗弃。默认 2 天:take 是实时场景的取用口,
214
+ // 空闲一两天以上再重挂,下次消费一定从新的开始。
215
+ //
216
+ // 定义在这里(gcCursors 的所在地)而非 collect.js —— status 要拿它渲染提示,
217
+ // 两处各写一个 2 就会漂移:改了环境变量而 status 仍说「2 天」是在撒谎
218
+ const CURSOR_TTL_DAYS = (() => {
219
+ const d = Number(process.env.LARK_RELAY_CURSOR_TTL_DAYS)
220
+ return Number.isFinite(d) && d > 0 ? d : 2
221
+ })()
222
+
215
223
  // gc:删空闲超期的游标。与 gcStore 同构(都是「删超期的东西」),故放一起。
216
224
  //
217
225
  // 判据是**多久没人用过它**(mtime)。「落后多少条」和「停在哪天」都试过、
@@ -257,6 +265,7 @@ function gcCursors(ttlDays) {
257
265
  }
258
266
 
259
267
  module.exports = {
268
+ CURSOR_TTL_DAYS,
260
269
  writeEvent,
261
270
  scanAll,
262
271
  readEvent,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lark-relay",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Lark event relay: collect events to disk, take a batch when you need it.",
5
5
  "keywords": [
6
6
  "lark",