@prisma/streams-server 0.0.1 → 0.1.0
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/CODE_OF_CONDUCT.md +45 -0
- package/CONTRIBUTING.md +68 -0
- package/LICENSE +201 -0
- package/README.md +39 -2
- package/SECURITY.md +33 -0
- package/bin/prisma-streams-server +2 -0
- package/package.json +29 -34
- package/src/app.ts +74 -0
- package/src/app_core.ts +1983 -0
- package/src/app_local.ts +46 -0
- package/src/backpressure.ts +66 -0
- package/src/bootstrap.ts +239 -0
- package/src/config.ts +251 -0
- package/src/db/db.ts +1440 -0
- package/src/db/schema.ts +619 -0
- package/src/expiry_sweeper.ts +44 -0
- package/src/hist.ts +169 -0
- package/src/index/binary_fuse.ts +379 -0
- package/src/index/indexer.ts +745 -0
- package/src/index/run_cache.ts +84 -0
- package/src/index/run_format.ts +213 -0
- package/src/ingest.ts +655 -0
- package/src/lens/lens.ts +501 -0
- package/src/manifest.ts +114 -0
- package/src/memory.ts +155 -0
- package/src/metrics.ts +161 -0
- package/src/metrics_emitter.ts +50 -0
- package/src/notifier.ts +64 -0
- package/src/objectstore/interface.ts +13 -0
- package/src/objectstore/mock_r2.ts +269 -0
- package/src/objectstore/null.ts +32 -0
- package/src/objectstore/r2.ts +128 -0
- package/src/offset.ts +70 -0
- package/src/reader.ts +454 -0
- package/src/runtime/hash.ts +156 -0
- package/src/runtime/hash_vendor/LICENSE.hash-wasm +38 -0
- package/src/runtime/hash_vendor/NOTICE.md +8 -0
- package/src/runtime/hash_vendor/xxhash3.umd.min.cjs +7 -0
- package/src/runtime/hash_vendor/xxhash32.umd.min.cjs +7 -0
- package/src/runtime/hash_vendor/xxhash64.umd.min.cjs +7 -0
- package/src/schema/lens_schema.ts +290 -0
- package/src/schema/proof.ts +547 -0
- package/src/schema/registry.ts +405 -0
- package/src/segment/cache.ts +179 -0
- package/src/segment/format.ts +331 -0
- package/src/segment/segmenter.ts +326 -0
- package/src/segment/segmenter_worker.ts +43 -0
- package/src/segment/segmenter_workers.ts +94 -0
- package/src/server.ts +326 -0
- package/src/sqlite/adapter.ts +164 -0
- package/src/stats.ts +205 -0
- package/src/touch/engine.ts +41 -0
- package/src/touch/interpreter_worker.ts +459 -0
- package/src/touch/live_keys.ts +118 -0
- package/src/touch/live_metrics.ts +858 -0
- package/src/touch/live_templates.ts +619 -0
- package/src/touch/manager.ts +1341 -0
- package/src/touch/naming.ts +13 -0
- package/src/touch/routing_key_notifier.ts +275 -0
- package/src/touch/spec.ts +526 -0
- package/src/touch/touch_journal.ts +671 -0
- package/src/touch/touch_key_id.ts +20 -0
- package/src/touch/worker_pool.ts +189 -0
- package/src/touch/worker_protocol.ts +58 -0
- package/src/types/proper-lockfile.d.ts +1 -0
- package/src/uploader.ts +317 -0
- package/src/util/base32_crockford.ts +81 -0
- package/src/util/bloom256.ts +67 -0
- package/src/util/cleanup.ts +22 -0
- package/src/util/crc32c.ts +29 -0
- package/src/util/ds_error.ts +15 -0
- package/src/util/duration.ts +17 -0
- package/src/util/endian.ts +53 -0
- package/src/util/json_pointer.ts +148 -0
- package/src/util/log.ts +25 -0
- package/src/util/lru.ts +45 -0
- package/src/util/retry.ts +35 -0
- package/src/util/siphash.ts +71 -0
- package/src/util/stream_paths.ts +31 -0
- package/src/util/time.ts +14 -0
- package/src/util/yield.ts +3 -0
- package/build/index.d.mts +0 -1
- package/build/index.d.ts +0 -1
- package/build/index.js +0 -0
- package/build/index.mjs +0 -1
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
import type { SqliteDurableStore } from "../db/db";
|
|
2
|
+
import { canonicalizeTemplateFields, templateIdFor, type TemplateEncoding } from "./live_keys";
|
|
3
|
+
|
|
4
|
+
export type TemplateFieldSpec = { name: string; encoding: TemplateEncoding };
|
|
5
|
+
export type TemplateDecl = { entity: string; fields: TemplateFieldSpec[] };
|
|
6
|
+
|
|
7
|
+
export type ActivatedTemplate = {
|
|
8
|
+
templateId: string;
|
|
9
|
+
state: "active";
|
|
10
|
+
activeFromTouchOffset: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type DeniedTemplate = {
|
|
14
|
+
templateId: string;
|
|
15
|
+
reason: "rate_limited" | "invalid";
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type LiveTemplateRow = {
|
|
19
|
+
stream: string;
|
|
20
|
+
template_id: string;
|
|
21
|
+
entity: string;
|
|
22
|
+
fields_json: string;
|
|
23
|
+
encodings_json: string;
|
|
24
|
+
state: string;
|
|
25
|
+
created_at_ms: bigint;
|
|
26
|
+
last_seen_at_ms: bigint;
|
|
27
|
+
inactivity_ttl_ms: bigint;
|
|
28
|
+
active_from_source_offset: bigint;
|
|
29
|
+
retired_at_ms: bigint | null;
|
|
30
|
+
retired_reason: string | null;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type TemplateLifecycleEvent =
|
|
34
|
+
| {
|
|
35
|
+
type: "live.template_activated";
|
|
36
|
+
ts: string;
|
|
37
|
+
stream: string;
|
|
38
|
+
templateId: string;
|
|
39
|
+
entity: string;
|
|
40
|
+
fields: string[];
|
|
41
|
+
encodings: TemplateEncoding[];
|
|
42
|
+
reason: "declared" | "heartbeat";
|
|
43
|
+
activeFromTouchOffset: string;
|
|
44
|
+
inactivityTtlMs: number;
|
|
45
|
+
}
|
|
46
|
+
| {
|
|
47
|
+
type: "live.template_retired";
|
|
48
|
+
ts: string;
|
|
49
|
+
stream: string;
|
|
50
|
+
templateId: string;
|
|
51
|
+
entity: string;
|
|
52
|
+
fields: string[];
|
|
53
|
+
encodings: TemplateEncoding[];
|
|
54
|
+
lastSeenAt: string;
|
|
55
|
+
inactiveForMs: number;
|
|
56
|
+
reason: "inactivity";
|
|
57
|
+
}
|
|
58
|
+
| {
|
|
59
|
+
type: "live.template_evicted";
|
|
60
|
+
ts: string;
|
|
61
|
+
stream: string;
|
|
62
|
+
templateId: string;
|
|
63
|
+
reason: "cap_exceeded";
|
|
64
|
+
cap: number;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
type RateState = { tokens: number; lastRefillMs: number };
|
|
68
|
+
|
|
69
|
+
function nowIso(ms: number): string {
|
|
70
|
+
return new Date(ms).toISOString();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function parseTemplateRow(row: any): LiveTemplateRow {
|
|
74
|
+
return {
|
|
75
|
+
stream: String(row.stream),
|
|
76
|
+
template_id: String(row.template_id),
|
|
77
|
+
entity: String(row.entity),
|
|
78
|
+
fields_json: String(row.fields_json),
|
|
79
|
+
encodings_json: String(row.encodings_json),
|
|
80
|
+
state: String(row.state),
|
|
81
|
+
created_at_ms: typeof row.created_at_ms === "bigint" ? row.created_at_ms : BigInt(row.created_at_ms),
|
|
82
|
+
last_seen_at_ms: typeof row.last_seen_at_ms === "bigint" ? row.last_seen_at_ms : BigInt(row.last_seen_at_ms),
|
|
83
|
+
inactivity_ttl_ms: typeof row.inactivity_ttl_ms === "bigint" ? row.inactivity_ttl_ms : BigInt(row.inactivity_ttl_ms),
|
|
84
|
+
active_from_source_offset:
|
|
85
|
+
typeof row.active_from_source_offset === "bigint" ? row.active_from_source_offset : BigInt(row.active_from_source_offset),
|
|
86
|
+
retired_at_ms: row.retired_at_ms == null ? null : typeof row.retired_at_ms === "bigint" ? row.retired_at_ms : BigInt(row.retired_at_ms),
|
|
87
|
+
retired_reason: row.retired_reason == null ? null : String(row.retired_reason),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export class LiveTemplateRegistry {
|
|
92
|
+
private readonly db: SqliteDurableStore;
|
|
93
|
+
|
|
94
|
+
// In-memory last-seen tracking to avoid sqlite writes on every wait call.
|
|
95
|
+
private readonly lastSeenMem = new Map<string, { lastSeenMs: number; lastPersistMs: number }>();
|
|
96
|
+
private readonly dirtyLastSeen = new Set<string>();
|
|
97
|
+
|
|
98
|
+
private readonly rate = new Map<string, RateState>();
|
|
99
|
+
|
|
100
|
+
constructor(db: SqliteDurableStore) {
|
|
101
|
+
this.db = db;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private key(stream: string, templateId: string): string {
|
|
105
|
+
return `${stream}\n${templateId}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private allowActivation(stream: string, nowMs: number, limitPerMinute: number): boolean {
|
|
109
|
+
if (limitPerMinute <= 0) return true;
|
|
110
|
+
const ratePerMs = limitPerMinute / 60_000;
|
|
111
|
+
const st = this.rate.get(stream) ?? { tokens: limitPerMinute, lastRefillMs: nowMs };
|
|
112
|
+
const elapsed = Math.max(0, nowMs - st.lastRefillMs);
|
|
113
|
+
st.tokens = Math.min(limitPerMinute, st.tokens + elapsed * ratePerMs);
|
|
114
|
+
st.lastRefillMs = nowMs;
|
|
115
|
+
if (st.tokens < 1) {
|
|
116
|
+
this.rate.set(stream, st);
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
st.tokens -= 1;
|
|
120
|
+
this.rate.set(stream, st);
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
getActiveTemplateCount(stream: string): number {
|
|
125
|
+
try {
|
|
126
|
+
const row = this.db.db
|
|
127
|
+
.query(`SELECT COUNT(*) as cnt FROM live_templates WHERE stream=? AND state='active';`)
|
|
128
|
+
.get(stream) as any;
|
|
129
|
+
return Number(row?.cnt ?? 0);
|
|
130
|
+
} catch {
|
|
131
|
+
return 0;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
listActiveTemplates(stream: string): Array<{ templateId: string; entity: string; fields: string[]; encodings: TemplateEncoding[]; lastSeenAtMs: number }> {
|
|
136
|
+
try {
|
|
137
|
+
const rows = this.db.db
|
|
138
|
+
.query(
|
|
139
|
+
`SELECT template_id, entity, fields_json, encodings_json, last_seen_at_ms
|
|
140
|
+
FROM live_templates
|
|
141
|
+
WHERE stream=? AND state='active'
|
|
142
|
+
ORDER BY entity ASC, template_id ASC;`
|
|
143
|
+
)
|
|
144
|
+
.all(stream) as any[];
|
|
145
|
+
const out: Array<{ templateId: string; entity: string; fields: string[]; encodings: TemplateEncoding[]; lastSeenAtMs: number }> = [];
|
|
146
|
+
for (const row of rows) {
|
|
147
|
+
const templateId = String(row.template_id);
|
|
148
|
+
const entity = String(row.entity);
|
|
149
|
+
const fields = JSON.parse(String(row.fields_json));
|
|
150
|
+
const encodings = JSON.parse(String(row.encodings_json));
|
|
151
|
+
if (!Array.isArray(fields) || !Array.isArray(encodings) || fields.length !== encodings.length) continue;
|
|
152
|
+
const lastSeenAtMs = Number(row.last_seen_at_ms);
|
|
153
|
+
out.push({ templateId, entity, fields: fields.map(String), encodings: encodings.map(String) as any, lastSeenAtMs });
|
|
154
|
+
}
|
|
155
|
+
return out;
|
|
156
|
+
} catch {
|
|
157
|
+
return [];
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Activate templates (idempotent). Returns lifecycle events to be emitted.
|
|
163
|
+
*
|
|
164
|
+
* `baseStreamNextOffset` is used to set `active_from_source_offset` so we do
|
|
165
|
+
* not backfill fine touches for history when a template is activated while
|
|
166
|
+
* the interpreter is behind.
|
|
167
|
+
*/
|
|
168
|
+
activate(args: {
|
|
169
|
+
stream: string;
|
|
170
|
+
activeFromTouchOffset: string;
|
|
171
|
+
baseStreamNextOffset: bigint;
|
|
172
|
+
templates: TemplateDecl[];
|
|
173
|
+
inactivityTtlMs: number;
|
|
174
|
+
limits: {
|
|
175
|
+
maxActiveTemplatesPerStream: number;
|
|
176
|
+
maxActiveTemplatesPerEntity: number;
|
|
177
|
+
activationRateLimitPerMinute: number;
|
|
178
|
+
};
|
|
179
|
+
nowMs: number;
|
|
180
|
+
}): { activated: ActivatedTemplate[]; denied: DeniedTemplate[]; lifecycle: TemplateLifecycleEvent[] } {
|
|
181
|
+
const { stream, templates, inactivityTtlMs, nowMs } = args;
|
|
182
|
+
const { maxActiveTemplatesPerStream, maxActiveTemplatesPerEntity, activationRateLimitPerMinute } = args.limits;
|
|
183
|
+
|
|
184
|
+
const activated: ActivatedTemplate[] = [];
|
|
185
|
+
const denied: DeniedTemplate[] = [];
|
|
186
|
+
const lifecycle: TemplateLifecycleEvent[] = [];
|
|
187
|
+
|
|
188
|
+
const protectedIds = new Set<string>();
|
|
189
|
+
|
|
190
|
+
for (const t of templates) {
|
|
191
|
+
const entity = typeof t?.entity === "string" ? t.entity.trim() : "";
|
|
192
|
+
if (entity === "") {
|
|
193
|
+
denied.push({ templateId: "0000000000000000", reason: "invalid" });
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (!Array.isArray(t.fields) || t.fields.length === 0 || t.fields.length > 3) {
|
|
197
|
+
denied.push({ templateId: "0000000000000000", reason: "invalid" });
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const rawFields: Array<{ name: string; encoding: TemplateEncoding }> = [];
|
|
202
|
+
for (const f of t.fields) {
|
|
203
|
+
const name = typeof (f as any)?.name === "string" ? String((f as any).name).trim() : "";
|
|
204
|
+
const encoding = (f as any)?.encoding as TemplateEncoding;
|
|
205
|
+
if (name === "") continue;
|
|
206
|
+
if (encoding !== "string" && encoding !== "int64" && encoding !== "bool" && encoding !== "datetime" && encoding !== "bytes") continue;
|
|
207
|
+
rawFields.push({ name, encoding });
|
|
208
|
+
}
|
|
209
|
+
if (rawFields.length !== t.fields.length) {
|
|
210
|
+
denied.push({ templateId: "0000000000000000", reason: "invalid" });
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
{
|
|
214
|
+
const seen = new Set<string>();
|
|
215
|
+
let ok = true;
|
|
216
|
+
for (const f of rawFields) {
|
|
217
|
+
if (seen.has(f.name)) ok = false;
|
|
218
|
+
seen.add(f.name);
|
|
219
|
+
}
|
|
220
|
+
if (!ok) {
|
|
221
|
+
denied.push({ templateId: "0000000000000000", reason: "invalid" });
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const fields = canonicalizeTemplateFields(rawFields);
|
|
227
|
+
const fieldNames = fields.map((f) => f.name);
|
|
228
|
+
const encodings = fields.map((f) => f.encoding);
|
|
229
|
+
|
|
230
|
+
const templateId = templateIdFor(entity, fieldNames);
|
|
231
|
+
|
|
232
|
+
const existing = this.db.db
|
|
233
|
+
.query(
|
|
234
|
+
`SELECT stream, template_id, entity, fields_json, encodings_json, state, created_at_ms, last_seen_at_ms,
|
|
235
|
+
inactivity_ttl_ms, active_from_source_offset, retired_at_ms, retired_reason
|
|
236
|
+
FROM live_templates
|
|
237
|
+
WHERE stream=? AND template_id=? LIMIT 1;`
|
|
238
|
+
)
|
|
239
|
+
.get(stream, templateId) as any;
|
|
240
|
+
|
|
241
|
+
const alreadyActive = existing && String(existing.state) === "active";
|
|
242
|
+
const needsToken = !alreadyActive;
|
|
243
|
+
|
|
244
|
+
if (needsToken && !this.allowActivation(stream, nowMs, activationRateLimitPerMinute)) {
|
|
245
|
+
denied.push({ templateId, reason: "rate_limited" });
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (existing) {
|
|
250
|
+
const row = parseTemplateRow(existing);
|
|
251
|
+
if (row.entity !== entity) {
|
|
252
|
+
denied.push({ templateId, reason: "invalid" });
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
let storedFields: any;
|
|
256
|
+
let storedEnc: any;
|
|
257
|
+
try {
|
|
258
|
+
storedFields = JSON.parse(row.fields_json);
|
|
259
|
+
storedEnc = JSON.parse(row.encodings_json);
|
|
260
|
+
} catch {
|
|
261
|
+
denied.push({ templateId, reason: "invalid" });
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (!Array.isArray(storedFields) || !Array.isArray(storedEnc) || storedFields.length !== storedEnc.length) {
|
|
265
|
+
denied.push({ templateId, reason: "invalid" });
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
const sf = storedFields.map(String);
|
|
269
|
+
const se = storedEnc.map(String);
|
|
270
|
+
if (sf.join("\0") !== fieldNames.join("\0")) {
|
|
271
|
+
denied.push({ templateId, reason: "invalid" });
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
if (se.join("\0") !== encodings.join("\0")) {
|
|
275
|
+
denied.push({ templateId, reason: "invalid" });
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (row.state === "active") {
|
|
280
|
+
this.db.db
|
|
281
|
+
.query(
|
|
282
|
+
`UPDATE live_templates
|
|
283
|
+
SET last_seen_at_ms=?, inactivity_ttl_ms=?
|
|
284
|
+
WHERE stream=? AND template_id=?;`
|
|
285
|
+
)
|
|
286
|
+
.run(nowMs, inactivityTtlMs, stream, templateId);
|
|
287
|
+
} else {
|
|
288
|
+
this.db.db
|
|
289
|
+
.query(
|
|
290
|
+
`UPDATE live_templates
|
|
291
|
+
SET state='active',
|
|
292
|
+
last_seen_at_ms=?,
|
|
293
|
+
inactivity_ttl_ms=?,
|
|
294
|
+
active_from_source_offset=?,
|
|
295
|
+
retired_at_ms=NULL,
|
|
296
|
+
retired_reason=NULL
|
|
297
|
+
WHERE stream=? AND template_id=?;`
|
|
298
|
+
)
|
|
299
|
+
.run(nowMs, inactivityTtlMs, args.baseStreamNextOffset, stream, templateId);
|
|
300
|
+
}
|
|
301
|
+
} else {
|
|
302
|
+
this.db.db
|
|
303
|
+
.query(
|
|
304
|
+
`INSERT INTO live_templates(
|
|
305
|
+
stream, template_id, entity, fields_json, encodings_json,
|
|
306
|
+
state, created_at_ms, last_seen_at_ms, inactivity_ttl_ms, active_from_source_offset,
|
|
307
|
+
retired_at_ms, retired_reason
|
|
308
|
+
) VALUES(?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, NULL, NULL);`
|
|
309
|
+
)
|
|
310
|
+
.run(stream, templateId, entity, JSON.stringify(fieldNames), JSON.stringify(encodings), nowMs, nowMs, inactivityTtlMs, args.baseStreamNextOffset);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
protectedIds.add(templateId);
|
|
314
|
+
activated.push({ templateId, state: "active", activeFromTouchOffset: args.activeFromTouchOffset });
|
|
315
|
+
lifecycle.push({
|
|
316
|
+
type: "live.template_activated",
|
|
317
|
+
ts: nowIso(nowMs),
|
|
318
|
+
stream,
|
|
319
|
+
templateId,
|
|
320
|
+
entity,
|
|
321
|
+
fields: fieldNames,
|
|
322
|
+
encodings,
|
|
323
|
+
reason: "declared",
|
|
324
|
+
activeFromTouchOffset: args.activeFromTouchOffset,
|
|
325
|
+
inactivityTtlMs,
|
|
326
|
+
});
|
|
327
|
+
this.markSeen(stream, templateId, nowMs);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Enforce caps with LRU eviction.
|
|
331
|
+
const evicted = this.evictToCaps(stream, nowMs, { maxActiveTemplatesPerStream, maxActiveTemplatesPerEntity }, protectedIds);
|
|
332
|
+
for (const e of evicted) lifecycle.push(e);
|
|
333
|
+
|
|
334
|
+
return { activated, denied, lifecycle };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
heartbeat(stream: string, templateIdsUsed: string[], nowMs: number): void {
|
|
338
|
+
for (const id of templateIdsUsed) {
|
|
339
|
+
const templateId = typeof id === "string" ? id.trim() : "";
|
|
340
|
+
if (!/^[0-9a-f]{16}$/.test(templateId)) continue;
|
|
341
|
+
this.markSeen(stream, templateId, nowMs);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
flushLastSeen(nowMs: number, persistIntervalMs: number): void {
|
|
346
|
+
if (this.dirtyLastSeen.size === 0) return;
|
|
347
|
+
|
|
348
|
+
const stmt = this.db.db.query(
|
|
349
|
+
`UPDATE live_templates
|
|
350
|
+
SET last_seen_at_ms=?
|
|
351
|
+
WHERE stream=? AND template_id=? AND state='active';`
|
|
352
|
+
);
|
|
353
|
+
try {
|
|
354
|
+
for (const k of this.dirtyLastSeen) {
|
|
355
|
+
const item = this.lastSeenMem.get(k);
|
|
356
|
+
if (!item) {
|
|
357
|
+
this.dirtyLastSeen.delete(k);
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
if (nowMs - item.lastPersistMs < persistIntervalMs) continue;
|
|
361
|
+
const [stream, templateId] = k.split("\n");
|
|
362
|
+
stmt.run(item.lastSeenMs, stream, templateId);
|
|
363
|
+
item.lastPersistMs = nowMs;
|
|
364
|
+
this.dirtyLastSeen.delete(k);
|
|
365
|
+
}
|
|
366
|
+
} finally {
|
|
367
|
+
try {
|
|
368
|
+
stmt.finalize?.();
|
|
369
|
+
} catch {
|
|
370
|
+
// ignore
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
gcRetireExpired(stream: string, nowMs: number): { retired: TemplateLifecycleEvent[] } {
|
|
376
|
+
const expired: any[] = [];
|
|
377
|
+
try {
|
|
378
|
+
const rows = this.db.db
|
|
379
|
+
.query(
|
|
380
|
+
`SELECT template_id, entity, fields_json, encodings_json, last_seen_at_ms, inactivity_ttl_ms
|
|
381
|
+
FROM live_templates
|
|
382
|
+
WHERE stream=? AND state='active' AND (last_seen_at_ms + inactivity_ttl_ms) < ?
|
|
383
|
+
ORDER BY last_seen_at_ms ASC
|
|
384
|
+
LIMIT 1000;`
|
|
385
|
+
)
|
|
386
|
+
.all(stream, nowMs) as any[];
|
|
387
|
+
expired.push(...rows);
|
|
388
|
+
} catch {
|
|
389
|
+
return { retired: [] };
|
|
390
|
+
}
|
|
391
|
+
if (expired.length === 0) return { retired: [] };
|
|
392
|
+
|
|
393
|
+
// If a client is heartbeating frequently but last-seen persistence is
|
|
394
|
+
// configured with a longer interval than the inactivity TTL, DB state can
|
|
395
|
+
// look expired even though in-memory last-seen is fresh. Prefer in-memory
|
|
396
|
+
// last-seen and opportunistically refresh DB to avoid incorrect retirement.
|
|
397
|
+
const effectiveExpired: any[] = [];
|
|
398
|
+
const refreshLastSeen = this.db.db.query(
|
|
399
|
+
`UPDATE live_templates
|
|
400
|
+
SET last_seen_at_ms=?
|
|
401
|
+
WHERE stream=? AND template_id=? AND state='active';`
|
|
402
|
+
);
|
|
403
|
+
try {
|
|
404
|
+
for (const row of expired) {
|
|
405
|
+
const templateId = String(row.template_id);
|
|
406
|
+
const dbLastSeenAtMs = Number(row.last_seen_at_ms);
|
|
407
|
+
const ttlMs = Number(row.inactivity_ttl_ms);
|
|
408
|
+
const mem = this.lastSeenMem.get(this.key(stream, templateId));
|
|
409
|
+
const memLastSeen = mem ? mem.lastSeenMs : 0;
|
|
410
|
+
const lastSeenAtMs = Math.max(dbLastSeenAtMs, memLastSeen);
|
|
411
|
+
if (lastSeenAtMs + ttlMs >= nowMs) {
|
|
412
|
+
// Not expired when considering in-memory last-seen. Refresh DB so it
|
|
413
|
+
// doesn't get re-selected on the next GC tick.
|
|
414
|
+
if (mem && memLastSeen > dbLastSeenAtMs) {
|
|
415
|
+
refreshLastSeen.run(memLastSeen, stream, templateId);
|
|
416
|
+
mem.lastPersistMs = nowMs;
|
|
417
|
+
this.dirtyLastSeen.delete(this.key(stream, templateId));
|
|
418
|
+
}
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
effectiveExpired.push(row);
|
|
422
|
+
}
|
|
423
|
+
} finally {
|
|
424
|
+
try {
|
|
425
|
+
refreshLastSeen.finalize?.();
|
|
426
|
+
} catch {
|
|
427
|
+
// ignore
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (effectiveExpired.length === 0) return { retired: [] };
|
|
432
|
+
|
|
433
|
+
const retired: TemplateLifecycleEvent[] = [];
|
|
434
|
+
const update = this.db.db.query(
|
|
435
|
+
`UPDATE live_templates
|
|
436
|
+
SET state='retired', retired_reason='inactivity', retired_at_ms=?
|
|
437
|
+
WHERE stream=? AND template_id=? AND state='active';`
|
|
438
|
+
);
|
|
439
|
+
try {
|
|
440
|
+
for (const row of effectiveExpired) {
|
|
441
|
+
const templateId = String(row.template_id);
|
|
442
|
+
const entity = String(row.entity);
|
|
443
|
+
let fields: string[] = [];
|
|
444
|
+
let encodings: TemplateEncoding[] = [];
|
|
445
|
+
try {
|
|
446
|
+
const f = JSON.parse(String(row.fields_json));
|
|
447
|
+
const e = JSON.parse(String(row.encodings_json));
|
|
448
|
+
if (Array.isArray(f)) fields = f.map(String);
|
|
449
|
+
if (Array.isArray(e)) encodings = e.map(String) as any;
|
|
450
|
+
} catch {
|
|
451
|
+
// ignore
|
|
452
|
+
}
|
|
453
|
+
const dbLastSeenAtMs = Number(row.last_seen_at_ms);
|
|
454
|
+
const mem = this.lastSeenMem.get(this.key(stream, templateId));
|
|
455
|
+
const memLastSeen = mem ? mem.lastSeenMs : 0;
|
|
456
|
+
const lastSeenAtMs = Math.max(dbLastSeenAtMs, memLastSeen);
|
|
457
|
+
const inactiveForMs = Math.max(0, nowMs - lastSeenAtMs);
|
|
458
|
+
update.run(nowMs, stream, templateId);
|
|
459
|
+
retired.push({
|
|
460
|
+
type: "live.template_retired",
|
|
461
|
+
ts: nowIso(nowMs),
|
|
462
|
+
stream,
|
|
463
|
+
templateId,
|
|
464
|
+
entity,
|
|
465
|
+
fields,
|
|
466
|
+
encodings,
|
|
467
|
+
lastSeenAt: nowIso(lastSeenAtMs),
|
|
468
|
+
inactiveForMs,
|
|
469
|
+
reason: "inactivity",
|
|
470
|
+
});
|
|
471
|
+
this.lastSeenMem.delete(this.key(stream, templateId));
|
|
472
|
+
this.dirtyLastSeen.delete(this.key(stream, templateId));
|
|
473
|
+
}
|
|
474
|
+
} finally {
|
|
475
|
+
try {
|
|
476
|
+
update.finalize?.();
|
|
477
|
+
} catch {
|
|
478
|
+
// ignore
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return { retired };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
private markSeen(stream: string, templateId: string, nowMs: number): void {
|
|
486
|
+
const k = this.key(stream, templateId);
|
|
487
|
+
const item = this.lastSeenMem.get(k) ?? { lastSeenMs: 0, lastPersistMs: 0 };
|
|
488
|
+
if (nowMs > item.lastSeenMs) item.lastSeenMs = nowMs;
|
|
489
|
+
this.lastSeenMem.set(k, item);
|
|
490
|
+
this.dirtyLastSeen.add(k);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
private evictToCaps(
|
|
494
|
+
stream: string,
|
|
495
|
+
nowMs: number,
|
|
496
|
+
caps: { maxActiveTemplatesPerStream: number; maxActiveTemplatesPerEntity: number },
|
|
497
|
+
protectedIds: Set<string>
|
|
498
|
+
): TemplateLifecycleEvent[] {
|
|
499
|
+
const out: TemplateLifecycleEvent[] = [];
|
|
500
|
+
const { maxActiveTemplatesPerStream, maxActiveTemplatesPerEntity } = caps;
|
|
501
|
+
|
|
502
|
+
// Per-entity cap.
|
|
503
|
+
let entities: Array<{ entity: string; cnt: number }> = [];
|
|
504
|
+
try {
|
|
505
|
+
const rows = this.db.db
|
|
506
|
+
.query(
|
|
507
|
+
`SELECT entity, COUNT(*) as cnt
|
|
508
|
+
FROM live_templates
|
|
509
|
+
WHERE stream=? AND state='active'
|
|
510
|
+
GROUP BY entity;`
|
|
511
|
+
)
|
|
512
|
+
.all(stream) as any[];
|
|
513
|
+
entities = rows.map((r) => ({ entity: String(r.entity), cnt: Number(r.cnt) }));
|
|
514
|
+
} catch {
|
|
515
|
+
// ignore
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
for (const e of entities) {
|
|
519
|
+
if (e.cnt <= maxActiveTemplatesPerEntity) continue;
|
|
520
|
+
const extra = e.cnt - maxActiveTemplatesPerEntity;
|
|
521
|
+
const evicted = this.evictLru(stream, nowMs, extra, { entity: e.entity, cap: maxActiveTemplatesPerEntity }, protectedIds);
|
|
522
|
+
out.push(...evicted);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Per-stream cap.
|
|
526
|
+
let activeCount = 0;
|
|
527
|
+
try {
|
|
528
|
+
const row = this.db.db.query(`SELECT COUNT(*) as cnt FROM live_templates WHERE stream=? AND state='active';`).get(stream) as any;
|
|
529
|
+
activeCount = Number(row?.cnt ?? 0);
|
|
530
|
+
} catch {
|
|
531
|
+
activeCount = 0;
|
|
532
|
+
}
|
|
533
|
+
if (activeCount > maxActiveTemplatesPerStream) {
|
|
534
|
+
const extra = activeCount - maxActiveTemplatesPerStream;
|
|
535
|
+
const evicted = this.evictLru(stream, nowMs, extra, { cap: maxActiveTemplatesPerStream }, protectedIds);
|
|
536
|
+
out.push(...evicted);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
return out;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
private evictLru(
|
|
543
|
+
stream: string,
|
|
544
|
+
nowMs: number,
|
|
545
|
+
count: number,
|
|
546
|
+
scope: { entity?: string; cap: number },
|
|
547
|
+
protectedIds: Set<string>
|
|
548
|
+
): TemplateLifecycleEvent[] {
|
|
549
|
+
if (count <= 0) return [];
|
|
550
|
+
const out: TemplateLifecycleEvent[] = [];
|
|
551
|
+
|
|
552
|
+
const pick = (excludeProtected: boolean): string[] => {
|
|
553
|
+
const params: any[] = [stream];
|
|
554
|
+
let where = `stream=? AND state='active'`;
|
|
555
|
+
if (scope.entity) {
|
|
556
|
+
where += ` AND entity=?`;
|
|
557
|
+
params.push(scope.entity);
|
|
558
|
+
}
|
|
559
|
+
if (excludeProtected && protectedIds.size > 0) {
|
|
560
|
+
const placeholders = Array.from(protectedIds).map(() => "?").join(", ");
|
|
561
|
+
where += ` AND template_id NOT IN (${placeholders})`;
|
|
562
|
+
params.push(...Array.from(protectedIds));
|
|
563
|
+
}
|
|
564
|
+
const q = `SELECT template_id FROM live_templates WHERE ${where} ORDER BY last_seen_at_ms ASC, template_id ASC LIMIT ?;`;
|
|
565
|
+
params.push(count);
|
|
566
|
+
try {
|
|
567
|
+
const rows = this.db.db.query(q).all(...params) as any[];
|
|
568
|
+
return rows.map((r) => String(r.template_id));
|
|
569
|
+
} catch {
|
|
570
|
+
return [];
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
let ids = pick(true);
|
|
575
|
+
if (ids.length < count) {
|
|
576
|
+
// Evict protected templates only if we have to.
|
|
577
|
+
const extra = pick(false);
|
|
578
|
+
const merged: string[] = [];
|
|
579
|
+
const seen = new Set<string>();
|
|
580
|
+
for (const id of [...ids, ...extra]) {
|
|
581
|
+
if (seen.has(id)) continue;
|
|
582
|
+
seen.add(id);
|
|
583
|
+
merged.push(id);
|
|
584
|
+
if (merged.length >= count) break;
|
|
585
|
+
}
|
|
586
|
+
ids = merged;
|
|
587
|
+
}
|
|
588
|
+
if (ids.length === 0) return [];
|
|
589
|
+
|
|
590
|
+
const update = this.db.db.query(
|
|
591
|
+
`UPDATE live_templates
|
|
592
|
+
SET state='retired', retired_reason='cap_exceeded', retired_at_ms=?
|
|
593
|
+
WHERE stream=? AND template_id=? AND state='active';`
|
|
594
|
+
);
|
|
595
|
+
try {
|
|
596
|
+
for (const id of ids) {
|
|
597
|
+
update.run(nowMs, stream, id);
|
|
598
|
+
out.push({
|
|
599
|
+
type: "live.template_evicted",
|
|
600
|
+
ts: nowIso(nowMs),
|
|
601
|
+
stream,
|
|
602
|
+
templateId: id,
|
|
603
|
+
reason: "cap_exceeded",
|
|
604
|
+
cap: scope.cap,
|
|
605
|
+
});
|
|
606
|
+
this.lastSeenMem.delete(this.key(stream, id));
|
|
607
|
+
this.dirtyLastSeen.delete(this.key(stream, id));
|
|
608
|
+
}
|
|
609
|
+
} finally {
|
|
610
|
+
try {
|
|
611
|
+
update.finalize?.();
|
|
612
|
+
} catch {
|
|
613
|
+
// ignore
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
return out;
|
|
618
|
+
}
|
|
619
|
+
}
|