···5050rustls = { version = "0.23", features = ["aws-lc-rs"] }
5151tokio-tungstenite = { version = "0.28.0", features = ["rustls-tls-native-roots"] }
5252multibase = "0.9.2"
5353+sha2 = "0.10.9"
53545455[dev-dependencies]
5556tempfile = "3.26.0"
+4-3
README.md
···4646| `RELAY_HOST` | `wss://relay.fire.hose.cam/` | URL of the relay. |
4747| `RELAY_HOSTS` | | comma-separated list of relay URLs. if unset, falls back to `RELAY_HOST`. |
4848| `PLC_URL` | `https://plc.wtf`, `https://plc.directory` if full network | base URL(s) of the PLC directory (comma-separated for multiple). |
4949-| `EPHEMERAL` | `false` | if enabled, no records are stored. events are only stored up to 10 minutes for playback. |
4949+| `EPHEMERAL` | `false` | if enabled, no records are stored. events are deleted after a certain duration (`EPHEMERAL_TTL`). |
5050+| `EPHEMERAL_TTL` | `60min` | decides after how long events should be deleted. |
5051| `FULL_NETWORK` | `false` | if `true`, discovers and indexes all repositories in the network. |
5152| `FILTER_SIGNALS` | | comma-separated list of NSID patterns to use for the filter (e.g. `app.bsky.feed.post,app.bsky.graph.*`). |
5253| `FILTER_COLLECTIONS` | | comma-separated list of NSID patterns to use for the collections filter. |
···5455| `FIREHOSE_WORKERS` | `8` (`24` if full network) | number of concurrent workers for firehose events. |
5556| `BACKFILL_CONCURRENCY_LIMIT` | `16` (`64` if full network) | maximum number of concurrent backfill tasks. |
5657| `VERIFY_SIGNATURES` | `full` | signature verification level: `full`, `backfill-only`, or `none`. |
5757-| `CURSOR_SAVE_INTERVAL` | `3` | interval (in seconds) to save the firehose cursor. |
5858-| `REPO_FETCH_TIMEOUT` | `300` | timeout (in seconds) for fetching repositories. |
5858+| `CURSOR_SAVE_INTERVAL` | `3sec` | interval (in seconds) to save the firehose cursor. |
5959+| `REPO_FETCH_TIMEOUT` | `5min` | timeout (in seconds) for fetching repositories. |
5960| `CACHE_SIZE` | `256` | size of the database cache in MB. |
6061| `IDENTITY_CACHE_SIZE` | `100000` | number of identity entries to cache. |
6162| `API_PORT` | `3000` | port for the API server. |
···11-//! ephemeral mode block lifecycle: in-memory refcounting and TTL-based event expiry.
22-//!
33-//! ## model
44-//!
55-//! every event that references a block CID holds one refcount entry. the TTL worker
66-//! decrements the count when the event expires. when the count hits zero the block is
77-//! deleted inline in the same batch as the event deletion.
88-//!
99-//! ## correctness
1010-//!
1111-//! - refcounts are rebuilt from `db.events` on startup before the server accepts requests.
1212-//! - shared CIDs are handled correctly: two events referencing the same block each
1313-//! increment the counter; the block is deleted only when the second one expires.
1414-151use crate::db::{Db, keys};
1616-use crate::types::StoredEvent;
1717-use fjall::Slice;
182use miette::{IntoDiagnostic, WrapErr};
193use std::sync::Arc;
204use std::sync::atomic::Ordering;
215use std::time::Duration;
2222-use tracing::{debug, error, info, trace};
2323-2424-pub const EVENT_TTL_SECS: u64 = 60 * 10;
2525-2626-/// rebuilds `db.block_refcounts` by scanning all stored events.
2727-/// must be called on startup in ephemeral mode before accepting requests.
2828-pub fn ephemeral_startup_load_refcounts(db: &Db) -> miette::Result<()> {
2929- info!("rebuilding block refcounts from events (ephemeral mode)");
3030- for guard in db.events.iter() {
3131- let v = guard.value().into_diagnostic()?;
3232- let evt = rmp_serde::from_slice::<StoredEvent>(&v).into_diagnostic()?;
3333- let Some(cid) = evt.cid else { continue };
3434- let block_key = Slice::from(keys::block_key(evt.collection.as_str(), &cid.to_bytes()));
3535- let mut entry = db.block_refcounts.entry_sync(block_key).or_insert(0);
3636- *entry += 1;
3737- }
3838- trace!("ephemeral block refcounts ready");
3939- Ok(())
4040-}
66+use tracing::{debug, error, info};
417428pub fn ephemeral_ttl_worker(state: Arc<crate::state::AppState>) {
439 info!("ephemeral TTL worker started");
4410 loop {
4511 std::thread::sleep(Duration::from_secs(60));
4646- if let Err(e) = ephemeral_ttl_tick(&state.db) {
1212+ if let Err(e) = ephemeral_ttl_tick(&state.db, &state.ephemeral_ttl) {
4713 error!(err = %e, "ephemeral TTL tick failed");
4814 }
4915 }
5016}
51175252-pub fn ephemeral_ttl_tick(db: &Db) -> miette::Result<()> {
1818+pub fn ephemeral_ttl_tick(db: &Db, ttl: &Duration) -> miette::Result<()> {
5319 let now = chrono::Utc::now().timestamp() as u64;
5454- let cutoff_ts = now.saturating_sub(EVENT_TTL_SECS);
2020+ let cutoff_ts = now.saturating_sub(ttl.as_secs());
55215622 // write current watermark
5723 let current_event_id = db.next_event_id.load(Ordering::SeqCst);
···9056 let mut pruned = 0usize;
91579258 for guard in db.events.range(..cutoff_key_events) {
9393- let (k, v) = guard.into_inner().into_diagnostic()?;
9494- let evt = rmp_serde::from_slice::<StoredEvent>(&v).into_diagnostic()?;
9595-9696- if let Some(cid) = evt.cid {
9797- let block_key = Slice::from(keys::block_key(evt.collection.as_str(), &cid.to_bytes()));
9898-9999- let remove_block = {
100100- let count = db
101101- .block_refcounts
102102- .entry_sync(block_key.clone())
103103- .and_modify(|c| {
104104- *c = c.saturating_sub(1);
105105- })
106106- .or_default();
107107- *count == 0
108108- };
109109-110110- if remove_block {
111111- db.block_refcounts.remove_sync(&block_key);
112112- batch.remove(&db.blocks, block_key);
113113- }
114114- }
115115-5959+ let k = guard.key().into_diagnostic()?;
11660 batch.remove(&db.events, k);
11761 pruned += 1;
11862 }
+23-10
src/db/mod.rs
···4848 pub counts: Keyspace,
4949 pub filter: Keyspace,
5050 pub crawler: Keyspace,
5151- // only meaningful in ephemeral mode; empty and unused in non-ephemeral
5252- pub block_refcounts: scc::HashMap<Slice, u32>,
5351 pub event_tx: broadcast::Sender<BroadcastEvent>,
5452 pub next_event_id: Arc<AtomicU64>,
5553 pub counts_map: HashMap<SmolStr, u64>,
···226224 .data_block_compression_policy(CompressionPolicy::disabled())
227225 .data_block_restart_interval_policy(RestartIntervalPolicy::all(4)),
228226 )?;
227227+ // this is used in non-ephemeral mode
229228 let blocks = open_ks(
230229 "blocks",
231230 opts()
···296295 // eg. by many different repos and different records etc.
297296 // since its sequential we should still go with bigger block size though
298297 // backfills will be sequential though...
299299- .data_block_size_policy(BlockSizePolicy::new([kb(16), kb(64)]))
298298+ .data_block_size_policy(
299299+ cfg.ephemeral
300300+ .then(|| BlockSizePolicy::new([kb(64), kb(128), kb(256)]))
301301+ .unwrap_or_else(|| BlockSizePolicy::new([kb(16), kb(64)])),
302302+ )
300303 // we are streaming the new events to consumers so we dont want to compress them
301301- .data_block_compression_policy(CompressionPolicy::new([
302302- CompressionType::None,
303303- get_compression("events", 3),
304304- get_compression("events", 3),
305305- get_compression("events", 5),
306306- ]))
304304+ .data_block_compression_policy(
305305+ cfg.ephemeral
306306+ .then(|| {
307307+ CompressionPolicy::new([
308308+ CompressionType::None,
309309+ get_compression("events", 3),
310310+ ])
311311+ })
312312+ .unwrap_or_else(|| {
313313+ CompressionPolicy::new([
314314+ CompressionType::None,
315315+ get_compression("events", 3),
316316+ get_compression("events", 3),
317317+ get_compression("events", 5),
318318+ ])
319319+ }),
320320+ )
307321 // ids are int, we can prefix truncate a lot
308322 .data_block_restart_interval_policy(RestartIntervalPolicy::new([64, 128])),
309323 )?;
···400414 counts,
401415 filter,
402416 crawler,
403403- block_refcounts: scc::HashMap::default(),
404417 event_tx,
405418 counts_map,
406419 next_event_id: Arc::new(AtomicU64::new(last_id + 1)),
+3-5
src/main.rs
···7979 let state = Arc::new(state);
80808181 if cfg.ephemeral {
8282- db::ephemeral::ephemeral_startup_load_refcounts(&state.db)?;
8383-8484- let state_ttl = state.clone();
8282+ let state = state.clone();
8583 std::thread::Builder::new()
8686- .name("ephemeral-ttl".into())
8787- .spawn(move || db::ephemeral::ephemeral_ttl_worker(state_ttl))
8484+ .name("ephemeral-gc".into())
8585+ .spawn(move || db::ephemeral::ephemeral_ttl_worker(state))
8886 .into_diagnostic()?;
8987 }
9088
+20-12
src/ops.rs
···1818use crate::db::{self, Db, keys, ser_repo_state};
1919use crate::filter::FilterConfig;
2020use crate::ingest::stream::Commit;
2121+use crate::types::StoredData;
2122use crate::types::{
2223 AccountEvt, BroadcastEvent, IdentityEvt, MarshallableEvt, RepoState, RepoStatus, ResyncState,
2324 StoredEvent,
···282283 let event_id = db.next_event_id.fetch_add(1, Ordering::SeqCst);
283284284285 let action = DbAction::try_from(op.action.as_str())?;
285285- match action {
286286+ let block = match action {
286287 DbAction::Create | DbAction::Update => {
287288 let Some(cid) = &op.cid else {
288289 continue;
···299300 };
300301 let cid_raw = cid_ipld.to_bytes();
301302 let block_key = Slice::from(keys::block_key(collection, &cid_raw));
302302- batch.insert(&db.blocks, block_key.clone(), bytes.to_vec());
303303- blocks_count += 1;
304303304304+ blocks_count += 1;
305305 if !ephemeral {
306306+ batch.insert(&db.blocks, block_key.clone(), bytes.as_ref());
306307 batch.insert(&db.records, db_key.clone(), cid_raw);
307308 // accumulate counts
308309 if action == DbAction::Create {
309310 records_delta += 1;
310311 *collection_deltas.entry(collection).or_default() += 1;
311312 }
313313+ None
312314 } else if action == DbAction::Create || action == DbAction::Update {
313313- // ephemeral: track refcount for this event's CID so the TTL worker can
314314- // delete the block when the last referencing event expires
315315- let mut entry = db
316316- .block_refcounts
317317- .entry_sync(block_key.clone())
318318- .or_insert(0);
319319- *entry += 1;
315315+ Some(bytes.clone())
316316+ } else {
317317+ unreachable!("we tested if we are in create or update action")
320318 }
321319 }
322320 DbAction::Delete => {
···327325 records_delta -= 1;
328326 *collection_deltas.entry(collection).or_default() -= 1;
329327 }
328328+329329+ None
330330 }
331331- }
331331+ };
332332333333 let evt = StoredEvent {
334334 live: true,
···337337 collection: CowStr::Borrowed(collection),
338338 rkey,
339339 action,
340340- cid: op.cid.as_ref().map(|c| c.to_ipld().expect("valid cid")),
340340+ data: block
341341+ .map(StoredData::Block)
342342+ .or_else(|| {
343343+ op.cid
344344+ .as_ref()
345345+ .map(|c| c.to_ipld().expect("valid cid"))
346346+ .map(StoredData::Ptr)
347347+ })
348348+ .unwrap_or(StoredData::Nothing),
341349 };
342350343351 let bytes = rmp_serde::to_vec(&evt).into_diagnostic()?;
+3-4
src/state.rs
···11-use std::collections::HashMap;
21use std::sync::atomic::AtomicI64;
22+use std::{collections::HashMap, time::Duration};
3344use miette::Result;
55use tokio::sync::{Notify, watch};
···1818 pub filter: FilterHandle,
1919 pub relay_cursors: HashMap<Url, AtomicI64>,
2020 pub backfill_notify: Notify,
2121- /// Controls whether the crawler is running. Receivers are held by crawler tasks.
2221 pub crawler_enabled: watch::Sender<bool>,
2323- /// Controls whether firehose ingestion is running. Receivers are held by ingestor tasks.
2422 pub firehose_enabled: watch::Sender<bool>,
2525- /// Controls whether the backfill worker picks up new tasks. Receiver is held by the backfill worker.
2623 pub backfill_enabled: watch::Sender<bool>,
2424+ pub ephemeral_ttl: Duration,
2725}
28262927impl AppState {
···5957 crawler_enabled,
6058 firehose_enabled,
6159 backfill_enabled,
6060+ ephemeral_ttl: config.ephemeral_ttl.clone(),
6261 })
6362 }
6463