@etzelm/openclaw-memory-hindsight 0.2.0 → 0.4.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/CHANGELOG.md +94 -0
- package/README.md +103 -17
- package/dist/client.js +104 -4
- package/dist/config.js +5 -1
- package/dist/index.js +81 -20
- package/dist/metrics.js +197 -0
- package/dist/provider-runtime.js +1 -1
- package/dist/retain-queue.js +134 -31
- package/dist/service.js +25 -2
- package/dist/tools.js +174 -1
- package/dist/transcript.js +244 -2
- package/openclaw.plugin.json +18 -1
- package/package.json +3 -1
package/dist/metrics.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { normalizeDiagnosticValue } from "openclaw/plugin-sdk/diagnostic-runtime";
|
|
2
|
+
import { getPluginRuntimeGatewayRequestScope } from "openclaw/plugin-sdk/plugin-runtime";
|
|
3
|
+
import { normalizeAgentIdStrict } from "openclaw/plugin-sdk/routing";
|
|
4
|
+
/**
|
|
5
|
+
* Gateway-side metrics for auto-recall, auto-retain and the retain queue, rendered as Prometheus
|
|
6
|
+
* text on a gateway-auth plugin route. diagnostics-prometheus renders only core diagnostic events
|
|
7
|
+
* and has no plugin metric surface, so the plugin serves its own series on the gateway port.
|
|
8
|
+
* Labels are agent and bank plus fixed enums; never session ids, document ids or text.
|
|
9
|
+
*/
|
|
10
|
+
export const METRICS_ROUTE_PATH = "/plugins/hindsight-native/metrics";
|
|
11
|
+
const PREFIX = "openclaw_hindsight_";
|
|
12
|
+
// Seconds. Recall times out at 10 s, retain at 30 s.
|
|
13
|
+
export const LATENCY_BUCKETS = [0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30];
|
|
14
|
+
// Label sets per family before new agent and bank values fold into "other"; agents and banks come
|
|
15
|
+
// from config. Outcome and reason keep their value, so the bound grows by the enum size at most.
|
|
16
|
+
export const MAX_SERIES_PER_FAMILY = 200;
|
|
17
|
+
const OTHER = "other";
|
|
18
|
+
const FOLDED_LABELS = new Set(["agent", "bank"]);
|
|
19
|
+
/** Agent label: the normalized agent id, or "unknown" when it is missing or not label-safe. */
|
|
20
|
+
export function agentLabel(agentId) {
|
|
21
|
+
// The guard runs first: normalizing would turn a session-shaped value into a safe-looking id.
|
|
22
|
+
// The strict form never falls back to the default agent, so an odd id reads unknown, not main.
|
|
23
|
+
const safe = normalizeDiagnosticValue(agentId);
|
|
24
|
+
if (safe === "unknown") {
|
|
25
|
+
return safe;
|
|
26
|
+
}
|
|
27
|
+
const normalized = normalizeAgentIdStrict(safe);
|
|
28
|
+
return normalized.ok ? normalized.value : "unknown";
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Bank label. A conversation bank (`<owner>::conv::<digest>`, one per root session) folds into
|
|
32
|
+
* `<owner>::conv`, so the label set stays as small as the configured agents and bank map.
|
|
33
|
+
*/
|
|
34
|
+
export function bankLabel(bankId) {
|
|
35
|
+
if (!bankId) {
|
|
36
|
+
return "unknown";
|
|
37
|
+
}
|
|
38
|
+
const conv = bankId.indexOf("::conv::");
|
|
39
|
+
return normalizeDiagnosticValue(conv >= 0 ? `${bankId.slice(0, conv)}::conv` : bankId);
|
|
40
|
+
}
|
|
41
|
+
/** True for the error an `AbortSignal.timeout` fetch rejects with. */
|
|
42
|
+
export function isTimeoutError(error) {
|
|
43
|
+
return error?.name === "TimeoutError";
|
|
44
|
+
}
|
|
45
|
+
function labelKey(labels) {
|
|
46
|
+
return JSON.stringify(Object.entries(labels));
|
|
47
|
+
}
|
|
48
|
+
function escapeLabelValue(value) {
|
|
49
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
|
|
50
|
+
}
|
|
51
|
+
function formatLabels(labels, extra) {
|
|
52
|
+
const pairs = Object.entries(labels);
|
|
53
|
+
if (extra) {
|
|
54
|
+
pairs.push(extra);
|
|
55
|
+
}
|
|
56
|
+
if (pairs.length === 0) {
|
|
57
|
+
return "";
|
|
58
|
+
}
|
|
59
|
+
return `{${pairs.map(([k, v]) => `${k}="${escapeLabelValue(v)}"`).join(",")}}`;
|
|
60
|
+
}
|
|
61
|
+
/** Bounded map of label set to state; past the bound every new set lands on the "other" set. */
|
|
62
|
+
class Series {
|
|
63
|
+
create;
|
|
64
|
+
entries = new Map();
|
|
65
|
+
constructor(create) {
|
|
66
|
+
this.create = create;
|
|
67
|
+
}
|
|
68
|
+
get(labels) {
|
|
69
|
+
let key = labelKey(labels);
|
|
70
|
+
let entry = this.entries.get(key);
|
|
71
|
+
if (!entry) {
|
|
72
|
+
if (this.entries.size >= MAX_SERIES_PER_FAMILY) {
|
|
73
|
+
labels = Object.fromEntries(Object.entries(labels).map(([k, v]) => [k, FOLDED_LABELS.has(k) ? OTHER : v]));
|
|
74
|
+
key = labelKey(labels);
|
|
75
|
+
entry = this.entries.get(key);
|
|
76
|
+
}
|
|
77
|
+
if (!entry) {
|
|
78
|
+
entry = { labels, value: this.create() };
|
|
79
|
+
this.entries.set(key, entry);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return entry.value;
|
|
83
|
+
}
|
|
84
|
+
values() {
|
|
85
|
+
return [...this.entries.values()];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function newHistogram() {
|
|
89
|
+
return { buckets: LATENCY_BUCKETS.map(() => 0), sum: 0, count: 0 };
|
|
90
|
+
}
|
|
91
|
+
function formatNumber(value) {
|
|
92
|
+
return Number.isFinite(value) ? String(value) : value > 0 ? "+Inf" : "-Inf";
|
|
93
|
+
}
|
|
94
|
+
export class HindsightMetrics {
|
|
95
|
+
recalls = new Series(() => ({ n: 0 }));
|
|
96
|
+
recallLatency = new Series(newHistogram);
|
|
97
|
+
retains = new Series(() => ({ n: 0 }));
|
|
98
|
+
retainLatency = new Series(newHistogram);
|
|
99
|
+
replays = new Series(() => ({ n: 0 }));
|
|
100
|
+
drops = new Series(() => ({ n: 0 }));
|
|
101
|
+
// Kept across scrapes, so a bank whose queue drained reads 0 instead of disappearing.
|
|
102
|
+
depth = new Series(() => ({ n: 0 }));
|
|
103
|
+
recordRecall(agentId, bankId, outcome, ms) {
|
|
104
|
+
const base = { agent: agentLabel(agentId), bank: bankLabel(bankId) };
|
|
105
|
+
this.recalls.get({ ...base, outcome }).n += 1;
|
|
106
|
+
observe(this.recallLatency.get(base), ms);
|
|
107
|
+
}
|
|
108
|
+
recordRetain(agentId, bankId, outcome, ms) {
|
|
109
|
+
const base = { agent: agentLabel(agentId), bank: bankLabel(bankId) };
|
|
110
|
+
this.retains.get({ ...base, outcome }).n += 1;
|
|
111
|
+
observe(this.retainLatency.get(base), ms);
|
|
112
|
+
}
|
|
113
|
+
recordReplay(bankId, outcome) {
|
|
114
|
+
this.replays.get({ bank: bankLabel(bankId), outcome }).n += 1;
|
|
115
|
+
}
|
|
116
|
+
recordDrop(bankId, reason, count = 1) {
|
|
117
|
+
if (count > 0) {
|
|
118
|
+
this.drops.get({ bank: bankLabel(bankId), reason }).n += count;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/** Prometheus text exposition (version 0.0.4). Families with no samples print only HELP/TYPE. */
|
|
122
|
+
render(queueDepth) {
|
|
123
|
+
const lines = [];
|
|
124
|
+
const counter = (name, help, series) => {
|
|
125
|
+
lines.push(`# HELP ${PREFIX}${name} ${help}`, `# TYPE ${PREFIX}${name} counter`);
|
|
126
|
+
for (const { labels, value } of series.values()) {
|
|
127
|
+
lines.push(`${PREFIX}${name}${formatLabels(labels)} ${value.n}`);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
const histogram = (name, help, series) => {
|
|
131
|
+
lines.push(`# HELP ${PREFIX}${name} ${help}`, `# TYPE ${PREFIX}${name} histogram`);
|
|
132
|
+
for (const { labels, value } of series.values()) {
|
|
133
|
+
LATENCY_BUCKETS.forEach((le, i) => {
|
|
134
|
+
lines.push(`${PREFIX}${name}_bucket${formatLabels(labels, ["le", String(le)])} ${value.buckets[i]}`);
|
|
135
|
+
});
|
|
136
|
+
lines.push(`${PREFIX}${name}_bucket${formatLabels(labels, ["le", "+Inf"])} ${value.count}`, `${PREFIX}${name}_sum${formatLabels(labels)} ${formatNumber(value.sum)}`, `${PREFIX}${name}_count${formatLabels(labels)} ${value.count}`);
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
counter("recall_total", "Auto-recall attempts by outcome.", this.recalls);
|
|
140
|
+
histogram("recall_duration_seconds", "Auto-recall latency.", this.recallLatency);
|
|
141
|
+
counter("retain_total", "Auto-retain attempts by outcome.", this.retains);
|
|
142
|
+
histogram("retain_duration_seconds", "Auto-retain latency.", this.retainLatency);
|
|
143
|
+
lines.push(`# HELP ${PREFIX}retain_queue_depth Retains waiting in the offline queue.`, `# TYPE ${PREFIX}retain_queue_depth gauge`);
|
|
144
|
+
for (const { value } of this.depth.values()) {
|
|
145
|
+
value.n = 0;
|
|
146
|
+
}
|
|
147
|
+
for (const [bankId, n] of queueDepth?.() ?? []) {
|
|
148
|
+
this.depth.get({ bank: bankLabel(bankId) }).n += n;
|
|
149
|
+
}
|
|
150
|
+
for (const { labels, value } of this.depth.values()) {
|
|
151
|
+
lines.push(`${PREFIX}retain_queue_depth${formatLabels(labels)} ${value.n}`);
|
|
152
|
+
}
|
|
153
|
+
counter("retain_queue_replays_total", "Queued retain replay attempts by outcome.", this.replays);
|
|
154
|
+
counter("retain_queue_drops_total", "Retains dropped from the queue by reason.", this.drops);
|
|
155
|
+
return `${lines.join("\n")}\n`;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function observe(h, ms) {
|
|
159
|
+
const seconds = Math.max(0, ms) / 1000;
|
|
160
|
+
LATENCY_BUCKETS.forEach((le, i) => {
|
|
161
|
+
if (seconds <= le) {
|
|
162
|
+
h.buckets[i] += 1;
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
h.sum += seconds;
|
|
166
|
+
h.count += 1;
|
|
167
|
+
}
|
|
168
|
+
// Mirrors diagnostics-prometheus: the route is gateway-auth, and read access is checked here so a
|
|
169
|
+
// scope-narrowed operator identity cannot read it.
|
|
170
|
+
const READ_SCOPES = ["operator.read", "operator.write", "operator.admin"];
|
|
171
|
+
function hasReadScope() {
|
|
172
|
+
const scopes = getPluginRuntimeGatewayRequestScope()?.client?.connect?.scopes;
|
|
173
|
+
return Array.isArray(scopes) && READ_SCOPES.some((scope) => scopes.includes(scope));
|
|
174
|
+
}
|
|
175
|
+
export function createMetricsRouteHandler(metrics, queueDepth, readScope = hasReadScope) {
|
|
176
|
+
return (req, res) => {
|
|
177
|
+
res.setHeader("Cache-Control", "no-store");
|
|
178
|
+
if (!readScope()) {
|
|
179
|
+
res.statusCode = 403;
|
|
180
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
181
|
+
res.end("missing scope: operator.read");
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
185
|
+
res.statusCode = 405;
|
|
186
|
+
res.setHeader("Allow", "GET, HEAD");
|
|
187
|
+
res.end("Method Not Allowed");
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
const body = metrics.render(queueDepth);
|
|
191
|
+
res.statusCode = 200;
|
|
192
|
+
res.setHeader("Content-Type", "text/plain; version=0.0.4; charset=utf-8");
|
|
193
|
+
res.setHeader("Content-Length", String(Buffer.byteLength(body, "utf8")));
|
|
194
|
+
res.end(req.method === "HEAD" ? undefined : body);
|
|
195
|
+
return true;
|
|
196
|
+
};
|
|
197
|
+
}
|
package/dist/provider-runtime.js
CHANGED
|
@@ -36,6 +36,7 @@ function sliceLines(text, from, lines) {
|
|
|
36
36
|
}
|
|
37
37
|
export function createHindsightProviderRuntime(params) {
|
|
38
38
|
const { providerId, service } = params;
|
|
39
|
+
const reference = (id) => ({ providerId, id });
|
|
39
40
|
return {
|
|
40
41
|
async open({ agentId: rawAgentId, context }) {
|
|
41
42
|
if (!rawAgentId?.trim()) {
|
|
@@ -57,7 +58,6 @@ export function createHindsightProviderRuntime(params) {
|
|
|
57
58
|
assertCurrent();
|
|
58
59
|
return grant.bankId;
|
|
59
60
|
};
|
|
60
|
-
const reference = (id) => ({ providerId, id });
|
|
61
61
|
const provider = {
|
|
62
62
|
capabilities: {
|
|
63
63
|
sources: ["memory"],
|
package/dist/retain-queue.js
CHANGED
|
@@ -80,6 +80,10 @@ function isQueuedRetain(value) {
|
|
|
80
80
|
v.item !== null &&
|
|
81
81
|
typeof v.item.content === "string");
|
|
82
82
|
}
|
|
83
|
+
/** One queued copy per bank and document id; JSON keeps ids with any characters apart. */
|
|
84
|
+
function queueKey(key) {
|
|
85
|
+
return JSON.stringify([key.bankId, key.documentId]);
|
|
86
|
+
}
|
|
83
87
|
/** The key of a tombstone line, written when a rewrite failed to drop an out-of-date copy. */
|
|
84
88
|
function tombstoneKey(value) {
|
|
85
89
|
const t = value?.tombstone;
|
|
@@ -87,6 +91,9 @@ function tombstoneKey(value) {
|
|
|
87
91
|
? { bankId: t.bankId, documentId: t.documentId }
|
|
88
92
|
: undefined;
|
|
89
93
|
}
|
|
94
|
+
function tombstoneLine(key) {
|
|
95
|
+
return JSON.stringify({ tombstone: { bankId: key.bankId, documentId: key.documentId } });
|
|
96
|
+
}
|
|
90
97
|
/** True when the file is empty or its last byte is a newline. */
|
|
91
98
|
function endsWithNewline(filePath) {
|
|
92
99
|
const fd = openSync(filePath, "r");
|
|
@@ -109,7 +116,10 @@ function stored(entry, line = JSON.stringify(entry)) {
|
|
|
109
116
|
/**
|
|
110
117
|
* Bounded, file-backed FIFO of failed auto-retains, one JSON line per item. One entry per bank and
|
|
111
118
|
* document id: a newer retain of the same document replaces the queued one in place. A new item is
|
|
112
|
-
* appended to the file; replacing, pruning and replay rewrite it (mode 600, atomic rename).
|
|
119
|
+
* appended to the file; replacing, pruning and replay rewrite it (mode 600, atomic rename). When a
|
|
120
|
+
* rewrite fails, the item is appended instead (`load()` keeps the newest copy of each document). An
|
|
121
|
+
* append that would push the file past the size bound goes through a rewrite; when both fail, the
|
|
122
|
+
* caller sends the item live.
|
|
113
123
|
*/
|
|
114
124
|
export class RetainQueue {
|
|
115
125
|
options;
|
|
@@ -117,12 +127,16 @@ export class RetainQueue {
|
|
|
117
127
|
dropWarned = false;
|
|
118
128
|
/** Removals not yet written (replay batches its writes). */
|
|
119
129
|
dirty = false;
|
|
130
|
+
/** Bytes in the file as this queue last read or wrote it; appends grow it, rewrites reset it. */
|
|
131
|
+
fileBytes = 0;
|
|
120
132
|
limits;
|
|
121
133
|
logger;
|
|
134
|
+
onDrop;
|
|
122
135
|
constructor(options) {
|
|
123
136
|
this.options = options;
|
|
124
137
|
this.limits = options.limits;
|
|
125
138
|
this.logger = options.logger;
|
|
139
|
+
this.onDrop = options.onDrop;
|
|
126
140
|
}
|
|
127
141
|
get filePath() {
|
|
128
142
|
return this.options.filePath;
|
|
@@ -131,6 +145,7 @@ export class RetainQueue {
|
|
|
131
145
|
configure(params) {
|
|
132
146
|
this.limits = params.limits;
|
|
133
147
|
this.logger = params.logger;
|
|
148
|
+
this.onDrop = params.onDrop;
|
|
134
149
|
}
|
|
135
150
|
now() {
|
|
136
151
|
return this.options.now?.() ?? Date.now();
|
|
@@ -150,13 +165,18 @@ export class RetainQueue {
|
|
|
150
165
|
catch (error) {
|
|
151
166
|
if (error.code === "ENOENT") {
|
|
152
167
|
this.stored = [];
|
|
168
|
+
this.fileBytes = 0;
|
|
153
169
|
return;
|
|
154
170
|
}
|
|
155
171
|
throw error;
|
|
156
172
|
}
|
|
157
|
-
|
|
173
|
+
this.fileBytes = Buffer.byteLength(text, "utf8");
|
|
174
|
+
// Keyed by bank and document id in file order. A later copy wins and takes the later place:
|
|
175
|
+
// a crash between replay's batched saves leaves a delivered copy before a newer append.
|
|
176
|
+
const loaded = new Map();
|
|
158
177
|
let skipped = 0;
|
|
159
178
|
let tombstones = 0;
|
|
179
|
+
let duplicates = 0;
|
|
160
180
|
for (const line of text.split("\n")) {
|
|
161
181
|
if (!line.trim()) {
|
|
162
182
|
continue;
|
|
@@ -164,13 +184,17 @@ export class RetainQueue {
|
|
|
164
184
|
try {
|
|
165
185
|
const entry = JSON.parse(line);
|
|
166
186
|
if (isQueuedRetain(entry)) {
|
|
167
|
-
|
|
187
|
+
const key = queueKey(entry);
|
|
188
|
+
if (loaded.delete(key)) {
|
|
189
|
+
duplicates += 1;
|
|
190
|
+
}
|
|
191
|
+
loaded.set(key, stored(entry, line));
|
|
168
192
|
continue;
|
|
169
193
|
}
|
|
170
194
|
const dead = tombstoneKey(entry);
|
|
171
195
|
if (dead) {
|
|
172
|
-
// Drops the earlier
|
|
173
|
-
loaded
|
|
196
|
+
// Drops the earlier copy the tombstone was written for.
|
|
197
|
+
loaded.delete(queueKey(dead));
|
|
174
198
|
tombstones += 1;
|
|
175
199
|
continue;
|
|
176
200
|
}
|
|
@@ -180,11 +204,11 @@ export class RetainQueue {
|
|
|
180
204
|
}
|
|
181
205
|
skipped += 1;
|
|
182
206
|
}
|
|
183
|
-
this.stored = loaded;
|
|
207
|
+
this.stored = [...loaded.values()];
|
|
184
208
|
if (skipped > 0) {
|
|
185
209
|
this.logger.warn?.(`memory-hindsight: retain queue skipped ${skipped} unreadable line(s)`);
|
|
186
210
|
}
|
|
187
|
-
if (this.prune(false) > 0 || skipped > 0 || tombstones > 0) {
|
|
211
|
+
if (this.prune(false) > 0 || skipped > 0 || tombstones > 0 || duplicates > 0) {
|
|
188
212
|
try {
|
|
189
213
|
this.persist();
|
|
190
214
|
}
|
|
@@ -203,6 +227,21 @@ export class RetainQueue {
|
|
|
203
227
|
peek() {
|
|
204
228
|
return this.stored[0]?.entry;
|
|
205
229
|
}
|
|
230
|
+
/**
|
|
231
|
+
* True when the item alone is over the size bound, so `enqueue` refuses it. Not reported as a
|
|
232
|
+
* drop here: the caller may still send it live, and reports it only when it is lost.
|
|
233
|
+
*/
|
|
234
|
+
isTooLarge(entry) {
|
|
235
|
+
return stored({ ...entry, queuedAt: this.now() }).size > this.maxBytes();
|
|
236
|
+
}
|
|
237
|
+
/** Queued items per bank id. */
|
|
238
|
+
depthByBank() {
|
|
239
|
+
const depth = new Map();
|
|
240
|
+
for (const { entry } of this.stored) {
|
|
241
|
+
depth.set(entry.bankId, (depth.get(entry.bankId) ?? 0) + 1);
|
|
242
|
+
}
|
|
243
|
+
return depth;
|
|
244
|
+
}
|
|
206
245
|
/** Bank and document id of every queued item, oldest first. */
|
|
207
246
|
list() {
|
|
208
247
|
return this.stored.map(({ entry }) => ({ bankId: entry.bankId, documentId: entry.documentId }));
|
|
@@ -241,24 +280,65 @@ export class RetainQueue {
|
|
|
241
280
|
else {
|
|
242
281
|
this.stored.push(next);
|
|
243
282
|
}
|
|
283
|
+
let rewriteError;
|
|
244
284
|
try {
|
|
245
|
-
|
|
285
|
+
// Drops are reported only once the write that makes them stick succeeds; the fallback below
|
|
286
|
+
// restores memory and prunes again, and reports then.
|
|
287
|
+
const pruned = this.pruneStored();
|
|
288
|
+
if (pruned.length === 0 &&
|
|
289
|
+
index < 0 &&
|
|
290
|
+
existsSync(this.filePath) &&
|
|
291
|
+
this.canAppend(next.size)) {
|
|
246
292
|
this.append(next.line);
|
|
247
293
|
return true;
|
|
248
294
|
}
|
|
249
295
|
this.persist();
|
|
296
|
+
this.reportDrops(pruned);
|
|
297
|
+
// False when the bound dropped the item just added or replaced.
|
|
298
|
+
return this.stored.includes(next);
|
|
250
299
|
}
|
|
251
300
|
catch (error) {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
301
|
+
rewriteError = error;
|
|
302
|
+
}
|
|
303
|
+
// The rewrite failed. load() keeps the newest copy per bank and document id, so appending the
|
|
304
|
+
// new copy also replaces an older one in the file; the byte check keeps the file bounded.
|
|
305
|
+
const kept = index >= 0 ? before.filter((_, i) => i !== index) : before;
|
|
306
|
+
// Memory becomes what load() gives for the file plus the appended text: the new copy after the
|
|
307
|
+
// older one, and a tombstone for every item the bounds drop, so no dropped line can come back
|
|
308
|
+
// on a restart (say, after a later tombstone brings the count under maxItems).
|
|
309
|
+
this.stored = [...kept, next];
|
|
310
|
+
// Reported only if the append below sticks; otherwise memory goes back to `kept`.
|
|
311
|
+
const prunedAgain = this.pruneStored();
|
|
312
|
+
const keepsNext = this.stored.includes(next);
|
|
313
|
+
const dead = kept.filter((s) => !this.stored.includes(s)).map((s) => s.entry);
|
|
314
|
+
if (!keepsNext && index >= 0) {
|
|
315
|
+
dead.push(before[index].entry);
|
|
316
|
+
}
|
|
317
|
+
const lines = dead.map((e) => tombstoneLine(e));
|
|
318
|
+
if (keepsNext) {
|
|
319
|
+
lines.push(next.line);
|
|
320
|
+
}
|
|
321
|
+
const text = lines.join("\n");
|
|
322
|
+
// One more byte for the trailing newline, as Stored.size counts it.
|
|
323
|
+
if (existsSync(this.filePath) && this.canAppend(Buffer.byteLength(text, "utf8") + 1)) {
|
|
324
|
+
try {
|
|
325
|
+
if (text) {
|
|
326
|
+
this.append(text);
|
|
327
|
+
}
|
|
328
|
+
this.reportDrops(prunedAgain);
|
|
329
|
+
return keepsNext;
|
|
257
330
|
}
|
|
258
|
-
|
|
331
|
+
catch {
|
|
332
|
+
// Neither write worked; handled below.
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
// A queued copy of the same document is out of date now (the caller sends the newer one live),
|
|
336
|
+
// so it must not be replayed over it: not from memory, and not after a restart.
|
|
337
|
+
this.stored = kept;
|
|
338
|
+
if (index >= 0) {
|
|
339
|
+
this.markStale(before[index].entry);
|
|
259
340
|
}
|
|
260
|
-
|
|
261
|
-
return this.stored.includes(next);
|
|
341
|
+
throw rewriteError;
|
|
262
342
|
}
|
|
263
343
|
/**
|
|
264
344
|
* Removes the entry when it is still the queued copy (a newer enqueue replaced it otherwise).
|
|
@@ -289,9 +369,17 @@ export class RetainQueue {
|
|
|
289
369
|
* stays quiet until the queue is empty again.
|
|
290
370
|
*/
|
|
291
371
|
prune(persist = true) {
|
|
372
|
+
const dropped = this.pruneStored();
|
|
373
|
+
this.reportDrops(dropped);
|
|
374
|
+
if (dropped.length > 0 && persist) {
|
|
375
|
+
this.persist();
|
|
376
|
+
}
|
|
377
|
+
return dropped.length;
|
|
378
|
+
}
|
|
379
|
+
/** The bounds step of `prune()`: updates memory and warns, but neither reports nor writes. */
|
|
380
|
+
pruneStored() {
|
|
292
381
|
const { maxItems, maxAgeMs } = this.limits();
|
|
293
382
|
const cutoff = this.now() - maxAgeMs;
|
|
294
|
-
const before = this.stored.length;
|
|
295
383
|
let kept = this.stored.filter((s) => s.entry.queuedAt >= cutoff);
|
|
296
384
|
if (kept.length > maxItems) {
|
|
297
385
|
kept = kept.slice(kept.length - maxItems);
|
|
@@ -300,29 +388,34 @@ export class RetainQueue {
|
|
|
300
388
|
while (kept.length > 0 && bytes > this.maxBytes()) {
|
|
301
389
|
bytes -= kept.shift().size;
|
|
302
390
|
}
|
|
391
|
+
const keptSet = new Set(kept);
|
|
392
|
+
const dropped = this.stored.filter((s) => !keptSet.has(s));
|
|
303
393
|
this.stored = kept;
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
this.dropWarned = true;
|
|
308
|
-
this.logger.warn?.(`memory-hindsight: retain queue dropped ${dropped} item(s) over retainQueueMaxItems (${maxItems}), retainQueueMaxAgeMs (${maxAgeMs}) or the ${this.maxBytes()}-byte size bound; further drops are not logged until the queue is empty`);
|
|
309
|
-
}
|
|
310
|
-
if (persist) {
|
|
311
|
-
this.persist();
|
|
312
|
-
}
|
|
394
|
+
if (dropped.length > 0 && !this.dropWarned) {
|
|
395
|
+
this.dropWarned = true;
|
|
396
|
+
this.logger.warn?.(`memory-hindsight: retain queue dropped ${dropped.length} item(s) over retainQueueMaxItems (${maxItems}), retainQueueMaxAgeMs (${maxAgeMs}) or the ${this.maxBytes()}-byte size bound; further drops are not logged until the queue is empty`);
|
|
313
397
|
}
|
|
314
398
|
return dropped;
|
|
315
399
|
}
|
|
400
|
+
reportDrops(dropped) {
|
|
401
|
+
for (const s of dropped) {
|
|
402
|
+
this.onDrop?.(s.entry.bankId, "limit");
|
|
403
|
+
}
|
|
404
|
+
}
|
|
316
405
|
ensureDir() {
|
|
317
406
|
const dir = dirname(this.filePath);
|
|
318
407
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
319
408
|
chmodSync(dir, 0o700);
|
|
320
409
|
}
|
|
410
|
+
/** No append that would take the file past the size bound (one byte for a torn line's newline). */
|
|
411
|
+
canAppend(size) {
|
|
412
|
+
return this.fileBytes + size + 1 <= this.maxBytes();
|
|
413
|
+
}
|
|
321
414
|
append(line) {
|
|
322
415
|
// A torn last line (crash mid-append) must not swallow this item: start on a fresh line.
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
416
|
+
const text = `${endsWithNewline(this.filePath) ? "" : "\n"}${line}\n`;
|
|
417
|
+
appendFileSync(this.filePath, text, { mode: 0o600 });
|
|
418
|
+
this.fileBytes += Buffer.byteLength(text, "utf8");
|
|
326
419
|
try {
|
|
327
420
|
chmodSync(this.filePath, 0o600);
|
|
328
421
|
}
|
|
@@ -336,25 +429,32 @@ export class RetainQueue {
|
|
|
336
429
|
*/
|
|
337
430
|
markStale(entry) {
|
|
338
431
|
try {
|
|
339
|
-
this.append(
|
|
432
|
+
this.append(tombstoneLine(entry));
|
|
340
433
|
}
|
|
341
434
|
catch (error) {
|
|
342
435
|
this.logger.warn?.(`memory-hindsight: could not mark an out-of-date queued retain (${formatErrorMessage(error)}); a restart may replay it before the newer copy is written again`);
|
|
343
436
|
}
|
|
344
437
|
}
|
|
345
438
|
persist() {
|
|
439
|
+
this.rewrite();
|
|
440
|
+
// Cleared only once written, so a failed replay save is retried by the next save().
|
|
346
441
|
this.dirty = false;
|
|
442
|
+
}
|
|
443
|
+
rewrite() {
|
|
347
444
|
if (this.stored.length === 0) {
|
|
348
445
|
rmSync(this.filePath, { force: true });
|
|
446
|
+
this.fileBytes = 0;
|
|
349
447
|
this.dropWarned = false;
|
|
350
448
|
return;
|
|
351
449
|
}
|
|
352
450
|
this.ensureDir();
|
|
353
451
|
const tmp = `${this.filePath}.tmp`;
|
|
354
|
-
|
|
452
|
+
const text = `${this.stored.map((s) => s.line).join("\n")}\n`;
|
|
453
|
+
writeFileSync(tmp, text, { mode: 0o600 });
|
|
355
454
|
// writeFileSync keeps the mode of an existing file; force it.
|
|
356
455
|
chmodSync(tmp, 0o600);
|
|
357
456
|
renameSync(tmp, this.filePath);
|
|
457
|
+
this.fileBytes = Buffer.byteLength(text, "utf8");
|
|
358
458
|
}
|
|
359
459
|
}
|
|
360
460
|
/**
|
|
@@ -459,6 +559,7 @@ export class RetainQueueRunner {
|
|
|
459
559
|
return delivered;
|
|
460
560
|
}
|
|
461
561
|
if (!isDroppingRejection(error)) {
|
|
562
|
+
this.options.onReplay?.(entry.bankId, "deferred");
|
|
462
563
|
// Not an answer about this item (outage, auth, rate limit, ambiguous 404, refused
|
|
463
564
|
// redirect, or an unexpected error): keep it and back off.
|
|
464
565
|
if (!isTransportFailure(error) && !this.rejectionWarned) {
|
|
@@ -473,6 +574,7 @@ export class RetainQueueRunner {
|
|
|
473
574
|
}
|
|
474
575
|
// Hindsight answered 4xx about this item: retrying cannot help, so it leaves the queue.
|
|
475
576
|
this.queue.remove(entry, { persist: false });
|
|
577
|
+
this.options.onDrop?.(entry.bankId, "rejected");
|
|
476
578
|
logger.warn?.(`memory-hindsight: queued retain rejected and dropped: ${formatErrorMessage(error)}`);
|
|
477
579
|
continue;
|
|
478
580
|
}
|
|
@@ -481,6 +583,7 @@ export class RetainQueueRunner {
|
|
|
481
583
|
return delivered;
|
|
482
584
|
}
|
|
483
585
|
this.queue.remove(entry, { persist: false });
|
|
586
|
+
this.options.onReplay?.(entry.bankId, "delivered");
|
|
484
587
|
this.rejectionWarned = false;
|
|
485
588
|
delivered += 1;
|
|
486
589
|
if (delivered % SAVE_EVERY === 0) {
|
package/dist/service.js
CHANGED
|
@@ -3,6 +3,12 @@ import { boundedSignal, HindsightClient } from "./client.js";
|
|
|
3
3
|
import { RETAIN_TIMEOUT_MS } from "./config.js";
|
|
4
4
|
import { BankSetupError, isDroppingRejection, isTransportFailure, RetainQueueRunner, retainQueuePath, sharedRetainQueue, } from "./retain-queue.js";
|
|
5
5
|
export function createHindsightService(params) {
|
|
6
|
+
const { metrics } = params;
|
|
7
|
+
// Queue and replay observers feed the gateway metrics; none when the caller passes no metrics.
|
|
8
|
+
const queueObserver = metrics ? { onDrop: metrics.recordDrop.bind(metrics) } : {};
|
|
9
|
+
const runnerObserver = metrics
|
|
10
|
+
? { onDrop: metrics.recordDrop.bind(metrics), onReplay: metrics.recordReplay.bind(metrics) }
|
|
11
|
+
: {};
|
|
6
12
|
const preparedBanks = new Map();
|
|
7
13
|
let stopped = false;
|
|
8
14
|
let runner;
|
|
@@ -71,6 +77,10 @@ export function createHindsightService(params) {
|
|
|
71
77
|
// Read again after the await: the service may have stopped or restarted meanwhile.
|
|
72
78
|
const target = queue;
|
|
73
79
|
if (!target || refused || !isTransportFailure(error)) {
|
|
80
|
+
// Queue-first path: the queue refused the item and the live write failed, so it is lost.
|
|
81
|
+
if (target && refused && isTransportFailure(error) && target.isTooLarge(entry)) {
|
|
82
|
+
metrics?.recordDrop(bankId, "too_large");
|
|
83
|
+
}
|
|
74
84
|
throw error;
|
|
75
85
|
}
|
|
76
86
|
let queued;
|
|
@@ -82,6 +92,11 @@ export function createHindsightService(params) {
|
|
|
82
92
|
throw error;
|
|
83
93
|
}
|
|
84
94
|
if (!queued) {
|
|
95
|
+
// Lost: the live write failed and the queue refused it. A bound that dropped the new item
|
|
96
|
+
// is already counted as `limit` by the queue.
|
|
97
|
+
if (target.isTooLarge(entry)) {
|
|
98
|
+
metrics?.recordDrop(bankId, "too_large");
|
|
99
|
+
}
|
|
85
100
|
throw error;
|
|
86
101
|
}
|
|
87
102
|
params.logger.warn?.(`memory-hindsight: Hindsight unreachable, retain queued (${target.size()} pending): ${formatErrorMessage(error)}`);
|
|
@@ -109,7 +124,11 @@ export function createHindsightService(params) {
|
|
|
109
124
|
return { maxItems: config.retainQueueMaxItems, maxAgeMs: config.retainQueueMaxAgeMs };
|
|
110
125
|
};
|
|
111
126
|
try {
|
|
112
|
-
queue = sharedRetainQueue(retainQueuePath(stateDir), {
|
|
127
|
+
queue = sharedRetainQueue(retainQueuePath(stateDir), {
|
|
128
|
+
limits,
|
|
129
|
+
logger: params.logger,
|
|
130
|
+
...queueObserver,
|
|
131
|
+
});
|
|
113
132
|
}
|
|
114
133
|
catch (error) {
|
|
115
134
|
queue = undefined;
|
|
@@ -126,9 +145,12 @@ export function createHindsightService(params) {
|
|
|
126
145
|
// A setup 4xx is about the bank config, not this item: keep it (see BankSetupError).
|
|
127
146
|
throw isDroppingRejection(error) ? new BankSetupError(error) : error;
|
|
128
147
|
}
|
|
129
|
-
|
|
148
|
+
// Replay may repeat a delivered item (a crash before the batched save); a replace of
|
|
149
|
+
// the same document id makes the repeat a no-op on the server.
|
|
150
|
+
await client.retain(entry.bankId, { ...entry.item, document_id: entry.documentId, update_mode: "replace" }, { signal });
|
|
130
151
|
},
|
|
131
152
|
logger: params.logger,
|
|
153
|
+
...runnerObserver,
|
|
132
154
|
...params.retainQueueRetry,
|
|
133
155
|
});
|
|
134
156
|
if (queue.size() > 0) {
|
|
@@ -137,6 +159,7 @@ export function createHindsightService(params) {
|
|
|
137
159
|
}
|
|
138
160
|
},
|
|
139
161
|
retainQueue: () => runner,
|
|
162
|
+
retainQueueDepth: () => queue?.depthByBank(),
|
|
140
163
|
stopped: () => stopped,
|
|
141
164
|
stop() {
|
|
142
165
|
stopped = true;
|