lark-relay 0.6.1 → 0.6.2

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/lib/store.js CHANGED
@@ -1,10 +1,6 @@
1
1
  'use strict'
2
2
 
3
- // store:一事件一文件 + 按天分目录 + cursor 共享。
4
- // 多消费者共享 store、各持游标,不做「处理完即删」—— 谁都不能替别人删。
5
- // 回收由 collect 进程整目录删(store 按天、cursors 按空闲时长)。
6
- //
7
- // 踩坑史见 docs/lessons.md
3
+ // 多消费者共享按天存储的事件,各持消费断点;GC 只回收事件目录
8
4
  const fs = require('fs')
9
5
  const path = require('path')
10
6
  const { paths, ensureDir, eventFilename, dayKey, dayKeyOffset } = require('./paths')
@@ -24,7 +20,6 @@ function writeEvent(app, obj, seq) {
24
20
  }
25
21
 
26
22
  // 排序键:stamp -> pid -> seq,数值比较(字典序在位宽变化时会错序)。
27
- // 必须容忍非法文件名 -- 见 docs/lessons.md#杂项文件
28
23
  const NAME_RE = /^(\d+)_(\d+)_(\d+)$/
29
24
 
30
25
  function parseKey(name) {
@@ -63,8 +58,7 @@ function afterCursor(entry, cursor) {
63
58
  return cmpName(entry.name, cName) > 0
64
59
  }
65
60
 
66
- // 扫 app 下盘上还在的全部日期。不设扫描窗口:gc 只保留 retain 天,
67
- // 稳态下「全部日期」就是「retain 天」。见 docs/lessons.md#扫描窗口
61
+ // 扫描盘上保留的全部日期
68
62
  function scanAll(app) {
69
63
  const out = []
70
64
  let days
@@ -100,8 +94,7 @@ function readEvent(entry) {
100
94
  }
101
95
  }
102
96
 
103
- // 落盘侧去重的种子:读回某天已落盘事件的 message_id。
104
- // collect 重启后 bus 重投时它是新文件,游标拦不住。见 docs/lessons.md#去重
97
+ // 重启后从当天事件恢复 message_id 去重集合
105
98
  function seedIds(app, day = dayKey()) {
106
99
  const ids = new Set()
107
100
  let names
@@ -113,7 +106,7 @@ function seedIds(app, day = dayKey()) {
113
106
  for (const name of names) {
114
107
  if (!isEventName(name)) continue
115
108
  const ev = readEvent({ file: path.join(paths.appDay(app, day), name) })
116
- const id = ev && (ev.message_id || ev.id)
109
+ const id = ev?.message_id
117
110
  if (id) ids.add(id)
118
111
  }
119
112
  return ids
@@ -136,27 +129,6 @@ function writeCursor(name, app, key) {
136
129
  fs.renameSync(tmp, file)
137
130
  }
138
131
 
139
- // 标记「刚被用过」。gc 判据是 mtime,而 take 空手退出时不写游标 --
140
- // 不 touch 会把「盯着一个安静的群」误判成遗弃
141
- function touchCursor(name, app) {
142
- try {
143
- const now = new Date()
144
- fs.utimesSync(paths.cursor(name, app), now, now)
145
- return true
146
- } catch {
147
- return false // 还没有游标文件(首次跑),seedCursorNow 会建
148
- }
149
- }
150
-
151
- // mtime 而非 atime:atime 会被 status 的只读扫描刷新,那不算「在用」
152
- function cursorMtime(name, app) {
153
- try {
154
- return fs.statSync(paths.cursor(name, app)).mtimeMs
155
- } catch {
156
- return null
157
- }
158
- }
159
-
160
132
  // --since now:游标落到当前最新,不重放历史
161
133
  function seedCursorNow(name, app) {
162
134
  const all = scanAll(app)
@@ -210,70 +182,13 @@ function gcStore(retainDays) {
210
182
  return removed
211
183
  }
212
184
 
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
-
223
- // gc:删空闲超期的游标。与 gcStore 同构(都是「删超期的东西」),故放一起。
224
- //
225
- // 判据是**多久没人用过它**(mtime)。「落后多少条」和「停在哪天」都试过、
226
- // 方向都是反的 -- 见 docs/lessons.md#游标判据。
227
- //
228
- // 不必守「在跑的别删」:take 启动即 touch,默认 --timeout(12h)远小于 TTL(2 天),
229
- // 在跑的游标不可能超期。test 锁住了这个不变量
230
- function gcCursors(ttlDays) {
231
- const cutoff = Date.now() - ttlDays * 86400_000
232
- const removed = []
233
- let names
234
- try {
235
- names = fs.readdirSync(paths.cursors)
236
- } catch {
237
- return removed
238
- }
239
- for (const name of names) {
240
- if (name.startsWith('.')) continue
241
- const dir = path.join(paths.cursors, name)
242
- let files
243
- try {
244
- if (!fs.statSync(dir).isDirectory()) continue
245
- files = fs.readdirSync(dir)
246
- } catch {
247
- continue
248
- }
249
- // 粒度是 app 而非整个身份 -- 见 docs/lessons.md#回收粒度
250
- for (const app of files) {
251
- if (app.startsWith('.') || app.endsWith('.tmp')) continue
252
- const file = path.join(dir, app)
253
- try {
254
- if (fs.statSync(file).mtimeMs >= cutoff) continue
255
- fs.rmSync(file, { force: true })
256
- fs.rmSync(`${file}.tmp`, { force: true })
257
- removed.push(`${name}/${app}`)
258
- } catch {}
259
- }
260
- try {
261
- if (!fs.readdirSync(dir).length) fs.rmdirSync(dir)
262
- } catch {}
263
- }
264
- return removed
265
- }
266
-
267
185
  module.exports = {
268
- CURSOR_TTL_DAYS,
269
186
  writeEvent,
270
187
  scanAll,
271
188
  readEvent,
272
189
  seedIds,
273
190
  readCursor,
274
191
  writeCursor,
275
- touchCursor,
276
- cursorMtime,
277
192
  seedCursorNow,
278
193
  afterCursor,
279
194
  entryKey,
@@ -283,5 +198,4 @@ module.exports = {
283
198
  latestEntry,
284
199
  listApps,
285
200
  gcStore,
286
- gcCursors,
287
201
  }
package/lib/take.js CHANGED
@@ -1,8 +1,6 @@
1
1
  'use strict'
2
2
 
3
- // take:只有一种形态 —— 阻塞等 → 有一批就输出 → 退出。
4
- // 循环由 harness 承担(Claude Code run_in_background 进程退出会主动通知 AI),
5
- // 包不做 --follow、不做 nohup 循环、不落中间日志文件。
3
+ // 阻塞取一批,调用方负责处理后再次取用
6
4
  const fs = require('fs')
7
5
  const crypto = require('crypto')
8
6
  const store = require('./store')
@@ -31,6 +29,7 @@ function parseChats(spec) {
31
29
  } catch (err) {
32
30
  const e = new Error(`--chats 指向的文件读不到:${file}`)
33
31
  e.userFacing = true
32
+ e.exitCode = 2
34
33
  throw e
35
34
  }
36
35
  raw = text
@@ -60,44 +59,35 @@ async function takeBatch(opts) {
60
59
  maxWaitMs,
61
60
  timeoutMs,
62
61
  pollMs = 500,
63
- eventKey = 'im.message.receive_v1',
64
62
  signal,
65
63
  } = opts
66
64
 
67
65
  const chatSet = new Set(chats)
68
66
  const startedAt = Date.now()
69
67
  const batch = []
70
- const seen = new Set()
71
68
  let firstAt = null
72
69
  let lastNewAt = null
73
70
  let cursor = store.readCursor(name, app)
74
- let commit = cursor
75
71
 
76
72
  for (;;) {
77
- // abort(SIGTERM/SIGINT)时必须和正常返回一样落盘 —— 否则批次已吐给调用方
78
- // (AI 已看到、可能已回群),游标却没推,重启后同一批会**重跑一遍**(实测:重复回群)
73
+ // 信号退出也交付已攒的批次,提交时机与正常退出一致
79
74
  if (signal && signal.aborted) {
80
- if (batch.length && commit) store.writeCursor(name, app, commit)
81
- return { events: batch, reason: batch.length ? 'batch' : 'signal', cursor: commit }
75
+ if (batch.length && cursor) store.writeCursor(name, app, cursor)
76
+ return { events: batch, reason: batch.length ? 'batch' : 'signal', cursor }
82
77
  }
83
78
 
84
79
  const entries = store.scanAll(app).filter((e) => store.afterCursor(e, cursor))
85
- let scannedLast = null
86
80
  const fresh = []
87
81
  for (const entry of entries) {
88
- const key = store.entryKey(entry)
89
- scannedLast = key
90
- if (seen.has(key)) continue
91
- seen.add(key)
82
+ cursor = store.entryKey(entry)
92
83
  const ev = store.readEvent(entry)
93
84
  if (!ev) continue // 半成品/坏文件:跳过
94
- if (eventKey && ev.type && ev.type !== eventKey) continue
85
+ if (ev.type !== 'im.message.receive_v1') continue
95
86
  if (chatSet.size && !chatSet.has(ev.chat_id)) continue
96
87
  fresh.push(ev)
97
88
  }
98
89
 
99
- let passed = []
100
- if (fresh.length) passed = applyFilter(fresh, filter)
90
+ const passed = applyFilter(fresh, filter)
101
91
 
102
92
  if (passed.length) {
103
93
  batch.push(...passed)
@@ -106,12 +96,9 @@ async function takeBatch(opts) {
106
96
  lastNewAt = now
107
97
  }
108
98
 
109
- // 游标推进到已扫过的位置:被 filter/chats 丢掉的事件不必重扫。
110
- // 批次未吐出时也推进 —— 崩了就重来一轮,不会吐半批。
111
- if (scannedLast) commit = scannedLast
112
- if (batch.length === 0 && commit && commit !== cursor) {
113
- store.writeCursor(name, app, commit)
114
- cursor = commit
99
+ // 内存游标每轮前移;有待交付批次时,磁盘断点保留到交付前
100
+ if (batch.length === 0 && entries.length) {
101
+ store.writeCursor(name, app, cursor)
115
102
  }
116
103
 
117
104
  if (batch.length) {
@@ -119,15 +106,15 @@ async function takeBatch(opts) {
119
106
  const quiet = now - lastNewAt >= debounceMs
120
107
  const capped = now - firstAt >= maxWaitMs
121
108
  if (quiet || capped) {
122
- // 吐批即推游标,不等 ack —— at-most-once。聊天场景「漏回 < 重复回」
123
- if (commit) store.writeCursor(name, app, commit)
124
- return { events: batch, reason: 'batch', cursor: commit }
109
+ // at-most-once:交付前提交,不等待业务处理确认
110
+ if (cursor) store.writeCursor(name, app, cursor)
111
+ return { events: batch, reason: 'batch', cursor }
125
112
  }
126
113
  }
127
114
 
128
115
  if (Date.now() - startedAt >= timeoutMs) {
129
- if (commit) store.writeCursor(name, app, commit)
130
- return { events: batch, reason: batch.length ? 'batch' : 'timeout', cursor: commit }
116
+ if (batch.length && cursor) store.writeCursor(name, app, cursor)
117
+ return { events: batch, reason: batch.length ? 'batch' : 'timeout', cursor }
131
118
  }
132
119
 
133
120
  await sleep(pollMs)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lark-relay",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "description": "Lark event relay: collect events to disk, take a batch when you need it.",
5
5
  "keywords": [
6
6
  "lark",
@@ -14,7 +14,7 @@
14
14
  "author": "adaex",
15
15
  "type": "commonjs",
16
16
  "engines": {
17
- "node": ">=18"
17
+ "node": ">=22"
18
18
  },
19
19
  "bin": {
20
20
  "lark-relay": "bin/lark-relay.js",