Skip to main content

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}