@camstack/system 1.2.102 → 1.2.104

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.
Files changed (62) hide show
  1. package/dist/addon-runner.js +15 -1
  2. package/dist/addon-runner.mjs +15 -1
  3. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  4. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  5. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  6. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  7. package/dist/builtins/alerts/alerts.addon.js +1 -1
  8. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  9. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
  10. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
  11. package/dist/builtins/console-logging/index.js +1 -1
  12. package/dist/builtins/console-logging/index.mjs +1 -1
  13. package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
  14. package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
  15. package/dist/builtins/device-manager/device-manager.addon.js +1 -1
  16. package/dist/builtins/device-manager/device-manager.addon.mjs +1 -1
  17. package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
  18. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
  19. package/dist/builtins/hub-forwarder/index.js +1 -1
  20. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  21. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  22. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  23. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  24. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  25. package/dist/builtins/local-network/local-network.addon.js +1 -1
  26. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  27. package/dist/builtins/loki-logging/index.js +1 -1
  28. package/dist/builtins/loki-logging/index.mjs +1 -1
  29. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  30. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  31. package/dist/builtins/platform-probe/index.js +1 -1
  32. package/dist/builtins/platform-probe/index.mjs +1 -1
  33. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  34. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  35. package/dist/builtins/snapshot/index.js +150 -22
  36. package/dist/builtins/snapshot/index.mjs +150 -22
  37. package/dist/builtins/snapshot/snapshot-cache.d.ts +63 -0
  38. package/dist/builtins/snapshot/snapshot-resize.d.ts +33 -1
  39. package/dist/builtins/snapshot/snapshot.addon.d.ts +6 -0
  40. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  41. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  42. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +1 -1
  43. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +1 -1
  44. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  45. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  46. package/dist/builtins/system-config/system-config.addon.js +1 -1
  47. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  48. package/dist/builtins/winston-logging/index.js +1 -1
  49. package/dist/builtins/winston-logging/index.mjs +1 -1
  50. package/dist/{dist-3wh5LCad.mjs → dist-CAzHQ03i.mjs} +144 -21
  51. package/dist/{dist-9sC0wZw6.js → dist-DR1VmGz6.js} +144 -21
  52. package/dist/index.d.ts +2 -2
  53. package/dist/index.js +243 -328
  54. package/dist/index.mjs +233 -319
  55. package/dist/kernel/config-schema.d.ts +14 -10
  56. package/dist/kernel/heap-watch.d.ts +106 -7
  57. package/dist/kernel/moleculer/process-service.d.ts +34 -0
  58. package/dist/logging/log-manager.d.ts +7 -5
  59. package/dist/logging/partitioned-log-buffer.d.ts +95 -16
  60. package/dist/{manifest-python-deps-Bt_J04b0.mjs → manifest-python-deps-5ei2xVUh.mjs} +355 -1
  61. package/dist/{manifest-python-deps-BtVjvfvj.js → manifest-python-deps-GejnH--L.js} +422 -0
  62. package/package.json +1 -1
@@ -85,18 +85,22 @@ export type AppConfig = BootstrapConfig & {
85
85
  */
86
86
  perAddonLogBufferSize?: number;
87
87
  /**
88
- * SOFT ceiling on retained entries across every addon bucket. `null` (the
89
- * default) means the per-addon rings are the only bound — the historical
90
- * behaviour, unchanged unless you set this.
88
+ * Ceiling on retained entries across every addon bucket.
91
89
  *
92
- * Set it when `perAddonLogBufferSize × addons` is more than the box can spare:
93
- * 70 addons × 5000 is 350k retained entries, which is a lot on a mini PC.
90
+ * UNSET (the normal case) takes `DEFAULT_MAX_TOTAL_LOG_ENTRIES` the buffer is
91
+ * bounded in aggregate out of the box. It has to be: `perAddonLogBufferSize ×
92
+ * addons` grows with the roster, and hub-main ingests from every local runner
93
+ * AND every agent, so the measured ~60-bucket hub retained ~300k entries
94
+ * (~92MB at ~306 B/entry) with no aggregate bound at all.
94
95
  *
95
- * Soft, and deliberately so: it is met by discarding only entries at or below
96
- * `logBufferPruneLevel`. If pruning every prunable entry still leaves the total
97
- * above this number, the buffer stays above it. A cap that could evict an
98
- * `error` would trade the lines that explain an incident for lines about frame
99
- * timings.
96
+ * Set a number to tune it. Set an explicit `null` to opt out entirely — the
97
+ * per-addon rings then become the only bound again.
98
+ *
99
+ * It is met by discarding entries at or below `logBufferPruneLevel` FIRST, so a
100
+ * live addon's `warn`/`error` are never traded for lines about frame timings.
101
+ * Only when that cannot reach the target are whole least-recently-WRITTEN
102
+ * buckets dropped — a bound that pruning alone cannot hold is not a bound, and
103
+ * the bucket nobody has written to is the cheapest thing in the buffer to lose.
100
104
  */
101
105
  maxTotalLogBufferSize?: number | null;
102
106
  /**
@@ -59,14 +59,46 @@ export declare function nextMode(current: HeapWatchMode, usedRatio: number, esca
59
59
  * Live calibration, both readings from the same hub minutes apart: 2491MB
60
60
  * stranded before the reclaim, 438MB after. 438MB is this process's irreducible
61
61
  * native floor (sqlite pages, libvips, sharp, thread stacks, node's own text) —
62
- * collecting against it would buy nothing and cost a stall every minute. 1536
63
- * sits clear of that floor and still fires ~1.1GB before the ratchet is worth
64
- * anything, so it triggers on the real condition and stops as soon as it is fixed.
62
+ * collecting against it would buy nothing and cost a stall every minute.
63
+ *
64
+ * ## Why this came down from 1536 (2026-08-17)
65
+ *
66
+ * The trigger was originally set to fire only on an unmistakable strand. It
67
+ * does, and the pass on the record works exactly as designed:
68
+ *
69
+ * [mem] reclaim hub-main stranded=2365MB rss=4016MB→2368MB freed=1648MB took=654ms
70
+ *
71
+ * But the number it was protecting against changed. The host now runs with a
72
+ * 24GiB cgroup ceiling and **no swap**, and is OOM-killing a neighbouring VM.
73
+ * RSS this process is holding and not using is no longer merely untidy — it is
74
+ * the headroom something else needed, and the OOM is triggered by the PEAK, not
75
+ * by the average. A trigger of 1536 means the peak is allowed to stand 1.5GiB
76
+ * above the floor before anything happens.
77
+ *
78
+ * 1024 is 2.3x the measured 438MB floor, so ordinary movement around the floor
79
+ * can never trip it, and every pass that does fire still returns roughly
80
+ * `1024 - 438` ≈ **580MB minimum** — a real return for a 250-650ms stall,
81
+ * not a stall spent collecting noise.
65
82
  */
66
- export declare const HEAP_RECLAIM_TRIGGER_MB = 1536;
67
- /** Floor on how often the stop-the-world may run. 250-450ms measured, so ten
68
- * minutes keeps the worst case under 0.1% of the router's time. */
69
- export declare const HEAP_RECLAIM_MIN_INTERVAL_MS = 600000;
83
+ export declare const HEAP_RECLAIM_TRIGGER_MB = 1024;
84
+ /**
85
+ * Floor on how often the stop-the-world may run.
86
+ *
87
+ * Ten minutes was chosen to keep the worst case under 0.1% of the router's
88
+ * time. The worst case is not what governs the cost, though: the reclaim only
89
+ * runs when {@link shouldReclaim} says the process is genuinely stranded, and
90
+ * after a pass it is at the ~438MB floor. Live off-heap growth was measured at
91
+ * roughly 3GB/2h ≈ 25MB/min, so re-stranding the ~580MB needed to qualify again
92
+ * takes on the order of **20 minutes** — well outside any floor considered
93
+ * here. In steady state this constant therefore costs nothing at all.
94
+ *
95
+ * What it does govern is a BURST, which is the only case that matters on a host
96
+ * with no swap: the ten-minute floor let a peak that re-formed in seconds stand
97
+ * for ten minutes. Two minutes bounds that, and even the pathological case of a
98
+ * process that re-strands instantly costs 654ms per 120s — 0.55% of the
99
+ * router's time, against a ~1.5GiB peak it removes each pass.
100
+ */
101
+ export declare const HEAP_RECLAIM_MIN_INTERVAL_MS = 120000;
70
102
  export interface HeapSample {
71
103
  readonly rssMb: number;
72
104
  readonly heapUsedMb: number;
@@ -136,3 +168,70 @@ export interface HeapReclaimOptions {
136
168
  * a diagnostic that can crash the process it watches is worse than none.
137
169
  */
138
170
  export declare function startHeapWatch(label?: string, sink?: HeapWatchSink, intervalMs?: number, reclaimOptions?: HeapReclaimOptions): () => void;
171
+ /**
172
+ * Steady heartbeat period for a HEAVY addon runner.
173
+ *
174
+ * hub-main logs once a minute because it is ONE process. The heavy runners are
175
+ * ~7 per node sharing the same hub log stream; at 60s each they would put 7
176
+ * routine lines a minute in it forever, which is how a log stops being read.
177
+ * Five minutes keeps the per-runner record (a leak is still a visible climb
178
+ * across samples) at ~1.4 lines/min across the class.
179
+ *
180
+ * This slows LOGGING only. The probe keeps running at
181
+ * {@link HEAP_WATCH_FAST_INTERVAL_MS} (probe and log cadences are separate —
182
+ * see the comment inside {@link startHeapWatch}), so the reclaim gate and the
183
+ * escalated burst-sampling react exactly as fast as hub-main's.
184
+ */
185
+ export declare const RUNNER_HEAP_WATCH_INTERVAL_MS = 300000;
186
+ /** Wiring for {@link startRunnerHeapWatch}. */
187
+ export interface RunnerHeapWatchOptions {
188
+ /**
189
+ * `CAMSTACK_RUNNER_HEAP_PROFILE` as the parent passed it. Anything but
190
+ * `'heavy'` (including unset — the un-annotated default is light) starts
191
+ * nothing: light runners are small, already carry V8 heap flags, and a
192
+ * quiet runner must never pay a stop-the-world.
193
+ */
194
+ readonly heapProfile: string | undefined;
195
+ /** Log label — the runner's nodeId, so `[mem]` lines group per runner. */
196
+ readonly label: string;
197
+ readonly sink: HeapWatchSink;
198
+ /**
199
+ * `CAMSTACK_RUNNER_HEAP_RECLAIM` as the environment carries it. `'off'`
200
+ * keeps the heartbeat and disables only the reclaim pass — the operator
201
+ * kill-switch for the media path, usable without a rebuild.
202
+ */
203
+ readonly reclaimSwitch?: string;
204
+ /** Heartbeat period override. Defaults to {@link RUNNER_HEAP_WATCH_INTERVAL_MS}. */
205
+ readonly intervalMs?: number;
206
+ /** Test injection. Production omits it and gets {@link createV8Reclaimer}. */
207
+ readonly reclaimOptions?: HeapReclaimOptions;
208
+ }
209
+ /**
210
+ * Start the heap watch + stranded-page reclaimer for ONE addon runner, iff it
211
+ * is a heavy one. Returns the stop function, or undefined when nothing started.
212
+ *
213
+ * ## Why heavy runners get hub-main's mechanism
214
+ *
215
+ * The media-path runners deliberately run with NO old-space ceiling
216
+ * (`maxOldSpaceMb: 0` — a flat cap turned a disk-stall backlog into
217
+ * `Ineffective mark-compacts near heap limit` and killed recording, twice on
218
+ * 2026-08-17). Uncapped, V8 feels no pressure, so a one-off burst becomes the
219
+ * runner's permanent RSS: the recorder measured 2122MB holding a ~50MB index.
220
+ * hub-main had exactly this shape and its reclaimer is proven live — 11 passes
221
+ * in 4h, 375–1652MB returned per pass at 550–680ms each. Same mechanism, same
222
+ * `shouldReclaim` stranded-over-trigger gate; never a second implementation.
223
+ *
224
+ * ## The stall, priced for the runners that get it
225
+ *
226
+ * A pass is a sub-second stop-the-world at most once per
227
+ * {@link HEAP_RECLAIM_MIN_INTERVAL_MS}, and only while >1GiB is stranded. For
228
+ * the recorder — finalizing a segment every ~300ms across 34 profile-writers —
229
+ * the pause delays WATCHER work, not video: ffmpeg children keep writing to
230
+ * disk independently, the playlist tail is the durable queue, and the next
231
+ * tick drains what accrued. For stream-broker a pass can hitch live frames
232
+ * once per qualifying window; after a pass the runner sits at its floor and
233
+ * re-stranding the trigger takes tens of minutes, so in practice the class
234
+ * pays a few passes per hour against the multi-GiB peak that was OOM-killing
235
+ * a neighbouring VM on a host with no swap.
236
+ */
237
+ export declare function startRunnerHeapWatch(options: RunnerHeapWatchOptions): (() => void) | undefined;
@@ -5,6 +5,40 @@ interface RunnerAddonSpec {
5
5
  readonly addonId: string;
6
6
  readonly addonDir: string;
7
7
  }
8
+ /**
9
+ * Default old-space ceiling for a HEAVY runner, in MB.
10
+ *
11
+ * ## Why a heavy runner needs a ceiling at all
12
+ *
13
+ * `heapProfile: 'heavy'` used to mean "V8 defaults", i.e. no `--max-old-space-size` — which on this
14
+ * host is V8's own memory-derived default (multiple GB). Nothing then bounds a burst, and V8 does
15
+ * not hand committed pages back (the long argument for that is in `kernel/heap-watch.ts`), so a
16
+ * one-off burst becomes the runner's permanent RSS.
17
+ *
18
+ * Measured on the live hub, 2026-08-17: the `recorder` runner reaches **2122 MB RSS** and stays
19
+ * there after the deferred archive walk (99,809 segments in 84s), while the `RecordingIndex` it
20
+ * actually retains is arithmetically ~21 MB (a `SegmentRow` is 5 numbers + 3 strings ≈ 207 B). The
21
+ * same addon on two agent nodes carrying the same code and a smaller archive sits at **58 MB**.
22
+ * The ~2 GB is walk garbage held as high-water in a heap that had no reason to collect it.
23
+ *
24
+ * ## Why 1024
25
+ *
26
+ * From the same live topology snapshot, every heavy runner's FULL RSS — which is a strict
27
+ * over-estimate of its old space, since it also contains native allocations, `external` and
28
+ * `arrayBuffers`, none of which `--max-old-space-size` bounds:
29
+ *
30
+ * detection 471 MB · provider-reolink 507 MB · pipeline-orchestrator 238 MB · ml 218 MB ·
31
+ * snapshot 185 MB · decoder-nodeav 174 MB · audio-analyzer 170 MB
32
+ *
33
+ * 1024 MB clears the largest of those by ~2x while cutting the recorder's observed ratchet by
34
+ * more than half. It is a CEILING, not a target: a runner that fits today keeps behaving exactly
35
+ * as it does now, because V8 only collects harder as it approaches the limit.
36
+ *
37
+ * A runner whose working set genuinely does not fit declares its own number in its manifest
38
+ * (`execution.maxOldSpaceMb`) — the number belongs next to the addon that knows its working set.
39
+ * `stream-broker` (1323 MB measured, frame buffers) is the one shipped addon that does.
40
+ */
41
+ export declare const HEAVY_MAX_OLD_MB_DEFAULT = 1024;
8
42
  interface SpawnedProcess {
9
43
  readonly pid: number;
10
44
  readonly nodeId: string;
@@ -54,11 +54,13 @@ export declare class LogManager {
54
54
  * `perAddonCapacity` bounds EACH addon's bucket, not the total — a chatty addon
55
55
  * evicts only its own lines, so quiet addons keep their sparse history.
56
56
  *
57
- * `options.maxTotalEntries` adds a soft ceiling across all buckets, because the
58
- * per-bucket bound scales with addon count. When it is exceeded, only entries at
59
- * or below `options.pruneLevel` (default `debug`) are discarded `warn` and
60
- * `error` are never evicted to satisfy it. Both default to the previous
61
- * behaviour: no total cap. See {@link PartitionedLogBuffer}.
57
+ * `options.maxTotalEntries` ceilings the total across all buckets, because the
58
+ * per-bucket bound scales with addon count, and `options.maxBuckets` ceilings the
59
+ * bucket count itself. When the total is exceeded, entries at or below
60
+ * `options.pruneLevel` (default `debug`) are discarded first a live addon's
61
+ * `warn`/`error` are never evicted to satisfy it — and only if that cannot reach
62
+ * the target are whole least-recently-written buckets dropped. Both bounds are ON
63
+ * by default; pass an explicit `null` to opt out. See {@link PartitionedLogBuffer}.
62
64
  */
63
65
  constructor(perAddonCapacity?: number, options?: PartitionedLogBufferOptions);
64
66
  /** Entries currently retained across every bucket. */
@@ -1,14 +1,52 @@
1
1
  import { LogEntry, LogFilter, LogLevel } from '@camstack/types';
2
+ /**
3
+ * Default ceiling on retained entries across every bucket.
4
+ *
5
+ * This used to be `null` — no aggregate bound at all — and the only thing
6
+ * holding hub-main's log memory was `perAddonCapacity x buckets`, a product
7
+ * that grows with the roster. Measured on the live cluster: 37 forked runners
8
+ * on the hub plus its in-process builtins, and hub-main additionally ingests
9
+ * every line from both agents, so ~60 buckets is the ordinary case and
10
+ * 60 x 5000 = 300k retained entries.
11
+ *
12
+ * A realistic entry (timestamp, level, scope, ~70-char message, three tags, no
13
+ * `meta`) measures **~306 B retained** in V8. So the old unbounded case is
14
+ * ~92MB of a hub process that is being asked to fit inside a cgroup with a
15
+ * neighbour to protect, and 50_000 is **~15MB**.
16
+ *
17
+ * 50_000 across a ~60-bucket roster is ~830 entries per addon on average while
18
+ * still letting a single chatty addon hold its full 5000-entry ring — the
19
+ * partitioning is not weakened, only the total is held.
20
+ */
21
+ export declare const DEFAULT_MAX_TOTAL_LOG_ENTRIES = 50000;
22
+ /**
23
+ * Default ceiling on the NUMBER of buckets.
24
+ *
25
+ * Buckets were created on first write and never removed, so every addon id
26
+ * that ever logged — a removed addon, a renamed one, a one-off worker id —
27
+ * kept a ring alive for the life of the process. {@link DEFAULT_MAX_TOTAL_LOG_ENTRIES}
28
+ * alone does not fix that: the total sweep may only discard entries at or below
29
+ * `pruneLevel`, so a dead addon's 5000 `info` lines are untouchable by it.
30
+ *
31
+ * 128 is ~2x the ~60-bucket live roster, so no real deployment ever evicts on
32
+ * bucket count alone; it exists to make an unbounded id space impossible.
33
+ */
34
+ export declare const DEFAULT_MAX_LOG_BUCKETS = 128;
2
35
  export interface PartitionedLogBufferOptions {
3
36
  /** Hard ring size for EACH addon's bucket. */
4
37
  readonly perAddonCapacity?: number;
5
38
  /**
6
- * Soft ceiling on entries across all buckets. `null` (the default) means the
7
- * only bound is the per-addon rings.
39
+ * Soft ceiling on entries across all buckets. `null` means the only bound is
40
+ * the per-addon rings. Defaults to {@link DEFAULT_MAX_TOTAL_LOG_ENTRIES}.
8
41
  */
9
42
  readonly maxTotalEntries?: number | null;
10
43
  /** Highest severity the total-cap sweep may discard. Default `debug`. */
11
44
  readonly pruneLevel?: LogLevel;
45
+ /**
46
+ * Hard ceiling on the number of buckets. `null` means unbounded. Defaults to
47
+ * {@link DEFAULT_MAX_LOG_BUCKETS}.
48
+ */
49
+ readonly maxBuckets?: number | null;
12
50
  }
13
51
  /**
14
52
  * A log buffer partitioned into one fixed-capacity {@link LogRingBuffer} per
@@ -19,32 +57,48 @@ export interface PartitionedLogBufferOptions {
19
57
  * Drop-in for {@link LogRingBuffer} from {@link LogManager}'s perspective —
20
58
  * exposes the same `push` / `getAll` / `query` / `clear` surface.
21
59
  *
22
- * ## Two bounds, doing different jobs
60
+ * ## Three bounds, doing different jobs
23
61
  *
24
62
  * **`perAddonCapacity`** is a HARD bound: each bucket is a fixed-size ring, so
25
63
  * total memory can never exceed `perAddonCapacity × buckets` whatever else
26
64
  * happens. This is the safety net.
27
65
  *
28
- * **`maxTotalEntries`** is a SOFT target across all buckets, and it exists because
29
- * the hard bound scales with addon count — 70 addons × 5000 is a lot of retained
30
- * entries on a small box. When the total is exceeded, the sweep discards only
31
- * entries at or below `pruneLevel` (default `debug`), oldest first, from the
66
+ * **`maxTotalEntries`** targets the total across all buckets, and it exists because
67
+ * the hard bound scales with addon count — 60 buckets × 5000 was 300k retained
68
+ * entries on the live hub. When the total is exceeded, the sweep first discards
69
+ * only entries at or below `pruneLevel` (default `debug`), oldest first, from the
32
70
  * largest buckets first.
33
71
  *
34
- * It is deliberately soft. Meeting a total cap by discarding `warn` and `error`
35
- * would trade the entries that explain an incident for entries about frame
36
- * timings; if pruning every prunable entry still leaves the total above the cap,
37
- * the buffer stays above it and the hard per-bucket bound is what holds. A cap
38
- * that can evict an error is not a feature.
72
+ * It is deliberately soft ON A LIVE BUCKET. Meeting a total cap by discarding
73
+ * `warn` and `error` from an addon that is still logging would trade the entries
74
+ * that explain an incident for entries about frame timings. A cap that can evict
75
+ * a live addon's error is not a feature.
39
76
  *
40
77
  * Largest-bucket-first because the noisy addon is the one creating the pressure —
41
78
  * the same principle that motivated partitioning in the first place.
79
+ *
80
+ * **`maxBuckets`, and bucket eviction, are the hard backstop.** Pruning alone
81
+ * cannot bound this structure: it may only discard at or below `pruneLevel`, so
82
+ * a bucket full of `info` from an addon that no longer exists was immortal, and
83
+ * the roster the product scales with is not fixed. When pruning cannot reach the
84
+ * target — or the bucket count exceeds `maxBuckets` — whole buckets are dropped
85
+ * **least-recently-WRITTEN first**, and never the bucket currently being written
86
+ * to. That is precisely the dead addon's bucket, and it is precisely not the
87
+ * bucket of the addon whose incident is in progress.
42
88
  */
43
89
  export declare class PartitionedLogBuffer {
44
90
  private readonly buffers;
91
+ /**
92
+ * Bucket key → the write ordinal of its last `push`. A monotonic counter, not
93
+ * a clock: eviction order must follow the order writes actually happened, and
94
+ * a wall clock that steps backwards would pick the wrong victim.
95
+ */
96
+ private readonly lastWriteSeq;
97
+ private writeSeq;
45
98
  private perAddonCapacity;
46
99
  private maxTotalEntries;
47
100
  private pruneLevel;
101
+ private maxBuckets;
48
102
  /** Running total, so `push` does not walk every bucket to decide. */
49
103
  private totalEntries;
50
104
  constructor(perAddonCapacity?: number, options?: PartitionedLogBufferOptions);
@@ -59,18 +113,40 @@ export declare class PartitionedLogBuffer {
59
113
  readonly perAddonCapacity?: number;
60
114
  readonly maxTotalEntries?: number | null;
61
115
  readonly pruneLevel?: LogLevel;
116
+ readonly maxBuckets?: number | null;
62
117
  }): void;
63
118
  private bufferFor;
64
119
  push(entry: LogEntry): void;
65
120
  /**
66
- * Bring the total down to `PRUNE_TARGET_RATIO × cap` by discarding prunable
67
- * entries, largest bucket first. Stops early when nothing prunable is left —
68
- * see the class docblock on why that is the correct outcome rather than a
69
- * failure to enforce.
121
+ * Bring the total down to `PRUNE_TARGET_RATIO × cap`.
122
+ *
123
+ * Two passes, in this order and no other: first discard prunable entries,
124
+ * largest bucket first, which is free of information cost. Only if that
125
+ * cannot reach the target does the second pass drop WHOLE buckets,
126
+ * least-recently-written first — the bound has to hold even when every
127
+ * retained entry is above `pruneLevel`, and a bucket nobody has written to is
128
+ * the cheapest thing in the structure to lose.
70
129
  */
71
130
  private enforceTotalCap;
131
+ /**
132
+ * Drop whole buckets, least-recently-WRITTEN first, until the total is at or
133
+ * below `target`. Never touches `protectedKey` (the bucket being written to
134
+ * right now) and always leaves at least one bucket standing.
135
+ */
136
+ private evictBucketsUntil;
137
+ /** Drop exactly `count` buckets, least-recently-written first. */
138
+ private evictBuckets;
139
+ /** Bucket keys ordered least-recently-written first, excluding `protectedKey`.
140
+ * A bucket with no recorded write sorts first — it can only be a leftover. */
141
+ private evictionOrder;
142
+ /** Remove a bucket and everything that indexes it. An empty ring left behind
143
+ * is the leak this class shipped with — the map only ever grew. */
144
+ private dropBucket;
72
145
  /** Entries retained across every bucket. */
73
146
  size(): number;
147
+ /** Buckets currently retained. Observability for the aggregate bound — an
148
+ * addon roster that only ever grows is what this class had to stop doing. */
149
+ bucketCount(): number;
74
150
  /** Every retained entry across all buckets, newest-first (mirrors
75
151
  * {@link LogRingBuffer.getAll}). Used to replay history to a destination. */
76
152
  getAll(): LogEntry[];
@@ -83,6 +159,9 @@ export declare class PartitionedLogBuffer {
83
159
  */
84
160
  query(filter: LogFilter): LogEntry[];
85
161
  clear(filter?: LogFilter): number;
162
+ /** An emptied bucket is removed rather than left as an empty ring: the map
163
+ * used to grow with every addon id that ever logged and never shrink. */
164
+ private dropIfEmpty;
86
165
  /** The bucket key a filter pins to via `tags.addonId`, or null when the
87
166
  * filter doesn't constrain the addon (→ scan all buckets). */
88
167
  private pinnedAddonKey;