Skip to content

Commit 88b6e5c

Browse files
authored
Merge pull request #19 from pbudzik/feat/polish-bundle-2
Polish bundle: file locking, sort-on-flush, RLE for kind
2 parents e0b87ee + 4e714f3 commit 88b6e5c

13 files changed

Lines changed: 554 additions & 40 deletions

File tree

Cargo.lock

Lines changed: 94 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ clap = { version = "4", features = ["derive"] }
1515
byteorder = "1.5.0"
1616
bytes = "1.11.1"
1717
chrono = { version = "0.4.44", features = ["serde"] }
18+
fs4 = "0.13"
1819
hyper = "1.9.0"
1920
lz4_flex = "0.13.1"
2021
memmap2 = "0.9.10"

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,8 @@ What works end-to-end:
117117
- Durable batch ingest with idempotent dedupe (blake3 128-bit hash, 7-day TTL)
118118
- WAL rotation, sealing, and crash recovery (memtable rebuilt from unsealed files)
119119
- Atomic manifest updates (tmp + rename + parent dir fsync)
120-
- Background memtable flush → immutable columnar raw segments, partitioned by `bucket = blake3(account_id) % bucket_count`
120+
- Background memtable flush → immutable columnar raw segments, partitioned by `bucket = blake3(account_id) % bucket_count`, written in canonical billing order `(account, product, meter, model, ts)` (sort-on-flush) so compaction is cheaper and dictionary encoding compresses better
121+
- Exclusive process lock (`db_root/LOCK` via `flock`) — prevents server + admin commands from racing on the same database
121122
- Columnar on-disk format with per-column zstd compression and blake3 checksum (see [Segment format](#segment-format))
122123
- Background hourly rollup scheduler — seals completed hours into per-bucket rollup segments, advances the manifest watermark atomically, query path routes `RollupHourly` through rollups with raw fallback for the open-period tail
123124
- Background compaction scheduler — merges small per-bucket segments into a single output, applies the `ReplacementRecord` to the manifest, deletes old files after a configurable reader grace period (spec §15.3)
@@ -144,7 +145,6 @@ db_root/
144145

145146
Known gaps (tracked against `rust_ai_usage_db_spec.md`):
146147

147-
- No RLE encoding yet (used for `kind`-style low-cardinality columns); `Plain` + zstd handles it adequately.
148148
- No block-level metadata for fine-grained skipping inside a segment; pruning is segment-level only.
149149
- Rollup segments still use length-prefixed bincode (not the columnar format) — they're tiny so it hasn't been a win yet
150150
- COUNT semantics differ for `RollupHourly` queries: each rollup row counts as 1, not as the number of underlying events. Use `RawEvents` source for exact event counts.
@@ -178,10 +178,11 @@ Each column payload before compression is encoded based on its declared `encodin
178178

179179
| Encoding | Layout | Used for |
180180
| --- | --- | --- |
181-
| `Plain` (0) | bincode-serialized `Vec<T>` | `event_id`, `kind`, `correction_ref`, `dimensions` |
181+
| `Plain` (0) | bincode-serialized `Vec<T>` | `event_id`, `correction_ref`, `dimensions` |
182182
| `Dictionary` (1) | bincode `(Vec<String>, Vec<u32>)` — unique values + index per row | `account_id`, `product_id`, `meter_id`, `model_id`, `source`, `unit`, `subscription_id` |
183183
| `Delta` (2) | bincode `Vec<i64>` of running differences | `timestamp_ms`, `ingested_at_ms` |
184184
| `Zigzag` (3) | `u32 count` + concatenated zigzag-varints | `quantity` |
185+
| `Rle` (4) | bincode `Vec<(u8, u32)>` of (value, run_length) | `kind` |
185186

186187
Dictionary collapses ID columns from O(rows × string size) to O(unique values × string size + 4 bytes/row); for ID-heavy workloads this is a 1000× shrink on the column. Delta encoding turns near-monotonic timestamps into small differences that zstd compresses dramatically better. Zigzag-varint packs small i128 quantities into 1–2 bytes instead of 16.
187188

src/admin.rs

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,22 @@ use crate::ingest::memtable::Memtable;
1717
use crate::ingest::wal::Wal;
1818
use crate::rollup::worker::RollupWorker;
1919
use crate::runtime::config::Config;
20+
use crate::runtime::lock::DbLock;
2021
use crate::runtime::state::{AppState, AppStateInner};
2122
use crate::storage::manifest::{Manifest, SegmentKind};
2223
use crate::storage::segment_reader::RawSegmentReader;
2324

24-
/// Build a read-only AppState by loading the manifest directly. Does NOT
25-
/// run the full Recovery flow (no segment-scan dedupe rebuild, no WAL
26-
/// replay) — admin commands operate on the on-disk state as-is. Cheaper
27-
/// than `run_startup_recovery` for one-shot commands.
28-
pub fn open_state_for_admin(config: Config) -> anyhow::Result<AppState> {
25+
/// Build a read-only AppState by loading the manifest directly. Acquires
26+
/// the DB process lock at the same time and returns it as a separate
27+
/// guard the caller must hold for the duration of the admin operation.
28+
/// Does NOT run the full Recovery flow (no segment-scan dedupe rebuild,
29+
/// no WAL replay) — admin commands operate on the on-disk state as-is.
30+
///
31+
/// Returning `(AppState, DbLock)` rather than embedding the lock in the
32+
/// state keeps AppStateInner test-friendly: tests that build state
33+
/// manually (each with a fresh tempdir) don't need to also fake a lock.
34+
pub fn open_state_for_admin(config: Config) -> anyhow::Result<(AppState, DbLock)> {
35+
let lock = DbLock::acquire(&config.db_root)?;
2936
let manifest = Manifest::load(&config.db_root)?.ok_or_else(|| {
3037
anyhow::anyhow!(
3138
"no manifest found at {:?} — run the server at least once to initialize the DB",
@@ -36,15 +43,16 @@ pub fn open_state_for_admin(config: Config) -> anyhow::Result<AppState> {
3643
std::fs::create_dir_all(&wal_dir)?;
3744
let wal = Wal::open(wal_dir, manifest.last_sealed_wal_id)?;
3845
let (flush_sender, _r) = tokio::sync::mpsc::channel(4);
39-
Ok(Arc::new(AppStateInner {
46+
let state = Arc::new(AppStateInner {
4047
config,
4148
// Minimal dedupe — admin commands don't ingest.
4249
dedupe: Mutex::new(HotDedupe::new(1)),
4350
wal: Mutex::new(wal),
4451
memtable: Mutex::new(Memtable::new()),
4552
manifest: RwLock::new(manifest),
4653
flush_sender,
47-
}))
54+
});
55+
Ok((state, lock))
4856
}
4957

5058
/// `usagedb check [--deep]` — print manifest summary; with --deep, also

src/ingest/flusher.rs

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,19 +153,28 @@ impl FlusherWorker {
153153
/// Write one bucket's segment + return its metadata. Returns Err with
154154
/// a human-readable reason on any failure; the caller cleans up the
155155
/// partial file and any earlier bucket segments via `rollback_partial`.
156+
///
157+
/// Sort-on-flush: events are written in the spec's canonical order
158+
/// via `sort_events_canonical`. This matches the order compaction
159+
/// would produce, so reading + filtering is monotonic across the
160+
/// column families that drive billing queries. Saves a sort pass on
161+
/// every later compaction and improves dictionary encoding locality.
156162
fn write_bucket(
157163
&self,
158164
bucket: u32,
159165
bucket_events: &[UsageEvent],
160166
) -> Result<(SegmentMeta, PathBuf), String> {
167+
let mut sorted = bucket_events.to_vec();
168+
sort_events_canonical(&mut sorted);
169+
161170
let segment_id = format!("raw_{}", uuid::Uuid::new_v4().simple());
162171
let path = self.state.config.db_root.join(format!("{}.seg", segment_id));
163172

164173
let mut writer = match RawSegmentWriter::new(path.clone()) {
165174
Ok(w) => w,
166175
Err(e) => return Err(format!("create segment for bucket {}: {}", bucket, e)),
167176
};
168-
for event in bucket_events {
177+
for event in &sorted {
169178
if let Err(e) = writer.write_event(event) {
170179
let _ = std::fs::remove_file(&path);
171180
return Err(format!("write event to bucket {} segment {}: {}", bucket, segment_id, e));
@@ -179,7 +188,7 @@ impl FlusherWorker {
179188
}
180189
};
181190

182-
Ok((build_segment_meta(&segment_id, bucket_events, bucket, checksum), path))
191+
Ok((build_segment_meta(&segment_id, &sorted, bucket, checksum), path))
183192
}
184193
}
185194

@@ -189,6 +198,24 @@ fn rollback_partial(paths: &[PathBuf]) {
189198
}
190199
}
191200

201+
/// Sort events into the spec's canonical billing order:
202+
/// `(account_id, product_id, meter_id, model_id, timestamp_ms)`.
203+
/// Used by the flusher (sort-on-flush) and also exposed publicly so
204+
/// tests can verify the algorithm without driving the async pipeline.
205+
pub fn sort_events_canonical(events: &mut [UsageEvent]) {
206+
events.sort_by(|a, b| {
207+
a.account_id.0.cmp(&b.account_id.0)
208+
.then_with(|| a.product_id.0.cmp(&b.product_id.0))
209+
.then_with(|| a.meter_id.0.cmp(&b.meter_id.0))
210+
.then_with(|| {
211+
let am = a.model_id.as_ref().map(|m| m.0.as_str()).unwrap_or("");
212+
let bm = b.model_id.as_ref().map(|m| m.0.as_str()).unwrap_or("");
213+
am.cmp(bm)
214+
})
215+
.then_with(|| a.timestamp_ms.cmp(&b.timestamp_ms))
216+
});
217+
}
218+
192219
/// Build a SegmentMeta covering all events in the batch with the given bucket
193220
/// and segment checksum (returned by `RawSegmentWriter::finish`).
194221
pub fn build_segment_meta(segment_id: &str, batch: &[UsageEvent], bucket: u32, checksum: u64) -> SegmentMeta {

0 commit comments

Comments
 (0)