Skip to main content

tuwunel_service/migrations/injectivity/
repair.rs

1use std::{collections::BTreeMap, sync::Arc};
2
3use tuwunel_core::{
4	Result, debug, info,
5	smallvec::SmallVec,
6	utils::{ReadyExt, hash::sha256::Digest, stream::TryIgnore},
7	warn,
8};
9use tuwunel_database::{Map, Txn};
10
11use super::{
12	clear_chains,
13	scan::{Family, Scan, short_of},
14};
15use crate::{
16	Services,
17	rooms::state_compressor::{
18		CompressedState, CompressedStateEvent, StateDiff, compress_state_event,
19		parse_compressed_state_event,
20	},
21};
22
23/// The digest rows naming each infected state, keyed by the state.
24///
25/// A digest key of any other width is unreachable by digest lookup and so
26/// cannot misdirect a dedup; only the 32-byte rows are collected.
27type Digests = BTreeMap<u64, SmallVec<[Digest; 1]>>;
28
29/// What one family's heal staged.
30///
31/// A reinstated row is a dangling winner regaining the reverse row its
32/// forward row already names; a promoted row is an unresolved loser
33/// regaining the forward row its identity lost.
34#[derive(Default)]
35struct Healed {
36	reinstated: usize,
37	promoted: usize,
38}
39
40/// Completes the torn writes the residue names on its own.
41///
42/// The pre-fix allocator put the forward and reverse rows separately, so a
43/// lost tail write leaves one half of a pair behind: a dangling winner has
44/// the forward row and wants its reverse row back, a promotable loser has
45/// the reverse row and wants the forward row its identity lost. Returns
46/// whether anything was written, which is the caller's signal to rescan
47/// before judging the counts a heal changes.
48///
49/// Never run this and [`repair`] in the same pass: a promoted row is also
50/// a loser, so `delete_losers` would remove the reverse row the promotion
51/// just completed.
52#[tracing::instrument(level = "debug", skip_all)]
53pub(super) fn heal(services: &Services, scan: &Scan) -> bool {
54	if scan.unverifiable || !scan.healable() {
55		return false;
56	}
57
58	let db = &services.db;
59	let mut txn = db.txn();
60
61	let event_reverse = &db["shorteventid_eventid"];
62	let event_forward = &db["eventid_shorteventid"];
63	let events = heal_family(&mut txn, event_reverse, event_forward, &scan.events);
64
65	let statekey_reverse = &db["shortstatekey_statekey"];
66	let statekey_forward = &db["statekey_shortstatekey"];
67	let statekeys = heal_family(&mut txn, statekey_reverse, statekey_forward, &scan.statekeys);
68
69	info!(
70		reinstated_events = events.reinstated,
71		promoted_events = events.promoted,
72		reinstated_statekeys = statekeys.reinstated,
73		promoted_statekeys = statekeys.promoted,
74		"Completing torn short id writes; rescanning to re-measure what they explain."
75	);
76
77	txn.execute();
78
79	true
80}
81
82/// Stages one family's reinstatements and promotions.
83///
84/// A family any anomaly impugns stages nothing, since the bitmaps naming
85/// its residue are the ones in doubt. Returns the counts staged.
86fn heal_family(txn: &mut Txn, reverse: &Map, forward: &Map, family: &Family) -> Healed {
87	if !family.healable() {
88		return Healed::default();
89	}
90
91	for (short, identity) in &family.dangling {
92		txn.insert_raw(reverse, short.to_be_bytes(), identity.as_slice());
93	}
94
95	for (short, identity) in &family.promotable {
96		txn.insert_raw(forward, identity.as_slice(), short.to_be_bytes());
97	}
98
99	Healed {
100		reinstated: family.dangling.len(),
101		promoted: family.promotable.len(),
102	}
103}
104
105/// Applies whatever repair the scan cleared, in hazard order.
106///
107/// The cache-clearing lane runs on any dirty chain and is unconditionally
108/// safe; the destructive lane runs only when no anomaly impugned the scan.
109/// Returns whether the residue settled: a refusal reports false so the
110/// caller leaves the marker unwritten and the next boot scans again, while
111/// an anomaly with nothing to repair settles with a warning instead.
112#[tracing::instrument(level = "debug", skip_all)]
113pub(super) async fn repair(services: &Services, scan: &Scan) -> Result<bool> {
114	if scan.unverifiable {
115		return Ok(false);
116	}
117
118	if scan.strays > 0 {
119		warn!(
120			stray_references = scan.strays,
121			"Short room id references without a forward row exist; nothing repairs them."
122		);
123	}
124
125	if scan.dirty > 0 {
126		warn!(
127			dirty_entries = scan.dirty,
128			total_entries = scan.entries,
129			"Cached auth chains contain malformed or stale short id data; clearing the auth \
130			 chain cache."
131		);
132
133		clear_chains(services).await;
134	}
135
136	// Refused rather than repaired, so the cache-clearing lane above still
137	// runs on a boot whose heals never settled. A promoted row is also a
138	// loser, so repairing an unhealed residue would delete the reverse row
139	// a promotion was about to complete.
140	if scan.healable() {
141		warn!(
142			dangling_events = scan.events.dangling.len(),
143			dangling_statekeys = scan.statekeys.dangling.len(),
144			promotable_events = scan.events.promotable.len(),
145			promotable_statekeys = scan.statekeys.promotable.len(),
146			"Short id heals did not settle; refusing the repair while residue stays healable."
147		);
148
149		return Ok(false);
150	}
151
152	if scan.events.losers.is_empty() && scan.statekeys.losers.is_empty() {
153		match scan.anomalous() {
154			| false => info!("Short id mappings verified injective."),
155			| true => warn!(
156				dangling_events = scan.events.dangling.len(),
157				dangling_statekeys = scan.statekeys.dangling.len(),
158				contended_events = scan.events.contended,
159				contended_statekeys = scan.statekeys.contended,
160				unresolved_events = scan.events.unresolved,
161				unresolved_statekeys = scan.statekeys.unresolved,
162				malformed_event_keys = scan.events.malformed,
163				malformed_statekey_keys = scan.statekeys.malformed,
164				"Short id anomalies exist with no stale mappings to repair; not scanning again."
165			),
166		}
167
168		return Ok(true);
169	}
170
171	if scan.anomalous() {
172		warn!(
173			dangling_events = scan.events.dangling.len(),
174			dangling_statekeys = scan.statekeys.dangling.len(),
175			promotable_events = scan.events.promotable.len(),
176			promotable_statekeys = scan.statekeys.promotable.len(),
177			contended_events = scan.events.contended,
178			contended_statekeys = scan.statekeys.contended,
179			unresolved_events = scan.events.unresolved,
180			unresolved_statekeys = scan.statekeys.unresolved,
181			malformed_event_keys = scan.events.malformed,
182			malformed_statekey_keys = scan.statekeys.malformed,
183			orphan_entries = scan.orphans,
184			missing_parents = scan.missing_parents,
185			infected_parents = scan.infected_parents,
186			malformed_diffs = scan.malformed_diffs,
187			"Refusing the destructive short id repair; the nonzero counts name shapes it does \
188			 not handle. Please report this line upstream, since the scan repeats each boot \
189			 until a release handles them."
190		);
191
192		return Ok(false);
193	}
194
195	patch_statediffs(services, scan).await?;
196	move_keys(services, scan).await?;
197	delete_losers(services, scan);
198
199	Ok(true)
200}
201
202/// Patches the ghost halves of infected statediff entries to their winners.
203///
204/// Re-emitting through the sorted serialize path drops any entry the patch
205/// makes a duplicate. The digest row naming each patched state rides the
206/// same transaction: deleted, never recomputed, since a recomputed digest
207/// could collide with an existing key and manufacture a duplicate state
208/// this family has no detector for.
209#[tracing::instrument(level = "debug", skip_all)]
210async fn patch_statediffs(services: &Services, scan: &Scan) -> Result {
211	if scan.infected.is_empty() {
212		return Ok(());
213	}
214
215	let digests: Digests = services.db["statehash_shortstatehash"]
216		.raw_stream()
217		.ignore_err()
218		.ready_fold(Digests::new(), |mut digests, (key, value)| {
219			let infected = short_of(value)
220				.filter(|state| scan.infected.contains(state))
221				.and_then(|state| key.try_into().ok().map(|digest| (state, digest)));
222
223			if let Some((state, digest)) = infected {
224				digests.entry(state).or_default().push(digest);
225			}
226
227			digests
228		})
229		.await;
230
231	// Serial: each state's patch and digest delete form one transaction,
232	// and the measured population is a handful of rows.
233	for &state in &scan.infected {
234		patch_state(services, scan, &digests, state).await?;
235	}
236
237	Ok(())
238}
239
240/// Patches one state's diff row, its digest row riding the transaction.
241///
242/// The pair lands together or not at all: a surviving digest row would
243/// misdirect a later state dedup toward bytes the state no longer has.
244#[tracing::instrument(
245	level = "debug",
246	skip_all,
247	fields(
248		%state,
249	),
250)]
251async fn patch_state(services: &Services, scan: &Scan, digests: &Digests, state: u64) -> Result {
252	let diff = services
253		.state_compressor
254		.get_statediff(state)
255		.await?;
256
257	let (added, added_changes) = patch(&diff.added, scan);
258	let (removed, removed_changes) = patch(&diff.removed, scan);
259
260	if removed_changes > 0 {
261		warn!(
262			%state,
263			entries = removed_changes,
264			"Patched ghost entries inside a removed run; the state resolves differently \
265			 now that the removal matches."
266		);
267	}
268
269	let shrunk = diff
270		.added
271		.len()
272		.saturating_add(diff.removed.len())
273		.saturating_sub(added.len())
274		.saturating_sub(removed.len());
275
276	if shrunk > 0 {
277		info!(
278			%state,
279			entries = shrunk,
280			"Patching converged duplicate entries; the state shrank."
281		);
282	}
283
284	let patched = StateDiff {
285		parent: diff.parent,
286		added: Arc::new(added),
287		removed: Arc::new(removed),
288	};
289
290	let statehashes = &services.db["statehash_shortstatehash"];
291	let mut txn = services.db.txn();
292
293	// stateinfo_cache is not invalidated: migrations precede the workers
294	// that populate it.
295	services
296		.state_compressor
297		.save_statediff(&mut txn, state, &patched);
298
299	digests
300		.get(&state)
301		.into_iter()
302		.flatten()
303		.for_each(|digest| txn.del_raw(statehashes, digest));
304
305	txn.execute();
306
307	info!(
308		%state,
309		entries = added_changes.saturating_add(removed_changes),
310		"Patched stale short ids out of a compressed state."
311	);
312
313	Ok(())
314}
315
316/// Maps both halves of each entry through the winner maps.
317///
318/// Returns the rebuilt set and the number of entries that changed; an
319/// entry with no stale half passes through unchanged.
320fn patch(entries: &CompressedState, scan: &Scan) -> (CompressedState, u64) {
321	entries
322		.iter()
323		.fold((CompressedState::new(), 0_u64), |(mut patched, changes), entry| {
324			let winner = winner_of(entry, scan);
325			let changes = changes.saturating_add(u64::from(winner.is_some()));
326
327			patched.insert(winner.unwrap_or(*entry));
328
329			(patched, changes)
330		})
331}
332
333/// Rebuilds one entry winner-ward, when either half is a loser.
334///
335/// An entry with no stale half yields nothing.
336fn winner_of(entry: &CompressedStateEvent, scan: &Scan) -> Option<CompressedStateEvent> {
337	let (statekey, event) = parse_compressed_state_event(*entry);
338	let winner_statekey = scan.statekeys.winners.get(&statekey).copied();
339	let winner_event = scan.events.winners.get(&event).copied();
340
341	(winner_statekey.is_some() || winner_event.is_some()).then(|| {
342		compress_state_event(winner_statekey.unwrap_or(statekey), winner_event.unwrap_or(event))
343	})
344}
345
346/// Moves loser-keyed state rows to their winner key and rewrites
347/// loser-valued relation rows.
348///
349/// A `relatesto_typed` value is no key and rewrites unconditionally; the
350/// key-position policy lives on [`move_state_row`].
351#[tracing::instrument(level = "debug", skip_all)]
352async fn move_keys(services: &Services, scan: &Scan) -> Result {
353	if scan.moves.is_empty() && scan.relations.is_empty() {
354		return Ok(());
355	}
356
357	// Serial: the loser decisions feed one shared transaction, and the
358	// measured population is zero to a handful of rows.
359	let states = &services.db["shorteventid_shortstatehash"];
360	let mut txn = services.db.txn();
361
362	for &loser in &scan.moves {
363		let Some(&winner) = scan.events.winners.get(&loser) else {
364			continue;
365		};
366
367		move_state_row(states, &mut txn, loser, winner).await?;
368	}
369
370	let relations = &services.db["relatesto_typed"];
371
372	for (key, loser) in &scan.relations {
373		let Some(&winner) = scan.events.winners.get(loser) else {
374			continue;
375		};
376
377		txn.insert_raw(relations, key, winner.to_be_bytes());
378	}
379
380	info!(
381		moves = scan.moves.len(),
382		relations = scan.relations.len(),
383		"Rewrote loser-keyed and loser-valued rows."
384	);
385
386	txn.execute();
387
388	Ok(())
389}
390
391/// Moves one loser-keyed state row toward its winner.
392///
393/// The value moves only onto an absent winner key; an occupied one was
394/// written at another moment and keeps its own row.
395async fn move_state_row(states: &Arc<Map>, txn: &mut Txn, loser: u64, winner: u64) -> Result {
396	match states.get(&winner.to_be_bytes()).await {
397		| Ok(_) =>
398			debug!(loser, winner, "Dropping a loser-keyed state row; the winner has its own."),
399		| Err(error) if error.is_not_found() => {
400			let value = states.get(&loser.to_be_bytes()).await?;
401
402			txn.insert_raw(states, winner.to_be_bytes(), &*value);
403		},
404		| Err(error) => return Err(error),
405	}
406
407	txn.del_raw(states, loser.to_be_bytes());
408
409	Ok(())
410}
411
412/// Deletes the loser reverse rows of both families under one cork.
413///
414/// Last on purpose: uncorked, each removal would flush the write-ahead log
415/// per key, and any earlier placement would destroy the resolver an
416/// interrupted repair needs to resume.
417fn delete_losers(services: &Services, scan: &Scan) {
418	info!(
419		stale_events = scan.events.losers.len(),
420		stale_statekeys = scan.statekeys.losers.len(),
421		"Deleting stale short id reverse rows."
422	);
423
424	let _cork = services.db.cork_and_sync();
425
426	let events = &services.db["shorteventid_eventid"];
427
428	for loser in &scan.events.losers {
429		events.remove(&loser.to_be_bytes());
430	}
431
432	let statekeys = &services.db["shortstatekey_statekey"];
433
434	for loser in &scan.statekeys.losers {
435		statekeys.remove(&loser.to_be_bytes());
436	}
437}