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::{Result, result::LogErr, utils::TryReadyExt, warn};
16
17use self::{
18 repair::{heal, repair},
19 scan::scan,
20};
21use crate::Services;
22
23/// Global marker recording the scan and repair reached a verdict.
24///
25/// A decline stamps it like a settled repair, so no residue causes a
26/// rescan on later boots; the decline's counters become the value where
27/// a settled repair leaves it empty. Only an error leaves it unwritten,
28/// and an error fails the boot. Every reader tests presence only.
29static MARKER: &str = "fix_short_injectivity";
30
31/// Global marker recording the one-time auth chain cache clear.
32///
33/// Gating the clear on [`MARKER`] would re-run it on every boot an
34/// errored repair leaves unstamped.
35static CLEAR_MARKER: &str = "clear_auth_chain_cache";
36
37/// Scan passes one boot allows before giving up on convergence.
38///
39/// A heal completes torn writes and rescans to re-measure what they
40/// explain. Early passes may heal, while the final pass either repairs the
41/// settled residue or declines one that remains healable.
42const PASSES: usize = 3;
43
44/// The verdict [`repair`] reaches; the caller stamps [`MARKER`] on any
45/// verdict.
46///
47/// Only an error escapes without a verdict, leaving the marker unwritten
48/// and failing the boot.
49enum Verdict {
50 Settled,
51 Declined(Reason),
52}
53
54/// Why a repair declined, numbered for the decline record.
55///
56/// The reason decides which counters of the record were measured: an
57/// unverifiable scan measured nothing past the counter, a healable or
58/// family-anomalous verdict measured the families only, and a deep
59/// anomaly measured everything.
60enum Reason {
61 Unverifiable = 1,
62 Healable = 2,
63 FamilyAnomalous = 3,
64 DeepAnomalous = 4,
65}
66
67impl From<Reason> for u64 {
68 fn from(reason: Reason) -> Self {
69 match reason {
70 | Reason::Unverifiable => 1,
71 | Reason::Healable => 2,
72 | Reason::FamilyAnomalous => 3,
73 | Reason::DeepAnomalous => 4,
74 }
75 }
76}
77
78/// Runs the one-time chain cache clear, then the injectivity scan, heal,
79/// and repair behind [`MARKER`].
80///
81/// The clear takes [`CLEAR_MARKER`] and runs ahead of the early return, so
82/// a database that already completed the repair still discards its chains.
83/// The stamp follows any verdict: a settled repair stamps the empty value,
84/// a decline stamps its counters, and only an error leaves the marker
85/// unwritten and fails the boot. A heal rescans rather than repairing,
86/// because it changes the losers and winners the repair consumes.
87#[tracing::instrument(level = "debug", skip_all)]
88pub(super) async fn fix(services: &Services) -> Result {
89 let global = &services.db["global"];
90 let chains_cleared_this_boot = match global.get(CLEAR_MARKER).await {
91 | Ok(_) => false,
92 | Err(error) if error.is_not_found() => true,
93 | Err(error) => return Err(error),
94 };
95
96 if chains_cleared_this_boot {
97 clear_chain_cache(services).await?;
98 services.db["authchainkey_authchain"]
99 .sort()
100 .log_err()
101 .ok();
102 }
103
104 match global.get(MARKER).await {
105 | Ok(_) => return Ok(()),
106 | Err(error) if error.is_not_found() => (),
107 | Err(error) => return Err(error),
108 }
109
110 for pass in 1..=PASSES {
111 let residue = scan(services).await?;
112
113 // The last pass evaluates rather than heals, bounding a residue whose
114 // heals never settle.
115 if pass < PASSES && heal(services, &residue) {
116 continue;
117 }
118
119 match repair(services, &residue, chains_cleared_this_boot).await? {
120 | Verdict::Settled => global.insert(MARKER, []),
121 | Verdict::Declined(reason) => global.raw_put(MARKER, residue.decline_record(reason)),
122 }
123
124 break;
125 }
126
127 Ok(())
128}
129
130/// Discards auth chains cached before walk completeness was enforced.
131///
132/// A chain truncated at a missing ancestor is well-formed, so no scan
133/// separates it from a whole one and the population goes at once. The
134/// cache is derived and rebuilds on demand.
135#[tracing::instrument(level = "debug", skip_all)]
136async fn clear_chain_cache(services: &Services) -> Result {
137 let global = &services.db["global"];
138
139 warn!("Discarding cached auth chains; entries from earlier releases may be truncated.");
140
141 clear_chains(services).await?;
142 global.insert(CLEAR_MARKER, []);
143
144 Ok(())
145}
146
147/// Deletes every auth chain cache row under one cork.
148///
149/// The fallible scan must finish before its caller finalizes a marker. It is
150/// snapshot-based, so it holds only because migrations precede the workers
151/// that populate the cache.
152pub(super) async fn clear_chains(services: &Services) -> Result {
153 let _cork = services.db.cork_and_sync();
154 let progress = &services.server.progress;
155
156 progress.begin(CLEAR_MARKER);
157 services.db["authchainkey_authchain"]
158 .for_clear()
159 .ready_try_for_each(|_| {
160 progress.advance();
161
162 Ok(())
163 })
164 .await
165}
166
167/// Stamps both markers on a fresh database.
168///
169/// A fresh database never ran the unserialized allocator and holds no
170/// cached chains, so it has neither residue to scan for nor a cache to
171/// discard.
172pub(super) fn mark_clean(services: &Services) {
173 let global = &services.db["global"];
174
175 global.insert(MARKER, []);
176 global.insert(CLEAR_MARKER, []);
177}