@feltdb/core 0.8.2 → 0.8.4
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/dist/cli/commands.js +4 -1
- package/dist/cli/provisioning-neutrality.js +79 -0
- package/dist/collection.d.ts +43 -1
- package/dist/collection.d.ts.map +1 -1
- package/dist/collection.js +192 -22
- package/dist/create/create.js +25 -21
- package/dist/create/managed-account.js +11 -0
- package/dist/create/package-versions.js +1 -1
- package/dist/create/server-source/crates/feltdb/src/equality_index.rs +595 -0
- package/dist/create/server-source/crates/feltdb/src/lib.rs +547 -115
- package/dist/create/server-source/crates/feltdb/src/phase1c3_acceptance.rs +11 -2
- package/dist/create/server-source/crates/feltdb/src/query_execution_diagnostics.rs +126 -0
- package/dist/create/server-source/crates/feltdb/src/state_contract.rs +292 -2
- package/dist/create/server-source/crates/feltdb/src/sync.rs +12 -0
- package/dist/create/server-source/crates/feltdb/src/workload_diagnostics.rs +443 -0
- package/dist/create/server-source/crates/feltdb/tests/pr34_query_collection.rs +233 -0
- package/dist/create/server-source/crates/feltdb/tests/pr35_equality_index.rs +892 -0
- package/dist/create/server-source/crates/feltdb-server/src/audit.rs +1137 -29
- package/dist/create/server-source/crates/feltdb-server/src/main.rs +474 -28
- package/dist/db.d.ts +33 -34
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +74 -20
- package/dist/deployment.d.ts +30 -0
- package/dist/deployment.d.ts.map +1 -0
- package/dist/deployment.js +130 -0
- package/dist/embedded-transaction.d.ts +22 -4
- package/dist/embedded-transaction.d.ts.map +1 -1
- package/dist/embedded-transaction.js +51 -5
- package/dist/feltdb.d.ts +14 -2
- package/dist/feltdb.d.ts.map +1 -1
- package/dist/file-db.d.ts +8 -15
- package/dist/file-db.d.ts.map +1 -1
- package/dist/file-db.js +234 -130
- package/dist/http-client.d.ts +14 -0
- package/dist/http-client.d.ts.map +1 -1
- package/dist/http-client.js +23 -5
- package/dist/http-db.d.ts +119 -1
- package/dist/http-db.d.ts.map +1 -1
- package/dist/http-db.js +346 -31
- package/dist/index-core.d.ts +2 -0
- package/dist/index-core.d.ts.map +1 -1
- package/dist/index-core.js +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -0
- package/dist/indexeddb-db.d.ts.map +1 -1
- package/dist/indexeddb-db.js +35 -21
- package/dist/managed-recovery.d.ts +192 -0
- package/dist/managed-recovery.d.ts.map +1 -0
- package/dist/managed-recovery.js +242 -0
- package/dist/memory-db.js +1 -1
- package/dist/studio-app/assets/{feltdb_wasm-DB8cX151.js → feltdb_wasm-CVQWgXO-.js} +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-CNVpvaZV.wasm +0 -0
- package/dist/studio-app/assets/index-DwgNAIIX.js +29 -0
- package/dist/studio-app/index.html +1 -1
- package/dist/transaction.d.ts +30 -0
- package/dist/transaction.d.ts.map +1 -1
- package/dist/transaction.js +41 -0
- package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
- package/package.json +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-ClhDHp0S.wasm +0 -0
- package/dist/studio-app/assets/index-B0k4UAlI.js +0 -29
|
@@ -1,19 +1,91 @@
|
|
|
1
|
+
//! The per-request security audit log.
|
|
2
|
+
//!
|
|
3
|
+
//! # What this module is for
|
|
4
|
+
//!
|
|
5
|
+
//! Every authenticated request the server serves records exactly one event
|
|
6
|
+
//! here. PR36 measured that record as 55.1% of request-path time; PR37 asked
|
|
7
|
+
//! what the cost bought and established the answer by test:
|
|
8
|
+
//!
|
|
9
|
+
//! * 85.1% of the audit write was one `fdatasync`, taken per request;
|
|
10
|
+
//! * that barrier bought nothing against process death — an event that has
|
|
11
|
+
//! reached `write(2)` is already recoverable;
|
|
12
|
+
//! * request success never depended on it. A server whose audit path could
|
|
13
|
+
//! never be opened still returned `201`, committed transactions, and lost
|
|
14
|
+
//! the security evidence silently.
|
|
15
|
+
//!
|
|
16
|
+
//! So the request was waiting for a barrier no request outcome depended on.
|
|
17
|
+
//!
|
|
18
|
+
//! # The boundary PR38 draws
|
|
19
|
+
//!
|
|
20
|
+
//! Three states, deliberately not conflated:
|
|
21
|
+
//!
|
|
22
|
+
//! ```text
|
|
23
|
+
//! CONSTRUCTED the event exists in memory
|
|
24
|
+
//! ACCEPTED the writer has appended it to the audit stream
|
|
25
|
+
//! DURABLE the group containing it has completed its barrier
|
|
26
|
+
//! ```
|
|
27
|
+
//!
|
|
28
|
+
//! `ACCEPTED != DURABLE`, and `request success != DURABLE`. A request waits
|
|
29
|
+
//! for acceptance and no further:
|
|
30
|
+
//!
|
|
31
|
+
//! ```text
|
|
32
|
+
//! request producers ──► bounded queue ──► appender ──► stream
|
|
33
|
+
//! ▲ │
|
|
34
|
+
//! └──── ACCEPTED ─────────────────────┘
|
|
35
|
+
//! │
|
|
36
|
+
//! durability group
|
|
37
|
+
//! │
|
|
38
|
+
//! ▼
|
|
39
|
+
//! syncer ──► fdatasync
|
|
40
|
+
//! ```
|
|
41
|
+
//!
|
|
42
|
+
//! The appender is the single writer. That is not an implementation detail:
|
|
43
|
+
//! PR37 showed the old mutex was supplying the audit log's *only* ordering
|
|
44
|
+
//! signal, because the record schema carries no sequence number and file
|
|
45
|
+
//! position is all there is. One thread owning the append preserves exactly
|
|
46
|
+
//! that property, and the concurrency sweep re-proves it.
|
|
47
|
+
//!
|
|
48
|
+
//! The barrier moved to a second thread on purpose. Had the appender taken it
|
|
49
|
+
//! inline, a request arriving mid-barrier would wait for it, and the tail
|
|
50
|
+
//! latency PR38 exists to remove would have come back at the p99.
|
|
51
|
+
//!
|
|
52
|
+
//! # What PR38 does not decide
|
|
53
|
+
//!
|
|
54
|
+
//! Whether losing security evidence should invalidate an application
|
|
55
|
+
//! operation. It does not. A request whose audit event is rejected still
|
|
56
|
+
//! succeeds, exactly as before — but the loss is now counted, timestamped and
|
|
57
|
+
//! visible in the audit health surface instead of vanishing into a log line.
|
|
58
|
+
|
|
1
59
|
use std::{
|
|
2
|
-
fs::OpenOptions,
|
|
60
|
+
fs::{File, OpenOptions},
|
|
3
61
|
io::Write,
|
|
4
62
|
path::PathBuf,
|
|
5
|
-
sync::{
|
|
6
|
-
|
|
63
|
+
sync::{
|
|
64
|
+
atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering},
|
|
65
|
+
mpsc::{sync_channel, Receiver, RecvTimeoutError, SyncSender, TrySendError},
|
|
66
|
+
Arc, Condvar, Mutex,
|
|
67
|
+
},
|
|
68
|
+
thread::JoinHandle,
|
|
69
|
+
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
|
7
70
|
};
|
|
8
71
|
|
|
72
|
+
use feltdb::workload_diagnostics::{self, Phase};
|
|
9
73
|
use serde::Serialize;
|
|
10
74
|
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// The public surface
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
11
79
|
#[derive(Clone)]
|
|
12
80
|
pub struct AuditLog {
|
|
13
|
-
|
|
14
|
-
lock: Arc<Mutex<()>>,
|
|
81
|
+
inner: Arc<AuditLogInner>,
|
|
15
82
|
}
|
|
16
83
|
|
|
84
|
+
/// One security audit event, borrowed from the request that produced it.
|
|
85
|
+
///
|
|
86
|
+
/// `timestamp_ms` is written by the audit writer, not by the caller: the field
|
|
87
|
+
/// is here so the record shape is declared in one place, and whatever a caller
|
|
88
|
+
/// puts in it is overwritten. A caller cannot backdate an event.
|
|
17
89
|
#[derive(Serialize)]
|
|
18
90
|
pub struct AuditEvent<'a> {
|
|
19
91
|
pub timestamp_ms: u128,
|
|
@@ -25,35 +97,1071 @@ pub struct AuditEvent<'a> {
|
|
|
25
97
|
pub status: u16,
|
|
26
98
|
}
|
|
27
99
|
|
|
100
|
+
/// The same record, owned, because it crosses a thread boundary.
|
|
101
|
+
///
|
|
102
|
+
/// The field order is the field order of [`AuditEvent`] and must stay that
|
|
103
|
+
/// way: it is the on-disk record shape, and PR38 changes no schema.
|
|
104
|
+
#[derive(Serialize)]
|
|
105
|
+
struct OwnedAuditEvent {
|
|
106
|
+
timestamp_ms: u128,
|
|
107
|
+
namespace: String,
|
|
108
|
+
principal: String,
|
|
109
|
+
action: String,
|
|
110
|
+
target: String,
|
|
111
|
+
outcome: String,
|
|
112
|
+
status: u16,
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
impl AuditEvent<'_> {
|
|
116
|
+
fn to_owned_event(&self) -> OwnedAuditEvent {
|
|
117
|
+
OwnedAuditEvent {
|
|
118
|
+
timestamp_ms: 0,
|
|
119
|
+
namespace: self.namespace.to_string(),
|
|
120
|
+
principal: self.principal.to_string(),
|
|
121
|
+
action: self.action.to_string(),
|
|
122
|
+
target: self.target.to_string(),
|
|
123
|
+
outcome: self.outcome.to_string(),
|
|
124
|
+
status: self.status,
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// Limits
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
/// The bounds the audit pipeline runs under.
|
|
134
|
+
///
|
|
135
|
+
/// These are internal engineering constants, not a product surface. They are
|
|
136
|
+
/// overridable only in a process that has already opted into test
|
|
137
|
+
/// instrumentation, because a bound a deployment can dial is a bound that has
|
|
138
|
+
/// to be part of the contract, and PR38 is not proposing one.
|
|
139
|
+
#[derive(Debug, Clone, Copy)]
|
|
140
|
+
pub struct AuditLimits {
|
|
141
|
+
/// How many events may be waiting for the appender at once. Reaching it is
|
|
142
|
+
/// a defined, observable rejection rather than unbounded memory growth.
|
|
143
|
+
pub queue_capacity: usize,
|
|
144
|
+
/// The largest number of events one durability group may contain.
|
|
145
|
+
pub max_group_events: usize,
|
|
146
|
+
/// The longest an accepted event may wait before its group is closed.
|
|
147
|
+
/// Every accepted event participates in a group within this bound unless
|
|
148
|
+
/// the process terminates first.
|
|
149
|
+
pub max_group_age: Duration,
|
|
150
|
+
/// How many closed groups may be waiting for the barrier. When it is
|
|
151
|
+
/// reached the appender blocks, which is backpressure rather than a leak.
|
|
152
|
+
pub sync_queue_capacity: usize,
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
impl Default for AuditLimits {
|
|
156
|
+
fn default() -> Self {
|
|
157
|
+
Self {
|
|
158
|
+
queue_capacity: 1_024,
|
|
159
|
+
max_group_events: 64,
|
|
160
|
+
max_group_age: Duration::from_millis(5),
|
|
161
|
+
sync_queue_capacity: 8,
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
impl AuditLimits {
|
|
167
|
+
fn resolve() -> Self {
|
|
168
|
+
let mut limits = Self::default();
|
|
169
|
+
if !workload_diagnostics::enabled() {
|
|
170
|
+
return limits;
|
|
171
|
+
}
|
|
172
|
+
let read = |name: &str| {
|
|
173
|
+
std::env::var(name)
|
|
174
|
+
.ok()
|
|
175
|
+
.and_then(|raw| raw.trim().parse::<u64>().ok())
|
|
176
|
+
.filter(|value| *value > 0)
|
|
177
|
+
};
|
|
178
|
+
if let Some(value) = read("FELTDB_AUDIT_DIAGNOSTIC_QUEUE_CAPACITY") {
|
|
179
|
+
limits.queue_capacity = value as usize;
|
|
180
|
+
}
|
|
181
|
+
if let Some(value) = read("FELTDB_AUDIT_DIAGNOSTIC_GROUP_EVENTS") {
|
|
182
|
+
limits.max_group_events = value as usize;
|
|
183
|
+
}
|
|
184
|
+
if let Some(value) = read("FELTDB_AUDIT_DIAGNOSTIC_GROUP_AGE_MS") {
|
|
185
|
+
limits.max_group_age = Duration::from_millis(value);
|
|
186
|
+
}
|
|
187
|
+
limits
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
// Health and counters
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
/// What an operator can see about the audit subsystem.
|
|
196
|
+
///
|
|
197
|
+
/// PR37 proved that security evidence could be lost permanently while every
|
|
198
|
+
/// application response said success, and that nothing anywhere recorded the
|
|
199
|
+
/// loss. This is the answer to that: the counters are cumulative and never
|
|
200
|
+
/// reset, so "it was fine when I looked" is not the same claim as "nothing was
|
|
201
|
+
/// ever lost".
|
|
202
|
+
#[derive(Debug, Clone, Serialize)]
|
|
203
|
+
pub struct AuditHealth {
|
|
204
|
+
/// `healthy`, `degraded` or `failed`.
|
|
205
|
+
pub state: &'static str,
|
|
206
|
+
pub audit_events_accepted_total: u64,
|
|
207
|
+
pub audit_events_durable_total: u64,
|
|
208
|
+
pub audit_write_failures_total: u64,
|
|
209
|
+
pub audit_durability_failures_total: u64,
|
|
210
|
+
pub audit_queue_rejections_total: u64,
|
|
211
|
+
pub audit_queue_depth: i64,
|
|
212
|
+
pub audit_queue_capacity: u64,
|
|
213
|
+
pub audit_durability_groups_total: u64,
|
|
214
|
+
pub audit_durability_barriers_total: u64,
|
|
215
|
+
pub audit_accepted_sequence: u64,
|
|
216
|
+
pub audit_durable_sequence: u64,
|
|
217
|
+
/// Accepted but not yet durable. Bounded by the group limits, and the
|
|
218
|
+
/// exact set of events a machine failure could take.
|
|
219
|
+
pub audit_accepted_not_durable: u64,
|
|
220
|
+
pub audit_last_failure_timestamp_ms: Option<u64>,
|
|
221
|
+
pub audit_last_failure_reason: Option<String>,
|
|
222
|
+
pub audit_writer_running: bool,
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
#[derive(Default)]
|
|
226
|
+
struct AuditState {
|
|
227
|
+
accepted_total: AtomicU64,
|
|
228
|
+
durable_total: AtomicU64,
|
|
229
|
+
write_failures_total: AtomicU64,
|
|
230
|
+
durability_failures_total: AtomicU64,
|
|
231
|
+
queue_rejections_total: AtomicU64,
|
|
232
|
+
queue_depth: AtomicI64,
|
|
233
|
+
groups_total: AtomicU64,
|
|
234
|
+
barriers_total: AtomicU64,
|
|
235
|
+
accepted_sequence: AtomicU64,
|
|
236
|
+
durable_sequence: AtomicU64,
|
|
237
|
+
last_failure_ms: AtomicU64,
|
|
238
|
+
last_failure_reason: Mutex<Option<String>>,
|
|
239
|
+
/// Total failures observed when the previous barrier completed. Recovery
|
|
240
|
+
/// compares against this rather than simply clearing on any success.
|
|
241
|
+
failures_at_last_barrier: AtomicU64,
|
|
242
|
+
writer_running: AtomicBool,
|
|
243
|
+
degraded: AtomicBool,
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
impl AuditState {
|
|
247
|
+
fn record_failure(&self, counter: &AtomicU64, reason: &str) {
|
|
248
|
+
counter.fetch_add(1, Ordering::Relaxed);
|
|
249
|
+
self.degraded.store(true, Ordering::Relaxed);
|
|
250
|
+
self.last_failure_ms.store(now_ms() as u64, Ordering::Relaxed);
|
|
251
|
+
if let Ok(mut slot) = self.last_failure_reason.lock() {
|
|
252
|
+
*slot = Some(reason.to_string());
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/// A clean barrier is what clears `degraded`.
|
|
257
|
+
///
|
|
258
|
+
/// Not a successful append: an append succeeding says the stream accepts
|
|
259
|
+
/// writes, and the thing that had failed might have been the barrier. And
|
|
260
|
+
/// not merely a successful barrier either, because a subsystem shedding
|
|
261
|
+
/// events under sustained overload completes barriers the whole time it is
|
|
262
|
+
/// losing evidence — reporting that as healthy is exactly the blindness
|
|
263
|
+
/// PR37 found and PR38 exists to remove.
|
|
264
|
+
///
|
|
265
|
+
/// So the rule is: a barrier clears `degraded` only when nothing failed
|
|
266
|
+
/// between it and the barrier before it. Recovery therefore costs two
|
|
267
|
+
/// clean groups, which is the honest price of not calling an ongoing loss
|
|
268
|
+
/// healthy.
|
|
269
|
+
fn record_barrier(&self, events: u64, last_sequence: u64) {
|
|
270
|
+
self.barriers_total.fetch_add(1, Ordering::Relaxed);
|
|
271
|
+
self.durable_total.fetch_add(events, Ordering::Relaxed);
|
|
272
|
+
self.durable_sequence
|
|
273
|
+
.fetch_max(last_sequence, Ordering::Relaxed);
|
|
274
|
+
let failures = self.write_failures_total.load(Ordering::Relaxed)
|
|
275
|
+
+ self.durability_failures_total.load(Ordering::Relaxed);
|
|
276
|
+
if self
|
|
277
|
+
.failures_at_last_barrier
|
|
278
|
+
.swap(failures, Ordering::Relaxed)
|
|
279
|
+
== failures
|
|
280
|
+
{
|
|
281
|
+
self.degraded.store(false, Ordering::Relaxed);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
fn health(&self, capacity: usize) -> AuditHealth {
|
|
286
|
+
let running = self.writer_running.load(Ordering::Relaxed);
|
|
287
|
+
let accepted = self.accepted_total.load(Ordering::Relaxed);
|
|
288
|
+
let durable = self.durable_total.load(Ordering::Relaxed);
|
|
289
|
+
let last_failure = self.last_failure_ms.load(Ordering::Relaxed);
|
|
290
|
+
AuditHealth {
|
|
291
|
+
state: if !running {
|
|
292
|
+
"failed"
|
|
293
|
+
} else if self.degraded.load(Ordering::Relaxed) {
|
|
294
|
+
"degraded"
|
|
295
|
+
} else {
|
|
296
|
+
"healthy"
|
|
297
|
+
},
|
|
298
|
+
audit_events_accepted_total: accepted,
|
|
299
|
+
audit_events_durable_total: durable,
|
|
300
|
+
audit_write_failures_total: self.write_failures_total.load(Ordering::Relaxed),
|
|
301
|
+
audit_durability_failures_total: self
|
|
302
|
+
.durability_failures_total
|
|
303
|
+
.load(Ordering::Relaxed),
|
|
304
|
+
audit_queue_rejections_total: self.queue_rejections_total.load(Ordering::Relaxed),
|
|
305
|
+
audit_queue_depth: self.queue_depth.load(Ordering::Relaxed),
|
|
306
|
+
audit_queue_capacity: capacity as u64,
|
|
307
|
+
audit_durability_groups_total: self.groups_total.load(Ordering::Relaxed),
|
|
308
|
+
audit_durability_barriers_total: self.barriers_total.load(Ordering::Relaxed),
|
|
309
|
+
audit_accepted_sequence: self.accepted_sequence.load(Ordering::Relaxed),
|
|
310
|
+
audit_durable_sequence: self.durable_sequence.load(Ordering::Relaxed),
|
|
311
|
+
audit_accepted_not_durable: accepted.saturating_sub(durable),
|
|
312
|
+
audit_last_failure_timestamp_ms: (last_failure > 0).then_some(last_failure),
|
|
313
|
+
audit_last_failure_reason: self
|
|
314
|
+
.last_failure_reason
|
|
315
|
+
.lock()
|
|
316
|
+
.ok()
|
|
317
|
+
.and_then(|reason| reason.clone()),
|
|
318
|
+
audit_writer_running: running,
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
fn now_ms() -> u128 {
|
|
324
|
+
SystemTime::now()
|
|
325
|
+
.duration_since(UNIX_EPOCH)
|
|
326
|
+
.map(|elapsed| elapsed.as_millis())
|
|
327
|
+
.unwrap_or(0)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ---------------------------------------------------------------------------
|
|
331
|
+
// Diagnostics
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
/// Where a fault-injection abort lands inside the audit pipeline.
|
|
335
|
+
///
|
|
336
|
+
/// The three points are the three boundaries the durability contract turns on:
|
|
337
|
+
/// before anything is handed to the kernel, after the bytes are in the kernel
|
|
338
|
+
/// but before the barrier, and after the barrier. PR37's spellings are still
|
|
339
|
+
/// accepted so its scenarios keep their names.
|
|
340
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
341
|
+
pub enum AuditAbortPoint {
|
|
342
|
+
BeforeAppend,
|
|
343
|
+
AfterAppend,
|
|
344
|
+
AfterGroupSync,
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
impl AuditAbortPoint {
|
|
348
|
+
fn parse(raw: &str) -> Option<Self> {
|
|
349
|
+
match raw.trim() {
|
|
350
|
+
"before_append" | "before_write" => Some(Self::BeforeAppend),
|
|
351
|
+
"after_append" | "before_fsync" => Some(Self::AfterAppend),
|
|
352
|
+
"after_group_sync" | "after_fsync" => Some(Self::AfterGroupSync),
|
|
353
|
+
_ => None,
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
pub fn name(self) -> &'static str {
|
|
358
|
+
match self {
|
|
359
|
+
Self::BeforeAppend => "before_append",
|
|
360
|
+
Self::AfterAppend => "after_append",
|
|
361
|
+
Self::AfterGroupSync => "after_group_sync",
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/// Where an injected *failure* — as opposed to a crash — is returned.
|
|
367
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
368
|
+
pub enum AuditFailPoint {
|
|
369
|
+
Append,
|
|
370
|
+
GroupSync,
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
impl AuditFailPoint {
|
|
374
|
+
fn parse(raw: &str) -> Option<Self> {
|
|
375
|
+
match raw.trim() {
|
|
376
|
+
"append" => Some(Self::Append),
|
|
377
|
+
"group_sync" => Some(Self::GroupSync),
|
|
378
|
+
_ => None,
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
pub fn name(self) -> &'static str {
|
|
383
|
+
match self {
|
|
384
|
+
Self::Append => "append",
|
|
385
|
+
Self::GroupSync => "group_sync",
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/// Test-only switches on the audit path.
|
|
391
|
+
///
|
|
392
|
+
/// Gated twice: each switch has its own environment variable, and none is read
|
|
393
|
+
/// unless `FELTDB_WORKLOAD_DIAGNOSTICS=1` has already put the process into
|
|
394
|
+
/// test-instrumentation mode. A production server sets neither, so with
|
|
395
|
+
/// diagnostics off [`AuditDiagnostics::resolve`] returns the inert value and
|
|
396
|
+
/// the pipeline behaves as the product does.
|
|
397
|
+
///
|
|
398
|
+
/// This is a diagnostic surface, not an alternative production mode.
|
|
399
|
+
#[derive(Debug, Clone, Copy, Default)]
|
|
400
|
+
pub struct AuditDiagnostics {
|
|
401
|
+
/// Skip the pipeline entirely, to measure what it costs by removing it.
|
|
402
|
+
pub suppress_write: bool,
|
|
403
|
+
pub abort_point: Option<AuditAbortPoint>,
|
|
404
|
+
pub abort_target: Option<&'static str>,
|
|
405
|
+
/// Fail this stage every time it runs. Two stages fail naturally and are
|
|
406
|
+
/// therefore not injected: acceptance fails when the bounded queue is
|
|
407
|
+
/// full, and the append fails when the configured audit path cannot be
|
|
408
|
+
/// opened. The barrier is the one that has to be forced.
|
|
409
|
+
pub fail_point: Option<AuditFailPoint>,
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
impl AuditDiagnostics {
|
|
413
|
+
pub fn resolve() -> Self {
|
|
414
|
+
if !workload_diagnostics::enabled() {
|
|
415
|
+
return Self::default();
|
|
416
|
+
}
|
|
417
|
+
let suppress_write =
|
|
418
|
+
std::env::var("FELTDB_AUDIT_DIAGNOSTIC_SUPPRESS").as_deref() == Ok("1");
|
|
419
|
+
let abort_point = std::env::var("FELTDB_AUDIT_DIAGNOSTIC_ABORT_POINT")
|
|
420
|
+
.ok()
|
|
421
|
+
.and_then(|raw| AuditAbortPoint::parse(&raw));
|
|
422
|
+
let abort_target = std::env::var("FELTDB_AUDIT_DIAGNOSTIC_ABORT_TARGET")
|
|
423
|
+
.ok()
|
|
424
|
+
.filter(|value| !value.is_empty())
|
|
425
|
+
// Leaked deliberately: the value lives for the process, and a
|
|
426
|
+
// 'static borrow keeps the writer free of an allocation per event.
|
|
427
|
+
.map(|value| &*Box::leak(value.into_boxed_str()));
|
|
428
|
+
let fail_point = std::env::var("FELTDB_AUDIT_DIAGNOSTIC_FAIL_POINT")
|
|
429
|
+
.ok()
|
|
430
|
+
.and_then(|raw| AuditFailPoint::parse(&raw));
|
|
431
|
+
Self {
|
|
432
|
+
suppress_write,
|
|
433
|
+
abort_point: abort_point.filter(|_| abort_target.is_some()),
|
|
434
|
+
abort_target,
|
|
435
|
+
fail_point,
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
fn armed_for(&self, point: AuditAbortPoint, target: &str) -> bool {
|
|
440
|
+
self.abort_point == Some(point)
|
|
441
|
+
&& self
|
|
442
|
+
.abort_target
|
|
443
|
+
.is_some_and(|marker| target.contains(marker))
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
fn marks(&self, target: &str) -> bool {
|
|
447
|
+
self.abort_target
|
|
448
|
+
.is_some_and(|marker| target.contains(marker))
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
pub fn engaged(&self) -> bool {
|
|
452
|
+
self.suppress_write || self.abort_point.is_some() || self.fail_point.is_some()
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
pub fn describe(&self) -> String {
|
|
456
|
+
if !self.engaged() {
|
|
457
|
+
return "inert: the production audit pipeline, unmodified".to_string();
|
|
458
|
+
}
|
|
459
|
+
let mut parts = Vec::new();
|
|
460
|
+
if self.suppress_write {
|
|
461
|
+
parts.push("suppress_write".to_string());
|
|
462
|
+
}
|
|
463
|
+
if let (Some(point), Some(target)) = (self.abort_point, self.abort_target) {
|
|
464
|
+
parts.push(format!("abort {} on target containing {target}", point.name()));
|
|
465
|
+
}
|
|
466
|
+
if let Some(point) = self.fail_point {
|
|
467
|
+
parts.push(format!("fail {}", point.name()));
|
|
468
|
+
}
|
|
469
|
+
parts.join("; ")
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ---------------------------------------------------------------------------
|
|
474
|
+
// Acceptance
|
|
475
|
+
// ---------------------------------------------------------------------------
|
|
476
|
+
|
|
477
|
+
/// The rendezvous between one request and the appender.
|
|
478
|
+
///
|
|
479
|
+
/// The request blocks here until the writer has appended its event or reported
|
|
480
|
+
/// that it could not. It is filled exactly once, and a submission that is
|
|
481
|
+
/// dropped without being appended fills it on the way out, so a writer that
|
|
482
|
+
/// stops can never leave a request waiting forever.
|
|
483
|
+
#[derive(Default)]
|
|
484
|
+
struct AcceptanceSlot {
|
|
485
|
+
outcome: Mutex<Option<Result<u64, String>>>,
|
|
486
|
+
ready: Condvar,
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
impl AcceptanceSlot {
|
|
490
|
+
fn fill(&self, outcome: Result<u64, String>) {
|
|
491
|
+
let mut guard = self
|
|
492
|
+
.outcome
|
|
493
|
+
.lock()
|
|
494
|
+
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
495
|
+
if guard.is_none() {
|
|
496
|
+
*guard = Some(outcome);
|
|
497
|
+
self.ready.notify_all();
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
fn wait(&self) -> Result<u64, String> {
|
|
502
|
+
let mut guard = self
|
|
503
|
+
.outcome
|
|
504
|
+
.lock()
|
|
505
|
+
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
506
|
+
while guard.is_none() {
|
|
507
|
+
guard = self
|
|
508
|
+
.ready
|
|
509
|
+
.wait(guard)
|
|
510
|
+
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
511
|
+
}
|
|
512
|
+
guard
|
|
513
|
+
.clone()
|
|
514
|
+
.unwrap_or_else(|| Err("audit acceptance was never resolved".to_string()))
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
struct EventSubmission {
|
|
519
|
+
event: OwnedAuditEvent,
|
|
520
|
+
slot: Arc<AcceptanceSlot>,
|
|
521
|
+
state: Arc<AuditState>,
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
impl Drop for EventSubmission {
|
|
525
|
+
fn drop(&mut self) {
|
|
526
|
+
self.state.queue_depth.fetch_sub(1, Ordering::Relaxed);
|
|
527
|
+
// If the writer stopped before this event was appended, the request
|
|
528
|
+
// waiting on it is released here rather than blocking forever.
|
|
529
|
+
let unresolved = self
|
|
530
|
+
.slot
|
|
531
|
+
.outcome
|
|
532
|
+
.lock()
|
|
533
|
+
.map(|guard| guard.is_none())
|
|
534
|
+
.unwrap_or(false);
|
|
535
|
+
if unresolved {
|
|
536
|
+
self.state.record_failure(
|
|
537
|
+
&self.state.write_failures_total,
|
|
538
|
+
"the audit writer stopped before this event was accepted",
|
|
539
|
+
);
|
|
540
|
+
self.slot.fill(Err(
|
|
541
|
+
"the audit writer stopped before this event was accepted".to_string(),
|
|
542
|
+
));
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
enum Submission {
|
|
548
|
+
Event(Box<EventSubmission>),
|
|
549
|
+
Shutdown,
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/// One closed durability group, handed from the appender to the syncer.
|
|
553
|
+
struct GroupHandoff {
|
|
554
|
+
file: File,
|
|
555
|
+
events: u64,
|
|
556
|
+
last_sequence: u64,
|
|
557
|
+
carries_abort_marker: bool,
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// ---------------------------------------------------------------------------
|
|
561
|
+
// AuditLog
|
|
562
|
+
// ---------------------------------------------------------------------------
|
|
563
|
+
|
|
564
|
+
struct AuditLogInner {
|
|
565
|
+
path: PathBuf,
|
|
566
|
+
submissions: SyncSender<Submission>,
|
|
567
|
+
state: Arc<AuditState>,
|
|
568
|
+
diagnostics: AuditDiagnostics,
|
|
569
|
+
limits: AuditLimits,
|
|
570
|
+
threads: Mutex<Vec<JoinHandle<()>>>,
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
impl Drop for AuditLogInner {
|
|
574
|
+
fn drop(&mut self) {
|
|
575
|
+
stop(&self.submissions, &self.threads);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/// Close the pipeline: tell the appender to finish, then wait for both threads.
|
|
580
|
+
///
|
|
581
|
+
/// The appender closes its open group on the way out and the syncer drains
|
|
582
|
+
/// what is queued, so an orderly stop leaves every accepted event durable.
|
|
583
|
+
fn stop(submissions: &SyncSender<Submission>, threads: &Mutex<Vec<JoinHandle<()>>>) {
|
|
584
|
+
let handles = {
|
|
585
|
+
let mut guard = threads
|
|
586
|
+
.lock()
|
|
587
|
+
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
588
|
+
std::mem::take(&mut *guard)
|
|
589
|
+
};
|
|
590
|
+
if handles.is_empty() {
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
let _ = submissions.send(Submission::Shutdown);
|
|
594
|
+
for handle in handles {
|
|
595
|
+
let _ = handle.join();
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
28
599
|
impl AuditLog {
|
|
29
600
|
pub fn new(path: PathBuf) -> Self {
|
|
601
|
+
let diagnostics = AuditDiagnostics::resolve();
|
|
602
|
+
let limits = AuditLimits::resolve();
|
|
603
|
+
let state = Arc::new(AuditState::default());
|
|
604
|
+
state.writer_running.store(true, Ordering::Relaxed);
|
|
605
|
+
|
|
606
|
+
let (submissions, incoming) = sync_channel::<Submission>(limits.queue_capacity);
|
|
607
|
+
let (syncs, pending) = sync_channel::<GroupHandoff>(limits.sync_queue_capacity);
|
|
608
|
+
|
|
609
|
+
let appender_state = state.clone();
|
|
610
|
+
let appender_path = path.clone();
|
|
611
|
+
let appender = std::thread::Builder::new()
|
|
612
|
+
.name("feltdb-audit-appender".to_string())
|
|
613
|
+
.spawn(move || {
|
|
614
|
+
run_appender(incoming, syncs, appender_path, limits, appender_state, diagnostics);
|
|
615
|
+
})
|
|
616
|
+
.expect("the audit appender thread must start");
|
|
617
|
+
|
|
618
|
+
let syncer_state = state.clone();
|
|
619
|
+
let syncer = std::thread::Builder::new()
|
|
620
|
+
.name("feltdb-audit-syncer".to_string())
|
|
621
|
+
.spawn(move || run_syncer(pending, syncer_state, diagnostics))
|
|
622
|
+
.expect("the audit syncer thread must start");
|
|
623
|
+
|
|
30
624
|
Self {
|
|
31
|
-
|
|
32
|
-
|
|
625
|
+
inner: Arc::new(AuditLogInner {
|
|
626
|
+
path,
|
|
627
|
+
submissions,
|
|
628
|
+
state,
|
|
629
|
+
diagnostics,
|
|
630
|
+
limits,
|
|
631
|
+
threads: Mutex::new(vec![appender, syncer]),
|
|
632
|
+
}),
|
|
33
633
|
}
|
|
34
634
|
}
|
|
35
635
|
|
|
36
|
-
pub fn
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
636
|
+
pub fn diagnostics(&self) -> AuditDiagnostics {
|
|
637
|
+
self.inner.diagnostics
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
pub fn limits(&self) -> AuditLimits {
|
|
641
|
+
self.inner.limits
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
pub fn path(&self) -> &PathBuf {
|
|
645
|
+
&self.inner.path
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/// What an operator can see. Cheap enough to serve on a metrics route.
|
|
649
|
+
pub fn health(&self) -> AuditHealth {
|
|
650
|
+
self.inner.state.health(self.inner.limits.queue_capacity)
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/// Stop the pipeline and make everything accepted durable.
|
|
654
|
+
///
|
|
655
|
+
/// Idempotent, and also run from `Drop`, so a server that forgets to call
|
|
656
|
+
/// it still leaves an orderly stream behind.
|
|
657
|
+
pub fn shutdown(&self) {
|
|
658
|
+
stop(&self.inner.submissions, &self.inner.threads);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/// Record one security audit event, returning once it is ACCEPTED.
|
|
662
|
+
///
|
|
663
|
+
/// "Accepted" means the writer's `write(2)` returned: the bytes are in the
|
|
664
|
+
/// kernel and survive this process dying. It does **not** mean durable.
|
|
665
|
+
/// The barrier for the group containing this event happens afterwards, on
|
|
666
|
+
/// the syncer thread, and no request waits for it.
|
|
667
|
+
///
|
|
668
|
+
/// The error path is unchanged from PR37 in what it does to the request —
|
|
669
|
+
/// the caller in the request middleware still discards it — and changed in
|
|
670
|
+
/// what it does to the world: every failure moves a counter, stamps a
|
|
671
|
+
/// time, and degrades the audit health state.
|
|
672
|
+
pub fn record(&self, event: AuditEvent<'_>) -> Result<(), String> {
|
|
673
|
+
if self.inner.diagnostics.suppress_write {
|
|
674
|
+
return Ok(());
|
|
675
|
+
}
|
|
676
|
+
let state = &self.inner.state;
|
|
677
|
+
let slot = Arc::new(AcceptanceSlot::default());
|
|
678
|
+
let submission = Submission::Event(Box::new(EventSubmission {
|
|
679
|
+
event: event.to_owned_event(),
|
|
680
|
+
slot: slot.clone(),
|
|
681
|
+
state: state.clone(),
|
|
682
|
+
}));
|
|
683
|
+
|
|
684
|
+
let submitted = {
|
|
685
|
+
let _span = workload_diagnostics::span(Phase::AuditSubmit);
|
|
686
|
+
state.queue_depth.fetch_add(1, Ordering::Relaxed);
|
|
687
|
+
self.inner.submissions.try_send(submission)
|
|
688
|
+
};
|
|
689
|
+
if let Err(error) = submitted {
|
|
690
|
+
let (rejected, reason) = match error {
|
|
691
|
+
TrySendError::Full(rejected) => {
|
|
692
|
+
state.queue_rejections_total.fetch_add(1, Ordering::Relaxed);
|
|
693
|
+
(
|
|
694
|
+
rejected,
|
|
695
|
+
"the audit queue is at capacity: this event was not accepted",
|
|
696
|
+
)
|
|
697
|
+
}
|
|
698
|
+
TrySendError::Disconnected(rejected) => {
|
|
699
|
+
state.writer_running.store(false, Ordering::Relaxed);
|
|
700
|
+
(
|
|
701
|
+
rejected,
|
|
702
|
+
"the audit writer is not running: this event was not accepted",
|
|
703
|
+
)
|
|
704
|
+
}
|
|
705
|
+
};
|
|
706
|
+
// Resolve the slot here and count the failure here. Dropping the
|
|
707
|
+
// rejected submission then only corrects the queue depth: its own
|
|
708
|
+
// failure path is for events that reached the queue and died with
|
|
709
|
+
// the writer, which is a different loss and must not be counted
|
|
710
|
+
// twice as this one.
|
|
711
|
+
state.record_failure(&state.write_failures_total, reason);
|
|
712
|
+
if let Submission::Event(submission) = &rejected {
|
|
713
|
+
submission.slot.fill(Err(reason.to_string()));
|
|
714
|
+
}
|
|
715
|
+
drop(rejected);
|
|
716
|
+
return Err(reason.to_string());
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
let _span = workload_diagnostics::span(Phase::AuditAcceptWait);
|
|
720
|
+
slot.wait().map(|_| ())
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// ---------------------------------------------------------------------------
|
|
725
|
+
// The appender: the single writer
|
|
726
|
+
// ---------------------------------------------------------------------------
|
|
727
|
+
|
|
728
|
+
/// The group currently being filled.
|
|
729
|
+
#[derive(Default)]
|
|
730
|
+
struct OpenGroup {
|
|
731
|
+
events: u64,
|
|
732
|
+
last_sequence: u64,
|
|
733
|
+
carries_abort_marker: bool,
|
|
734
|
+
opened_at: Option<Instant>,
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
impl OpenGroup {
|
|
738
|
+
fn remaining(&self, age: Duration) -> Duration {
|
|
739
|
+
self.opened_at
|
|
740
|
+
.map(|opened| age.saturating_sub(opened.elapsed()))
|
|
741
|
+
.unwrap_or(age)
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/// The audit stream: the file, and the one buffer every record is built in.
|
|
746
|
+
struct AuditStream {
|
|
747
|
+
path: PathBuf,
|
|
748
|
+
file: Option<File>,
|
|
749
|
+
/// Reused across events so a record costs no allocation of its own.
|
|
750
|
+
buffer: Vec<u8>,
|
|
751
|
+
sequence: u64,
|
|
752
|
+
state: Arc<AuditState>,
|
|
753
|
+
diagnostics: AuditDiagnostics,
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
impl AuditStream {
|
|
757
|
+
fn new(path: PathBuf, state: Arc<AuditState>, diagnostics: AuditDiagnostics) -> Self {
|
|
758
|
+
Self {
|
|
759
|
+
path,
|
|
760
|
+
file: None,
|
|
761
|
+
buffer: Vec::with_capacity(512),
|
|
762
|
+
sequence: 0,
|
|
763
|
+
state,
|
|
764
|
+
diagnostics,
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/// Open once and keep the handle.
|
|
769
|
+
///
|
|
770
|
+
/// PR37 measured this happening per event, along with a `close`. The
|
|
771
|
+
/// writer owning the handle is what removes both, and it is the reason the
|
|
772
|
+
/// per-event syscall count can be stated as a single `write`.
|
|
773
|
+
fn handle(&mut self) -> Result<&mut File, String> {
|
|
774
|
+
if self.file.is_none() {
|
|
775
|
+
let _span = workload_diagnostics::span(Phase::AuditOpen);
|
|
776
|
+
if let Some(parent) = self.path.parent() {
|
|
777
|
+
std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
|
|
778
|
+
}
|
|
779
|
+
let opened = OpenOptions::new()
|
|
780
|
+
.create(true)
|
|
781
|
+
.append(true)
|
|
782
|
+
.open(&self.path)
|
|
783
|
+
.map_err(|error| error.to_string())?;
|
|
784
|
+
self.file = Some(opened);
|
|
785
|
+
}
|
|
786
|
+
Ok(self.file.as_mut().expect("the handle was just opened"))
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/// Append one event and resolve its acceptance.
|
|
790
|
+
fn append(&mut self, submission: &EventSubmission, group: &mut OpenGroup) {
|
|
791
|
+
let mut event = OwnedAuditEvent {
|
|
792
|
+
timestamp_ms: 0,
|
|
793
|
+
namespace: submission.event.namespace.clone(),
|
|
794
|
+
principal: submission.event.principal.clone(),
|
|
795
|
+
action: submission.event.action.clone(),
|
|
796
|
+
target: submission.event.target.clone(),
|
|
797
|
+
outcome: submission.event.outcome.clone(),
|
|
798
|
+
status: submission.event.status,
|
|
799
|
+
};
|
|
800
|
+
// Stamped by the writer, in append order, so the only two ordering
|
|
801
|
+
// signals the record carries — file position and timestamp — agree.
|
|
802
|
+
event.timestamp_ms = now_ms();
|
|
803
|
+
|
|
804
|
+
if self
|
|
805
|
+
.diagnostics
|
|
806
|
+
.armed_for(AuditAbortPoint::BeforeAppend, &event.target)
|
|
807
|
+
{
|
|
808
|
+
// Nothing of this event has reached the kernel.
|
|
809
|
+
std::process::abort();
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
let outcome = self.write_record(&event);
|
|
813
|
+
match outcome {
|
|
814
|
+
Ok(sequence) => {
|
|
815
|
+
self.state.accepted_total.fetch_add(1, Ordering::Relaxed);
|
|
816
|
+
self.state
|
|
817
|
+
.accepted_sequence
|
|
818
|
+
.store(sequence, Ordering::Relaxed);
|
|
819
|
+
group.events += 1;
|
|
820
|
+
group.last_sequence = sequence;
|
|
821
|
+
if group.opened_at.is_none() {
|
|
822
|
+
group.opened_at = Some(Instant::now());
|
|
823
|
+
}
|
|
824
|
+
if self.diagnostics.marks(&event.target) {
|
|
825
|
+
group.carries_abort_marker = true;
|
|
826
|
+
}
|
|
827
|
+
submission.slot.fill(Ok(sequence));
|
|
828
|
+
if self
|
|
829
|
+
.diagnostics
|
|
830
|
+
.armed_for(AuditAbortPoint::AfterAppend, &event.target)
|
|
831
|
+
{
|
|
832
|
+
// The bytes are in the kernel; no barrier has been asked
|
|
833
|
+
// for, and the request has just been released.
|
|
834
|
+
std::process::abort();
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
Err(reason) => {
|
|
838
|
+
self.state
|
|
839
|
+
.record_failure(&self.state.write_failures_total, &reason);
|
|
840
|
+
submission.slot.fill(Err(reason));
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
fn write_record(&mut self, event: &OwnedAuditEvent) -> Result<u64, String> {
|
|
846
|
+
if self.diagnostics.fail_point == Some(AuditFailPoint::Append) {
|
|
847
|
+
return Err("injected audit append failure".to_string());
|
|
848
|
+
}
|
|
849
|
+
// Serialized into the writer's own buffer and appended with one
|
|
850
|
+
// `write(2)`. PR37 traced fifty-four writes per event, because the
|
|
851
|
+
// serializer wrote straight into an unbuffered file; that was never a
|
|
852
|
+
// durability property, and it does not survive the writer owning
|
|
853
|
+
// serialization.
|
|
854
|
+
//
|
|
855
|
+
// The buffer is moved out and back so the record can be written while
|
|
856
|
+
// `self` is borrowed for the file handle. `mem::take` leaves an empty
|
|
857
|
+
// Vec behind and the original — capacity included — is restored.
|
|
858
|
+
let mut buffer = std::mem::take(&mut self.buffer);
|
|
859
|
+
buffer.clear();
|
|
860
|
+
let serialized = serde_json::to_writer(&mut buffer, event).map_err(|error| error.to_string());
|
|
861
|
+
buffer.push(b'\n');
|
|
862
|
+
let written = serialized.and_then(|()| {
|
|
863
|
+
let _span = workload_diagnostics::span(Phase::AuditAppend);
|
|
864
|
+
self.handle()
|
|
865
|
+
.and_then(|file| file.write_all(&buffer).map_err(|error| error.to_string()))
|
|
866
|
+
});
|
|
867
|
+
self.buffer = buffer;
|
|
868
|
+
written?;
|
|
869
|
+
self.sequence += 1;
|
|
870
|
+
Ok(self.sequence)
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/// Close the open group and hand it to the syncer.
|
|
874
|
+
fn close_group(&mut self, group: &mut OpenGroup, syncs: &SyncSender<GroupHandoff>) {
|
|
875
|
+
if group.events == 0 {
|
|
876
|
+
*group = OpenGroup::default();
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
let closed = std::mem::take(group);
|
|
880
|
+
self.state.groups_total.fetch_add(1, Ordering::Relaxed);
|
|
881
|
+
// The syncer barriers through its own descriptor for the same file, so
|
|
882
|
+
// the appender can keep appending while a group is being made durable.
|
|
883
|
+
// A barrier that catches later appends only over-delivers durability;
|
|
884
|
+
// `durable_sequence` still advances only to the group it was told
|
|
885
|
+
// about.
|
|
886
|
+
let duplicated = match self.file.as_ref().map(|file| file.try_clone()) {
|
|
887
|
+
Some(Ok(file)) => file,
|
|
888
|
+
Some(Err(error)) => {
|
|
889
|
+
self.state.record_failure(
|
|
890
|
+
&self.state.durability_failures_total,
|
|
891
|
+
&format!("could not duplicate the audit handle for a barrier: {error}"),
|
|
892
|
+
);
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
None => {
|
|
896
|
+
// The stream was never opened, so nothing was appended and
|
|
897
|
+
// there is nothing to barrier.
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
};
|
|
901
|
+
if syncs
|
|
902
|
+
.send(GroupHandoff {
|
|
903
|
+
file: duplicated,
|
|
904
|
+
events: closed.events,
|
|
905
|
+
last_sequence: closed.last_sequence,
|
|
906
|
+
carries_abort_marker: closed.carries_abort_marker,
|
|
907
|
+
})
|
|
908
|
+
.is_err()
|
|
909
|
+
{
|
|
910
|
+
self.state.record_failure(
|
|
911
|
+
&self.state.durability_failures_total,
|
|
912
|
+
"the audit syncer is not running: this group was not made durable",
|
|
913
|
+
);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
fn close(&mut self) {
|
|
918
|
+
if self.file.is_some() {
|
|
919
|
+
let _span = workload_diagnostics::span(Phase::AuditClose);
|
|
920
|
+
self.file = None;
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
fn run_appender(
|
|
926
|
+
incoming: Receiver<Submission>,
|
|
927
|
+
syncs: SyncSender<GroupHandoff>,
|
|
928
|
+
path: PathBuf,
|
|
929
|
+
limits: AuditLimits,
|
|
930
|
+
state: Arc<AuditState>,
|
|
931
|
+
diagnostics: AuditDiagnostics,
|
|
932
|
+
) {
|
|
933
|
+
let mut stream = AuditStream::new(path, state.clone(), diagnostics);
|
|
934
|
+
let mut group = OpenGroup::default();
|
|
935
|
+
loop {
|
|
936
|
+
// An empty group has no deadline, so an idle server performs no
|
|
937
|
+
// barriers at all: the group age bounds how long an *accepted* event
|
|
938
|
+
// waits, not how often the writer wakes up.
|
|
939
|
+
let received = if group.events == 0 {
|
|
940
|
+
incoming.recv().map_err(|_| RecvTimeoutError::Disconnected)
|
|
941
|
+
} else {
|
|
942
|
+
incoming.recv_timeout(group.remaining(limits.max_group_age))
|
|
943
|
+
};
|
|
944
|
+
match received {
|
|
945
|
+
Ok(Submission::Event(submission)) => {
|
|
946
|
+
stream.append(&submission, &mut group);
|
|
947
|
+
drop(submission);
|
|
948
|
+
if group.events >= limits.max_group_events as u64 {
|
|
949
|
+
stream.close_group(&mut group, &syncs);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
Ok(Submission::Shutdown) | Err(RecvTimeoutError::Disconnected) => {
|
|
953
|
+
stream.close_group(&mut group, &syncs);
|
|
954
|
+
stream.close();
|
|
955
|
+
break;
|
|
956
|
+
}
|
|
957
|
+
Err(RecvTimeoutError::Timeout) => stream.close_group(&mut group, &syncs),
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
drop(syncs);
|
|
961
|
+
state.writer_running.store(false, Ordering::Relaxed);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// ---------------------------------------------------------------------------
|
|
965
|
+
// The syncer: durability, off the request path
|
|
966
|
+
// ---------------------------------------------------------------------------
|
|
967
|
+
|
|
968
|
+
fn run_syncer(pending: Receiver<GroupHandoff>, state: Arc<AuditState>, diagnostics: AuditDiagnostics) {
|
|
969
|
+
for mut handoff in pending {
|
|
970
|
+
let _group = workload_diagnostics::span(Phase::AuditGroupSync);
|
|
971
|
+
let outcome = if diagnostics.fail_point == Some(AuditFailPoint::GroupSync) {
|
|
972
|
+
Err("injected audit durability-group failure".to_string())
|
|
973
|
+
} else {
|
|
974
|
+
let flushed = {
|
|
975
|
+
let _span = workload_diagnostics::span(Phase::AuditFlush);
|
|
976
|
+
handoff.file.flush().map_err(|error| error.to_string())
|
|
977
|
+
};
|
|
978
|
+
flushed.and_then(|_| {
|
|
979
|
+
let _span = workload_diagnostics::span(Phase::AuditFsync);
|
|
980
|
+
handoff.file.sync_data().map_err(|error| error.to_string())
|
|
981
|
+
})
|
|
982
|
+
};
|
|
983
|
+
match outcome {
|
|
984
|
+
Ok(()) => {
|
|
985
|
+
state.record_barrier(handoff.events, handoff.last_sequence);
|
|
986
|
+
if handoff.carries_abort_marker
|
|
987
|
+
&& diagnostics.abort_point == Some(AuditAbortPoint::AfterGroupSync)
|
|
988
|
+
{
|
|
989
|
+
std::process::abort();
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
Err(reason) => state.record_failure(&state.durability_failures_total, &reason),
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// ---------------------------------------------------------------------------
|
|
998
|
+
// Tests
|
|
999
|
+
// ---------------------------------------------------------------------------
|
|
1000
|
+
|
|
1001
|
+
#[cfg(test)]
|
|
1002
|
+
mod tests {
|
|
1003
|
+
use super::*;
|
|
1004
|
+
use std::fs;
|
|
1005
|
+
|
|
1006
|
+
fn event<'a>(target: &'a str) -> AuditEvent<'a> {
|
|
1007
|
+
AuditEvent {
|
|
1008
|
+
timestamp_ms: 0,
|
|
1009
|
+
namespace: "test",
|
|
1010
|
+
principal: "principal",
|
|
1011
|
+
action: "state:write",
|
|
1012
|
+
target,
|
|
1013
|
+
outcome: "allowed",
|
|
1014
|
+
status: 200,
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
fn scratch(label: &str) -> PathBuf {
|
|
1019
|
+
let dir = std::env::temp_dir().join(format!(
|
|
1020
|
+
"feltdb-audit-{label}-{}-{:?}",
|
|
1021
|
+
std::process::id(),
|
|
1022
|
+
std::thread::current().id()
|
|
1023
|
+
));
|
|
1024
|
+
fs::remove_dir_all(&dir).ok();
|
|
1025
|
+
dir
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
#[test]
|
|
1029
|
+
fn an_accepted_event_is_one_json_line_with_a_writer_stamped_timestamp() {
|
|
1030
|
+
let dir = scratch("accept");
|
|
1031
|
+
let path = dir.join("nested").join("audit.log");
|
|
1032
|
+
let log = AuditLog::new(path.clone());
|
|
1033
|
+
log.record(event("/collections/items/a")).expect("accepted");
|
|
1034
|
+
log.record(event("/collections/items/b")).expect("accepted");
|
|
1035
|
+
|
|
1036
|
+
// record() returned, so both events are ACCEPTED: appended and
|
|
1037
|
+
// readable, before any barrier has necessarily happened.
|
|
1038
|
+
let lines: Vec<String> = fs::read_to_string(&path)
|
|
1039
|
+
.expect("audit file exists")
|
|
1040
|
+
.lines()
|
|
1041
|
+
.map(str::to_string)
|
|
1042
|
+
.collect();
|
|
1043
|
+
assert_eq!(lines.len(), 2, "one line per event, in acceptance order");
|
|
1044
|
+
let first: serde_json::Value = serde_json::from_str(&lines[0]).expect("valid JSON");
|
|
1045
|
+
assert_eq!(first["target"], "/collections/items/a");
|
|
1046
|
+
assert!(
|
|
1047
|
+
first["timestamp_ms"].as_u64().unwrap_or(0) > 0,
|
|
1048
|
+
"the writer stamps the time; a caller cannot backdate an event",
|
|
1049
|
+
);
|
|
1050
|
+
assert_eq!(
|
|
1051
|
+
first.as_object().map(|fields| fields.len()),
|
|
1052
|
+
Some(7),
|
|
1053
|
+
"PR38 changes no record schema",
|
|
1054
|
+
);
|
|
1055
|
+
|
|
1056
|
+
let health = log.health();
|
|
1057
|
+
assert_eq!(health.audit_events_accepted_total, 2);
|
|
1058
|
+
log.shutdown();
|
|
1059
|
+
assert!(
|
|
1060
|
+
log.health().audit_events_durable_total >= 2,
|
|
1061
|
+
"an orderly shutdown makes every accepted event durable",
|
|
1062
|
+
);
|
|
1063
|
+
fs::remove_dir_all(&dir).ok();
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
#[test]
|
|
1067
|
+
fn an_unwritable_stream_fails_acceptance_and_is_visible_rather_than_silent() {
|
|
1068
|
+
let dir = scratch("fail");
|
|
1069
|
+
fs::create_dir_all(dir.join("audit.log")).expect("path is a directory");
|
|
1070
|
+
let log = AuditLog::new(dir.join("audit.log"));
|
|
1071
|
+
assert!(
|
|
1072
|
+
log.record(event("/collections/items/a")).is_err(),
|
|
1073
|
+
"an unopenable audit path must fail acceptance, not report success",
|
|
1074
|
+
);
|
|
1075
|
+
let health = log.health();
|
|
1076
|
+
assert_eq!(health.audit_events_accepted_total, 0);
|
|
1077
|
+
assert_eq!(health.audit_write_failures_total, 1);
|
|
1078
|
+
assert_eq!(health.state, "degraded");
|
|
1079
|
+
assert!(
|
|
1080
|
+
health.audit_last_failure_timestamp_ms.is_some(),
|
|
1081
|
+
"the failure is timestamped, which is the whole point of PR38's health surface",
|
|
1082
|
+
);
|
|
1083
|
+
fs::remove_dir_all(&dir).ok();
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
#[test]
|
|
1087
|
+
fn acceptance_is_not_durability() {
|
|
1088
|
+
// The states are distinct and the counters say so: an event can be
|
|
1089
|
+
// accepted while its group has not yet been through a barrier.
|
|
1090
|
+
let dir = scratch("states");
|
|
1091
|
+
let log = AuditLog::new(dir.join("audit.log"));
|
|
1092
|
+
log.record(event("/collections/items/a")).expect("accepted");
|
|
1093
|
+
let accepted = log.health();
|
|
1094
|
+
assert_eq!(accepted.audit_events_accepted_total, 1);
|
|
1095
|
+
assert!(accepted.audit_accepted_sequence >= 1);
|
|
1096
|
+
|
|
1097
|
+
log.shutdown();
|
|
1098
|
+
let durable = log.health();
|
|
1099
|
+
assert_eq!(durable.audit_events_durable_total, 1);
|
|
1100
|
+
assert_eq!(durable.audit_accepted_not_durable, 0);
|
|
1101
|
+
assert!(durable.audit_durability_barriers_total >= 1);
|
|
1102
|
+
fs::remove_dir_all(&dir).ok();
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
#[test]
|
|
1106
|
+
fn a_bounded_queue_rejects_rather_than_growing() {
|
|
1107
|
+
// The rejection path is reachable without a race: a pipeline whose
|
|
1108
|
+
// writer has stopped cannot accept anything, and says so in the same
|
|
1109
|
+
// counters an overloaded one would.
|
|
1110
|
+
let dir = scratch("bounded");
|
|
1111
|
+
let log = AuditLog::new(dir.join("audit.log"));
|
|
1112
|
+
log.record(event("/collections/items/a")).expect("accepted");
|
|
1113
|
+
log.shutdown();
|
|
1114
|
+
|
|
1115
|
+
let rejected = log.record(event("/collections/items/b"));
|
|
1116
|
+
assert!(rejected.is_err(), "a stopped pipeline accepts nothing");
|
|
1117
|
+
let health = log.health();
|
|
1118
|
+
assert_eq!(health.state, "failed", "and an operator can see that it stopped");
|
|
1119
|
+
assert!(health.audit_write_failures_total >= 1);
|
|
1120
|
+
assert!(health.audit_queue_capacity > 0, "the queue bound is declared");
|
|
1121
|
+
fs::remove_dir_all(&dir).ok();
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
#[test]
|
|
1125
|
+
fn a_durability_group_completes_in_the_background_without_a_shutdown() {
|
|
1126
|
+
// The property PR38 rests on: a request that has been released does
|
|
1127
|
+
// not have to wait for, or trigger, the barrier that makes its event
|
|
1128
|
+
// durable. The group closes on its own age bound and the syncer takes
|
|
1129
|
+
// it from there.
|
|
1130
|
+
let dir = scratch("background");
|
|
1131
|
+
let log = AuditLog::new(dir.join("audit.log"));
|
|
1132
|
+
for _ in 0..3 {
|
|
1133
|
+
log.record(event("/collections/items/a")).expect("accepted");
|
|
1134
|
+
std::thread::sleep(Duration::from_millis(20));
|
|
1135
|
+
}
|
|
1136
|
+
let deadline = Instant::now() + Duration::from_secs(5);
|
|
1137
|
+
while log.health().audit_events_durable_total < 3 && Instant::now() < deadline {
|
|
1138
|
+
std::thread::sleep(Duration::from_millis(10));
|
|
1139
|
+
}
|
|
1140
|
+
let health = log.health();
|
|
1141
|
+
assert_eq!(
|
|
1142
|
+
health.audit_events_durable_total, 3,
|
|
1143
|
+
"every accepted event reached a barrier without anyone asking for one",
|
|
1144
|
+
);
|
|
1145
|
+
assert!(health.audit_durability_barriers_total >= 1);
|
|
1146
|
+
assert_eq!(health.state, "healthy");
|
|
1147
|
+
fs::remove_dir_all(&dir).ok();
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
#[test]
|
|
1151
|
+
fn diagnostic_switches_are_inert_unless_workload_diagnostics_is_on() {
|
|
1152
|
+
workload_diagnostics::set_enabled(false);
|
|
1153
|
+
let resolved = AuditDiagnostics::resolve();
|
|
1154
|
+
assert!(!resolved.engaged());
|
|
1155
|
+
assert!(!resolved.suppress_write);
|
|
1156
|
+
assert_eq!(resolved.abort_point, None);
|
|
1157
|
+
assert_eq!(resolved.fail_point, None);
|
|
1158
|
+
assert_eq!(
|
|
1159
|
+
resolved.describe(),
|
|
1160
|
+
"inert: the production audit pipeline, unmodified"
|
|
1161
|
+
);
|
|
1162
|
+
let limits = AuditLimits::resolve();
|
|
1163
|
+
let default = AuditLimits::default();
|
|
1164
|
+
assert_eq!(limits.queue_capacity, default.queue_capacity);
|
|
1165
|
+
assert_eq!(limits.max_group_events, default.max_group_events);
|
|
58
1166
|
}
|
|
59
1167
|
}
|