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 +1 -1
- package/lib/collect.js +13 -9
- package/lib/cursors.js +62 -9
- package/lib/help.js +13 -3
- package/package.json +1 -1
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 =
|
|
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
|
-
//
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
|
41
|
+
const IDLE_TTL_DAYS = (() => {
|
|
42
42
|
const d = Number(process.env.LARK_RELAY_CURSOR_TTL_DAYS)
|
|
43
|
-
return
|
|
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
|
-
|
|
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
|
-
⚠️
|
|
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
|
-
|
|
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 自己写十来行就够,不必找模板:
|