@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.
Files changed (61) hide show
  1. package/dist/cli/commands.js +4 -1
  2. package/dist/cli/provisioning-neutrality.js +79 -0
  3. package/dist/collection.d.ts +43 -1
  4. package/dist/collection.d.ts.map +1 -1
  5. package/dist/collection.js +192 -22
  6. package/dist/create/create.js +25 -21
  7. package/dist/create/managed-account.js +11 -0
  8. package/dist/create/package-versions.js +1 -1
  9. package/dist/create/server-source/crates/feltdb/src/equality_index.rs +595 -0
  10. package/dist/create/server-source/crates/feltdb/src/lib.rs +547 -115
  11. package/dist/create/server-source/crates/feltdb/src/phase1c3_acceptance.rs +11 -2
  12. package/dist/create/server-source/crates/feltdb/src/query_execution_diagnostics.rs +126 -0
  13. package/dist/create/server-source/crates/feltdb/src/state_contract.rs +292 -2
  14. package/dist/create/server-source/crates/feltdb/src/sync.rs +12 -0
  15. package/dist/create/server-source/crates/feltdb/src/workload_diagnostics.rs +443 -0
  16. package/dist/create/server-source/crates/feltdb/tests/pr34_query_collection.rs +233 -0
  17. package/dist/create/server-source/crates/feltdb/tests/pr35_equality_index.rs +892 -0
  18. package/dist/create/server-source/crates/feltdb-server/src/audit.rs +1137 -29
  19. package/dist/create/server-source/crates/feltdb-server/src/main.rs +474 -28
  20. package/dist/db.d.ts +33 -34
  21. package/dist/db.d.ts.map +1 -1
  22. package/dist/db.js +74 -20
  23. package/dist/deployment.d.ts +30 -0
  24. package/dist/deployment.d.ts.map +1 -0
  25. package/dist/deployment.js +130 -0
  26. package/dist/embedded-transaction.d.ts +22 -4
  27. package/dist/embedded-transaction.d.ts.map +1 -1
  28. package/dist/embedded-transaction.js +51 -5
  29. package/dist/feltdb.d.ts +14 -2
  30. package/dist/feltdb.d.ts.map +1 -1
  31. package/dist/file-db.d.ts +8 -15
  32. package/dist/file-db.d.ts.map +1 -1
  33. package/dist/file-db.js +234 -130
  34. package/dist/http-client.d.ts +14 -0
  35. package/dist/http-client.d.ts.map +1 -1
  36. package/dist/http-client.js +23 -5
  37. package/dist/http-db.d.ts +119 -1
  38. package/dist/http-db.d.ts.map +1 -1
  39. package/dist/http-db.js +346 -31
  40. package/dist/index-core.d.ts +2 -0
  41. package/dist/index-core.d.ts.map +1 -1
  42. package/dist/index-core.js +2 -0
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +9 -0
  45. package/dist/indexeddb-db.d.ts.map +1 -1
  46. package/dist/indexeddb-db.js +35 -21
  47. package/dist/managed-recovery.d.ts +192 -0
  48. package/dist/managed-recovery.d.ts.map +1 -0
  49. package/dist/managed-recovery.js +242 -0
  50. package/dist/memory-db.js +1 -1
  51. package/dist/studio-app/assets/{feltdb_wasm-DB8cX151.js → feltdb_wasm-CVQWgXO-.js} +1 -1
  52. package/dist/studio-app/assets/feltdb_wasm_bg-CNVpvaZV.wasm +0 -0
  53. package/dist/studio-app/assets/index-DwgNAIIX.js +29 -0
  54. package/dist/studio-app/index.html +1 -1
  55. package/dist/transaction.d.ts +30 -0
  56. package/dist/transaction.d.ts.map +1 -1
  57. package/dist/transaction.js +41 -0
  58. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  59. package/package.json +1 -1
  60. package/dist/studio-app/assets/feltdb_wasm_bg-ClhDHp0S.wasm +0 -0
  61. 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::{Arc, Mutex},
6
- time::{SystemTime, UNIX_EPOCH},
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
- path: Arc<PathBuf>,
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
- path: Arc::new(path),
32
- lock: Arc::new(Mutex::new(())),
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 record(&self, mut event: AuditEvent<'_>) -> Result<(), String> {
37
- let _guard = self
38
- .lock
39
- .lock()
40
- .map_err(|_| "audit log lock poisoned".to_string())?;
41
- if let Some(parent) = self.path.parent() {
42
- std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
43
- }
44
- event.timestamp_ms = SystemTime::now()
45
- .duration_since(UNIX_EPOCH)
46
- .map_err(|error| error.to_string())?
47
- .as_millis();
48
- let mut file = OpenOptions::new()
49
- .create(true)
50
- .append(true)
51
- .open(&*self.path)
52
- .map_err(|error| error.to_string())?;
53
- serde_json::to_writer(&mut file, &event).map_err(|error| error.to_string())?;
54
- file.write_all(b"\n").map_err(|error| error.to_string())?;
55
- file.flush().map_err(|error| error.to_string())?;
56
- file.sync_data().map_err(|error| error.to_string())?;
57
- Ok(())
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
  }