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 +2 -1
- package/bin/lark-relay.js +7 -1
- package/lib/collect.js +6 -16
- package/lib/help.js +1 -1
- package/lib/lock.js +5 -7
- package/lib/status.js +3 -1
- package/lib/store.js +12 -3
- package/package.json +1 -1
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
|
|
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
|
|
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(
|
|
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 按群分组紧凑文本(默认
|
|
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
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
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(
|
|
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
|
-
|
|
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,
|