1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
//! Bounds on how much of the palace estate startup work may hold open at once
//! (#7106, epic #6802).
//!
//! Why: three startup jobs each walk every palace on disk — hydration
//! (`AppState::load_palaces_from_disk`), the BM25 backfill sweep, and the BM25
//! repair sweep. None of them bounded how many palaces they held open, and each
//! open hydrates that palace's drawer table, HNSW graph and KG adjacency
//! (~90 MB) plus three redb page caches. On the reporter's 94-palace install
//! that is what a 14 GB → 22.7 GB, 614%-CPU spike ~26 minutes after boot with
//! no request in flight looks like. The estate is not going to get smaller; the
//! concurrency has to get bounded.
//!
//! What: [`StartupOpenGate`], a semaphore with an observable high-water mark,
//! shared through `AppState` so all three jobs draw on ONE budget rather than
//! three independent ones; and [`release_after_sweep`], the rule for handing a
//! palace a sweep opened back to the LRU.
//!
//! Residency ruling (#7087): a palace a client used recently stays resident.
//! [`release_after_sweep`] reads the palace's persisted `last_used` stamp —
//! which the sweeps never write, so it is a pure client-use signal — and leaves
//! anything used inside [`DEFAULT_KEEP_RECENT_SECS`] alone. It also refuses to
//! release a handle anything still references, the same `Arc::strong_count`
//! anchor `PalaceRegistry::evict_idle` uses.
//!
//! Test: `gate_never_exceeds_its_limit_under_contention`,
//! `parse_open_limit_warns_and_keeps_the_default_on_garbage`,
//! `release_after_sweep_keeps_a_recently_used_palace`.
//!
//! [`StartupOpenGate`]: crate::startup_budget::StartupOpenGate
//! [`release_after_sweep`]: crate::startup_budget::release_after_sweep
//! [`DEFAULT_KEEP_RECENT_SECS`]: crate::startup_budget::DEFAULT_KEEP_RECENT_SECS
use ;
use Arc;
use Duration;
use ;
use PalaceId;
use PalaceRegistry;
/// Environment variable overriding [`DEFAULT_STARTUP_OPEN_LIMIT`].
pub const STARTUP_OPEN_LIMIT_ENV: &str = "TRUSTY_MEMORY_STARTUP_OPEN_LIMIT";
/// How many palaces startup work may hold open at once.
///
/// Why (#7106): each concurrently-open palace costs roughly 90 MB of hydrated
/// index plus its redb page caches, so the peak is the product of this number
/// and the per-palace cost — the only term the daemon controls. Four keeps
/// hydration meaningfully parallel on a laptop-class host (the documented 16 GB
/// minimum, #6802) while capping the transient peak at a few hundred megabytes
/// instead of the whole estate.
/// What: 4, overridable via [`STARTUP_OPEN_LIMIT_ENV`].
/// Test: `parse_open_limit_warns_and_keeps_the_default_on_garbage`.
pub const DEFAULT_STARTUP_OPEN_LIMIT: usize = 4;
/// How recently a client must have used a palace for a sweep to leave it warm.
///
/// Why (#7087): the owner's ruling is that an active-session palace stays
/// resident. Fifteen minutes is long enough to cover a pause in an interactive
/// session and short enough that a sweep still reclaims a genuinely dormant
/// estate.
/// What: 900 seconds, compared against the palace's persisted `last_used`
/// stamp, which only client operations write.
/// Test: `release_after_sweep_keeps_a_recently_used_palace`.
pub const DEFAULT_KEEP_RECENT_SECS: u64 = 900;
/// Decide the open limit from a raw override string, with the warning to log.
///
/// Why (#7106, Fail-Open Check): a rejected value must not silently restore
/// unbounded startup opens — that is the failure this module exists to prevent,
/// and it would look identical to a working daemon until the host swapped.
/// Separating the decision from the logging makes the fallback assertable
/// without capturing logs.
/// What: `None`, empty, non-numeric and `0` all yield
/// [`DEFAULT_STARTUP_OPEN_LIMIT`]; a rejected non-empty value also yields the
/// warning text naming the variable and the offending value.
/// Test: `parse_open_limit_warns_and_keeps_the_default_on_garbage`.
/// Resolve the startup open limit from the environment, logging a rejection.
/// One shared budget for every startup job that opens palaces (#7106).
///
/// Why: hydration, the BM25 backfill sweep and the BM25 repair sweep all run
/// concurrently at boot. Three independent limits multiply; one shared gate is
/// the only thing that makes "at most N palaces open at once" true of the
/// process rather than of each job separately. Cloning is cheap and shares the
/// same semaphore and counters, so it lives on `AppState`.
/// What: a `tokio::sync::Semaphore` plus a live count and its high-water mark,
/// so the bound is observable rather than merely intended.
/// Test: `gate_never_exceeds_its_limit_under_contention`.
/// A held startup-open slot; releases on drop.
/// Hand a palace a startup sweep opened back to the LRU (#7106, #7087).
///
/// Why: a sweep that walks the whole estate leaves every palace it touched
/// resident, so a background job nobody asked for pins the daemon's footprint
/// at the LRU cap. Releasing what the sweep brought in is what keeps the sweep's
/// cost transient. The owner's residency ruling (#7087) is the limit on that: a
/// palace a client is using stays resident.
/// What: refuses to release when (a) the palace was already resident before the
/// sweep opened it — something else wanted it warm; (b) the persisted
/// `last_used` stamp is inside `keep_recent`, which only client operations
/// write, never a sweep; or (c) anything still holds a reference to the handle.
/// Otherwise drops the cached handle; the next access transparently reopens
/// from redb, which is the source of truth.
/// Returns whether the handle was released.
/// Test: `release_after_sweep_keeps_a_recently_used_palace`,
/// `release_after_sweep_keeps_an_already_resident_palace`.