tuwunel_service/migrations/injectivity/mod.rs
1//! Short id injectivity: the one-time scan and repair.
2//!
3//! Releases before v1.8.3 could mint two short ids for one identity,
4//! leaving stale reverse rows in both families, ghost entries in a few
5//! compressed states, and auth chains cached from both allocations. The
6//! migration measures that residue, repairs what matches the shapes it
7//! handles, and marks itself complete like any other. Every release
8//! through v1.8.3 also memoized auth chains truncated at a missing
9//! ancestor, which no scan tells from a whole one, so the cache is
10//! discarded once on a marker of its own.
11
12mod repair;
13mod scan;
14
15use tuwunel_core::{
16 Result,
17 result::{LogErr, NotFound},
18 warn,
19};
20
21use self::{
22 repair::{heal, repair},
23 scan::scan,
24};
25use crate::{Service, Services};
26
27/// Global marker recording the repair ran to completion.
28///
29/// Refused or unverifiable residue leaves it unwritten, so the next boot
30/// scans again.
31static MARKER: &[u8] = b"fix_short_injectivity";
32
33/// Global marker recording the one-time auth chain cache clear.
34///
35/// Gating the clear on [`MARKER`] would re-run it on every boot a refused
36/// repair leaves unstamped.
37static CLEAR_MARKER: &[u8] = b"clear_auth_chain_cache";
38
39/// Scan passes one boot allows before giving up on convergence.
40///
41/// A heal completes torn writes and rescans to re-measure what they
42/// explain, and each pass strictly reduces the classes it heals, so the
43/// second pass is the one that repairs. The last pass never heals, which
44/// bounds a shape that does not settle and keeps the dirt-driven clearing
45/// lane in [`repair`] reachable on every boot.
46const PASSES: usize = 3;
47
48/// Runs the one-time chain cache clear, then the injectivity scan, heal,
49/// and repair behind [`MARKER`].
50///
51/// The clear takes [`CLEAR_MARKER`] and runs ahead of the early return, so
52/// a database that already completed the repair still discards its chains.
53/// The stamp follows the repair's own verdict: only a settled repair writes
54/// it. A heal rescans rather than repairing, because the orphan and parent
55/// counts a refusal turns on are taken against bitmaps the heal changes.
56#[tracing::instrument(level = "debug", skip_all)]
57pub(super) async fn fix(services: &Services) -> Result {
58 let global = &services.db["global"];
59
60 if global.get(CLEAR_MARKER).await.is_not_found() {
61 clear_chain_cache(services).await;
62 services.db["authchainkey_authchain"]
63 .sort()
64 .log_err()
65 .ok();
66 }
67
68 if !global.get(MARKER).await.is_not_found() {
69 return Ok(());
70 }
71
72 for pass in 1..=PASSES {
73 let residue = scan(services).await?;
74
75 // The last pass repairs rather than heals, so the dirt-driven
76 // clearing lane still fires on a boot whose heals never settle.
77 if pass < PASSES && heal(services, &residue) {
78 continue;
79 }
80
81 if repair(services, &residue).await? {
82 global.insert(MARKER, []);
83 }
84
85 break;
86 }
87
88 Ok(())
89}
90
91/// Discards auth chains cached before walk completeness was enforced.
92///
93/// A chain truncated at a missing ancestor is well-formed, so no scan
94/// separates it from a whole one and the population goes at once. The
95/// cache is derived and rebuilds on demand.
96#[tracing::instrument(level = "debug", skip_all)]
97async fn clear_chain_cache(services: &Services) {
98 let global = &services.db["global"];
99
100 warn!("Discarding cached auth chains; entries from earlier releases may be truncated.");
101
102 clear_chains(services).await;
103 global.insert(CLEAR_MARKER, []);
104}
105
106/// Deletes every auth chain cache row under one cork.
107///
108/// `Map::clear` deletes key by key and `Map::remove` flushes the WAL per
109/// key when uncorked. It is snapshot-based, so it holds only because
110/// migrations precede the workers that populate the cache.
111pub(super) async fn clear_chains(services: &Services) {
112 let _cork = services.db.cork_and_sync();
113
114 services.auth_chain.clear_cache().await;
115}
116
117/// Stamps both markers on a fresh database.
118///
119/// A fresh database never ran the unserialized allocator and holds no
120/// cached chains, so it has neither residue to scan for nor a cache to
121/// discard.
122pub(super) fn mark_clean(services: &Services) {
123 let global = &services.db["global"];
124
125 global.insert(MARKER, []);
126 global.insert(CLEAR_MARKER, []);
127}