use std::collections::BTreeMap;
use std::sync::Arc;
use haematite::tree::Hash;
use liminal::durability::bridge::block_on;
use liminal::durability::{DurableStore, RoutedBatchItem, StoreCounts};
use super::checkpoint_body_parts::{PART_KEY_PREFIX, legacy_part_keys};
use super::operation_batch::OperationBatch;
use super::prune::{PRUNE_RECEIPT_PREFIX, PruneReceipt};
use super::registry::REGISTRY_STREAM_KEY;
use super::restore_checkpoint_locator::{
CheckpointBodyKey, CheckpointLocator, checkpoint_conversation_route,
};
use super::route_keys::namespaced;
pub(super) const MARKER_KEY: &[u8] = b"registry/route-keys-migrated/v1";
pub(super) fn fresh_marker() -> RoutedBatchItem {
RoutedBatchItem {
key: MARKER_KEY.to_vec(),
expected: None,
value: b"written under the namespace from the first registration".to_vec(),
ttl: None,
}
}
#[derive(Debug, thiserror::Error)]
#[error("route key migration: {0}")]
pub(super) struct RouteKeyMigrationError(String);
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(super) struct RouteKeyMigration {
pub(super) migrated: Vec<u64>,
pub(super) skipped: u64,
pub(super) without_checkpoint: Vec<u64>,
pub(super) refused: Vec<(u64, String)>,
pub(super) counts: Option<StoreCounts>,
}
impl RouteKeyMigration {
pub(super) fn line(&self) -> String {
let counts = self.counts.as_ref().map_or_else(String::new, |counts| {
format!(
"; store counts: get_routed={} scan_page={} apply_routed_batch={} truncate={}",
counts.get_routed_calls,
counts.scan_page_calls,
counts.apply_routed_batch_calls,
counts.truncate_calls
)
});
format!(
"route key migration: {} migrated, {} skipped, {} without a checkpoint, {} refused{counts}",
self.migrated.len(),
self.skipped,
self.without_checkpoint.len(),
self.refused.len()
)
}
}
enum Outcome {
Migrated,
Skipped,
WithoutCheckpoint,
Refused(String),
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
enum Reached {
Shard(usize),
Route(String),
}
pub(super) fn migrate_route_keys(
store: &Arc<dyn DurableStore>,
ids: &[u64],
) -> Result<Option<RouteKeyMigration>, RouteKeyMigrationError> {
if ids.is_empty() || read(store, REGISTRY_STREAM_KEY, MARKER_KEY)?.is_some() {
return Ok(None);
}
let before = store.store_counts().map(|handle| handle.snapshot());
let spine = store.reclaim_spine();
let mut report = RouteKeyMigration::default();
let mut reached: BTreeMap<Reached, String> = BTreeMap::new();
for &id in ids {
let route = checkpoint_conversation_route(id);
match migrate_one(store, id, &route)? {
Outcome::Migrated => report.migrated.push(id),
Outcome::Skipped => report.skipped += 1,
Outcome::WithoutCheckpoint => report.without_checkpoint.push(id),
Outcome::Refused(reason) => report.refused.push((id, reason)),
}
let place = spine.as_ref().map_or_else(
|| Reached::Route(route.clone()),
|spine| Reached::Shard(spine.database().shard_for(route.as_bytes())),
);
reached.entry(place).or_insert(route);
}
for route in reached.values() {
for (from, to) in legacy_windows() {
block_on(store.remove_routed_range(route, &from, &to))
.map_err(|error| hung("cutting a legacy window", route, &error))?
.map_err(|error| failed("cutting a legacy window", route, &error))?;
}
}
report.counts = match (before, store.store_counts()) {
(Some(before), Some(handle)) => Some(handle.snapshot().since(&before)),
_ => None,
};
let line = report.line();
let mut marker = OperationBatch::new(REGISTRY_STREAM_KEY);
marker
.stage(RoutedBatchItem {
key: MARKER_KEY.to_vec(),
expected: None,
value: line.clone().into_bytes(),
ttl: None,
})
.map_err(|error| failed("staging the marker", REGISTRY_STREAM_KEY, &error))?;
block_on(marker.commit(store.as_ref(), || {}))
.map_err(|error| hung("writing the marker", REGISTRY_STREAM_KEY, &error))?
.map_err(|error| failed("writing the marker", REGISTRY_STREAM_KEY, &error))?;
tracing::info!(
migrated = report.migrated.len(),
skipped = report.skipped,
without_checkpoint = report.without_checkpoint.len(),
refused = report.refused.len(),
"{line}"
);
Ok(Some(report))
}
fn migrate_one(
store: &Arc<dyn DurableStore>,
id: u64,
route: &str,
) -> Result<Outcome, RouteKeyMigrationError> {
if read(store, route, &CheckpointLocator::routed_key(id))?.is_some() {
return Ok(Outcome::Skipped);
}
let Some(legacy) = read(store, route, CheckpointLocator::legacy_routed_key())? else {
return Ok(Outcome::WithoutCheckpoint);
};
let named = match CheckpointLocator::decode_named(&legacy) {
Ok(named) => named,
Err(error) => {
return Ok(Outcome::Refused(format!(
"legacy locator does not read: {error}"
)));
}
};
if named != id {
return Ok(Outcome::WithoutCheckpoint);
}
let locator = match CheckpointLocator::decode_canonical(&legacy, id) {
Ok(locator) => locator,
Err(error) => {
return Ok(Outcome::Refused(format!(
"legacy locator does not read: {error}"
)));
}
};
let body_key = locator.body_key();
let Some(head) = read(store, route, &body_key.legacy_routed_key())? else {
return Ok(Outcome::Refused(
"legacy locator names a body the route does not hold".to_owned(),
));
};
let mut batch = OperationBatch::new(route);
let mut put = |key: Vec<u8>, value: Vec<u8>| {
batch
.stage(RoutedBatchItem {
key,
expected: None,
value,
ttl: None,
})
.map_err(|error| failed("staging a namespaced key", route, &error))
};
for legacy_part in legacy_part_keys(&head) {
let Some(part) = read(store, route, &legacy_part)? else {
return Ok(Outcome::Refused(
"legacy manifest names a part the route does not hold".to_owned(),
));
};
put(namespaced(id, &legacy_part), part)?;
}
put(body_key.routed_key(id), head)?;
let (from, to) = receipt_window();
let receipts = block_on(store.scan_routed(route, &from, &to))
.map_err(|error| hung("reading the legacy prune ledger", route, &error))?
.map_err(|error| failed("reading the legacy prune ledger", route, &error))?;
for (key, value) in receipts {
let names_id = serde_json::from_slice::<PruneReceipt>(&value)
.is_ok_and(|receipt| receipt.conversation_id == id);
if names_id {
put(namespaced(id, &key), value)?;
}
}
put(CheckpointLocator::routed_key(id), legacy.clone())?;
batch
.stage_delete(
CheckpointLocator::legacy_routed_key().to_vec(),
Hash::of(&legacy),
)
.map_err(|error| failed("staging the legacy locator's removal", route, &error))?;
block_on(batch.commit(store.as_ref(), || {}))
.map_err(|error| hung("committing the namespaced checkpoint", route, &error))?
.map_err(|error| failed("committing the namespaced checkpoint", route, &error))?;
Ok(Outcome::Migrated)
}
pub(super) fn legacy_windows() -> [(Vec<u8>, Vec<u8>); 4] {
[
past(CheckpointLocator::legacy_routed_key()),
past(CheckpointBodyKey::legacy_key_prefix()),
past(PART_KEY_PREFIX),
receipt_window(),
]
}
pub(super) fn past(prefix: &[u8]) -> (Vec<u8>, Vec<u8>) {
let from = prefix.to_vec();
let mut to = from.clone();
if let Some(last) = to.last_mut() {
*last = last.saturating_add(1);
}
(from, to)
}
fn receipt_window() -> (Vec<u8>, Vec<u8>) {
past(PRUNE_RECEIPT_PREFIX.as_bytes())
}
fn read(
store: &Arc<dyn DurableStore>,
route: &str,
key: &[u8],
) -> Result<Option<Vec<u8>>, RouteKeyMigrationError> {
block_on(store.get_routed(route, key))
.map_err(|error| hung("a routed read", route, &error))?
.map_err(|error| failed("a routed read", route, &error))
}
fn hung(step: &str, route: &str, error: &dyn std::fmt::Display) -> RouteKeyMigrationError {
RouteKeyMigrationError(format!("{step} on {route} hung: {error}"))
}
fn failed(step: &str, route: &str, error: &dyn std::fmt::Display) -> RouteKeyMigrationError {
RouteKeyMigrationError(format!("{step} on {route} failed: {error}"))
}