Skip to main content

liminal_server/server/participant/production/
log_migration.rs

1//! One-time offline rewrite of every participant conversation log into the
2//! current row grammar.
3//!
4//! Rows written before schema v4 carry the complete retained-charge list, so a
5//! conversation's log grows with the square of its length and its replay
6//! decodes all of it. The rewrite replays each conversation once, re-encodes
7//! every row in log order with its retained-charge audit recorded as the change
8//! the row made, and writes the result as the conversation's next log
9//! generation. Nothing is authoritative until the new generation replays to
10//! exactly the state the old one does; only then does one compare-and-swap of
11//! the conversation's generation key make it the log every reader uses.
12//!
13//! Per conversation the rewrite is atomic (the switch is one compare-and-swap),
14//! idempotent (a conversation already on current rows is reported and left
15//! alone; a new generation left part-written by an interrupted run is checked
16//! row for row and completed), and it never skips a row: a row that does not
17//! replay, re-encode, or match what an earlier run wrote refuses the migration
18//! by name with the conversation's generation unchanged.
19//!
20//! It runs offline, over a store no server has open.
21
22use std::sync::Arc;
23
24use liminal::durability::DurableStore;
25use liminal::durability::bridge::block_on;
26
27use crate::config::types::ParticipantConfig;
28
29use super::log::{GenerationSwitchFailure, OperationLog};
30use super::ops_session_replay::LogRewrite;
31use super::outbox::ConversationOutboxLimits;
32use super::outbox_log::OutboxLog;
33use super::outbox_replay::RestoreError;
34use super::registry::ConversationRegistry;
35use super::state::ConversationAuthority;
36
37/// Rows appended to a new generation per store write.
38const WRITE_BATCH_ROWS: usize = 256;
39
40/// What the migration did to every registered conversation, in registry order.
41#[derive(Clone, Debug, Default, PartialEq, Eq)]
42pub struct ParticipantLogMigration {
43    /// One entry per registered conversation.
44    pub conversations: Vec<ConversationLogMigration>,
45}
46
47/// What the migration did to one conversation.
48#[derive(Clone, Debug, PartialEq, Eq)]
49pub struct ConversationLogMigration {
50    /// The conversation.
51    pub conversation_id: u64,
52    /// The result for it.
53    pub outcome: ConversationLogOutcome,
54}
55
56/// The result of migrating one conversation.
57#[derive(Clone, Debug, PartialEq, Eq)]
58pub enum ConversationLogOutcome {
59    /// The conversation has no log rows: its registry row was written and its
60    /// genesis never was.
61    Empty,
62    /// The authoritative log already holds only current rows.
63    Current {
64        /// The authoritative generation, unchanged.
65        generation: u64,
66        /// Rows in it.
67        rows: u64,
68    },
69    /// The log was rewritten into a new generation that is now authoritative.
70    Migrated {
71        /// The generation that was authoritative before.
72        from_generation: u64,
73        /// The generation that is authoritative now.
74        to_generation: u64,
75        /// Rows rewritten, every row of the log.
76        rows: u64,
77        /// Rows whose complete retained-charge list became a change.
78        converted_rows: u64,
79        /// Encoded bytes of the new generation.
80        bytes: u64,
81    },
82}
83
84/// Why the migration stopped. Every refusal names the conversation, and no
85/// conversation's authoritative generation is changed by a refused run.
86#[derive(Debug, thiserror::Error)]
87pub enum ParticipantLogMigrationError {
88    /// The participant configuration's outbox limits are invalid.
89    #[error("participant outbox limit configuration failed: {0}")]
90    Configuration(String),
91    /// The conversation registry could not be read.
92    #[error("the participant conversation registry could not be read: {0}")]
93    Registry(String),
94    /// A conversation's log did not replay, so its rows cannot be rewritten.
95    #[error(
96        "participant conversation {conversation_id} did not replay and was not migrated: {reason}"
97    )]
98    Replay {
99        /// The conversation.
100        conversation_id: u64,
101        /// The replay's refusal.
102        reason: String,
103    },
104    /// A conversation's new generation could not be read or written.
105    #[error(
106        "participant conversation {conversation_id}: generation {generation} could not be written: {reason}"
107    )]
108    Write {
109        /// The conversation.
110        conversation_id: u64,
111        /// The generation being written.
112        generation: u64,
113        /// The store's refusal.
114        reason: String,
115    },
116    /// A new generation left by an earlier run holds a row that differs from
117    /// the row this run derives at the same sequence, or more rows than the
118    /// log has.
119    #[error(
120        "participant conversation {conversation_id}: generation {generation} already holds a different row at sequence {sequence}"
121    )]
122    GenerationDrift {
123        /// The conversation.
124        conversation_id: u64,
125        /// The generation holding the stray row.
126        generation: u64,
127        /// The first sequence that differs.
128        sequence: u64,
129    },
130    /// The rewritten generation replays to a different state than the log it
131    /// was rewritten from.
132    #[error(
133        "participant conversation {conversation_id}: generation {generation} replays to a different state than generation {source_generation}; not switched"
134    )]
135    StateDrift {
136        /// The conversation.
137        conversation_id: u64,
138        /// The generation it was rewritten from.
139        source_generation: u64,
140        /// The rewritten generation.
141        generation: u64,
142    },
143    /// The generation key moved while the migration ran, so another writer is
144    /// using the store.
145    #[error(
146        "participant conversation {conversation_id}: the switch of the authoritative generation from {expected} to {to} was refused, so generation {expected} is unchanged by this run: {reason}"
147    )]
148    Switch {
149        /// The conversation.
150        conversation_id: u64,
151        /// The generation the migration read.
152        expected: u64,
153        /// The generation the migration wrote.
154        to: u64,
155        /// The store's refusal.
156        reason: String,
157    },
158    /// The authoritative generation was swapped but the flush that makes the
159    /// swap durable failed, so which generation a restart finds is not known.
160    /// Both generations replay to the same state; running the migration again
161    /// reads the authoritative generation and reports or completes the
162    /// conversation.
163    #[error(
164        "participant conversation {conversation_id}: the authoritative generation was swapped from {expected} to {to} but the flush failed, so it is not known which generation a restart finds; run the migration again, which reads the authoritative generation and completes or reports the conversation: {reason}"
165    )]
166    SwitchUnconfirmed {
167        /// The conversation.
168        conversation_id: u64,
169        /// The generation the migration read.
170        expected: u64,
171        /// The generation the migration wrote.
172        to: u64,
173        /// The store's refusal.
174        reason: String,
175    },
176}
177
178/// Rewrites every registered conversation's log into the current row grammar.
179///
180/// # Errors
181///
182/// Returns [`ParticipantLogMigrationError`] at the first conversation that
183/// cannot be migrated; conversations migrated before it stay migrated, and
184/// every later one keeps its authoritative generation. The refused one keeps
185/// its generation too, except after [`ParticipantLogMigrationError::SwitchUnconfirmed`],
186/// where either generation may be authoritative (both replay to the same
187/// state). Running again resumes where the refused run stopped.
188///
189/// The migration is forward-only: a binary older than the change-per-row
190/// grammar cannot read a migrated conversation, so a migrated store is never
191/// run under an older binary.
192pub fn migrate_participant_logs(
193    store: &Arc<dyn DurableStore>,
194    config: &ParticipantConfig,
195) -> Result<ParticipantLogMigration, ParticipantLogMigrationError> {
196    let limits =
197        ConversationOutboxLimits::try_new(config.max_retained_record_rows, config.identity_slots)
198            .map_err(|error| ParticipantLogMigrationError::Configuration(error.to_string()))?;
199    let conversation_ids = ConversationRegistry::new(Arc::clone(store))
200        .restore()
201        .map_err(|error| ParticipantLogMigrationError::Registry(error.to_string()))?;
202    let mut migration = ParticipantLogMigration::default();
203    for conversation_id in conversation_ids {
204        let outcome = migrate_conversation(store, config, limits, conversation_id)?;
205        match &outcome {
206            ConversationLogOutcome::Empty => tracing::info!(
207                conversation_id,
208                "participant log migration: conversation has no rows; nothing to rewrite"
209            ),
210            ConversationLogOutcome::Current { generation, rows } => tracing::info!(
211                conversation_id,
212                generation,
213                rows,
214                "participant log migration: conversation already holds only current rows"
215            ),
216            ConversationLogOutcome::Migrated {
217                from_generation,
218                to_generation,
219                rows,
220                converted_rows,
221                bytes,
222            } => tracing::info!(
223                conversation_id,
224                from_generation,
225                to_generation,
226                rows,
227                converted_rows,
228                bytes,
229                "participant log migration: conversation rewritten and switched to its new generation"
230            ),
231        }
232        migration.conversations.push(ConversationLogMigration {
233            conversation_id,
234            outcome,
235        });
236    }
237    Ok(migration)
238}
239
240fn migrate_conversation(
241    store: &Arc<dyn DurableStore>,
242    config: &ParticipantConfig,
243    limits: ConversationOutboxLimits,
244    conversation_id: u64,
245) -> Result<ConversationLogOutcome, ParticipantLogMigrationError> {
246    let write_error = |generation: u64, reason: String| ParticipantLogMigrationError::Write {
247        conversation_id,
248        generation,
249        reason,
250    };
251    let source = OperationLog::new(Arc::clone(store), conversation_id);
252    let source_generation = block_on(source.generation())
253        .map_err(|error| write_error(0, error.to_string()))?
254        .map_err(|error| write_error(0, error.to_string()))?;
255    let source = OperationLog::at_generation(Arc::clone(store), conversation_id, source_generation);
256    let mut rewrite = LogRewrite::default();
257    let source_state = replay(store, config, limits, &source, Some(&mut rewrite))?;
258    let rows = u64::try_from(rewrite.rows.len())
259        .map_err(|_| write_error(source_generation, "row count exceeds u64".to_owned()))?;
260    if rows == 0 {
261        return Ok(ConversationLogOutcome::Empty);
262    }
263    if rewrite.legacy_rows == 0 {
264        return Ok(ConversationLogOutcome::Current {
265            generation: source_generation,
266            rows,
267        });
268    }
269    let generation = source_generation
270        .checked_add(1)
271        .ok_or_else(|| write_error(source_generation, "generation overflow".to_owned()))?;
272    let target = OperationLog::at_generation(Arc::clone(store), conversation_id, generation);
273    let bytes = write_generation(&target, &rewrite.rows)?;
274    let target_state = replay(store, config, limits, &target, None)?;
275    if format!("{source_state:?}") != format!("{target_state:?}") {
276        return Err(ParticipantLogMigrationError::StateDrift {
277            conversation_id,
278            source_generation,
279            generation,
280        });
281    }
282    let switched =
283        block_on(source.switch_generation(source_generation, generation)).map_err(|error| {
284            ParticipantLogMigrationError::SwitchUnconfirmed {
285                conversation_id,
286                expected: source_generation,
287                to: generation,
288                reason: error.to_string(),
289            }
290        })?;
291    switched.map_err(|failure| match failure {
292        GenerationSwitchFailure::NotSwapped(error) => ParticipantLogMigrationError::Switch {
293            conversation_id,
294            expected: source_generation,
295            to: generation,
296            reason: error.to_string(),
297        },
298        GenerationSwitchFailure::SwappedUnconfirmed(error) => {
299            ParticipantLogMigrationError::SwitchUnconfirmed {
300                conversation_id,
301                expected: source_generation,
302                to: generation,
303                reason: error.to_string(),
304            }
305        }
306    })?;
307    Ok(ConversationLogOutcome::Migrated {
308        from_generation: source_generation,
309        to_generation: generation,
310        rows,
311        converted_rows: rewrite.converted_rows,
312        bytes,
313    })
314}
315
316fn replay(
317    store: &Arc<dyn DurableStore>,
318    config: &ParticipantConfig,
319    limits: ConversationOutboxLimits,
320    log: &OperationLog,
321    rewrite: Option<&mut LogRewrite>,
322) -> Result<ConversationAuthority, ParticipantLogMigrationError> {
323    let conversation_id = log.conversation_id();
324    let refused = |reason: String| ParticipantLogMigrationError::Replay {
325        conversation_id,
326        reason,
327    };
328    let outbox_log = OutboxLog::new(Arc::clone(store), conversation_id);
329    block_on(outbox_log.restore_cursor().validate_all())
330        .map_err(|error| refused(error.to_string()))?
331        .map_err(|error| refused(error.to_string()))?;
332    block_on(ConversationAuthority::replay_rewriting(
333        conversation_id,
334        log,
335        &outbox_log,
336        config,
337        limits,
338        rewrite,
339    ))
340    .map_err(|error| refused(error.to_string()))?
341    .map_err(|error| {
342        refused(match error {
343            RestoreError::Extension(error) => error.to_string(),
344            RestoreError::Semantic(error) => error.to_string(),
345        })
346    })
347}
348
349/// Writes `rows` as the whole of `target`'s generation, completing a
350/// generation an interrupted run left part-written after checking every row it
351/// already holds. Returns the generation's encoded size.
352fn write_generation(
353    target: &OperationLog,
354    rows: &[Vec<u8>],
355) -> Result<u64, ParticipantLogMigrationError> {
356    let conversation_id = target.conversation_id();
357    let generation = block_on(target.generation())
358        .map_err(|error| error.to_string())
359        .and_then(|read| read.map_err(|error| error.to_string()))
360        .map_err(|reason| ParticipantLogMigrationError::Write {
361            conversation_id,
362            generation: 0,
363            reason,
364        })?;
365    let write_error = |reason: String| ParticipantLogMigrationError::Write {
366        conversation_id,
367        generation,
368        reason,
369    };
370    let mut present = 0_u64;
371    let mut expected = rows.iter();
372    loop {
373        let page = block_on(target.read_raw_page(present))
374            .map_err(|error| write_error(error.to_string()))?
375            .map_err(|error| write_error(error.to_string()))?;
376        let short = page.len() < super::log::READ_BATCH_SIZE;
377        for entry in page {
378            let matches = expected.next().is_some_and(|row| *row == entry.payload);
379            if entry.sequence != present || !matches {
380                return Err(ParticipantLogMigrationError::GenerationDrift {
381                    conversation_id,
382                    generation,
383                    sequence: entry.sequence,
384                });
385            }
386            present = present
387                .checked_add(1)
388                .ok_or_else(|| write_error("row count overflow".to_owned()))?;
389        }
390        if short {
391            break;
392        }
393    }
394    if present > 0 {
395        tracing::warn!(
396            conversation_id,
397            generation,
398            present,
399            "participant log migration: completing a generation an interrupted run left part-written"
400        );
401    }
402    let remaining: Vec<Vec<u8>> = expected.cloned().collect();
403    let mut next = present;
404    for chunk in remaining.chunks(WRITE_BATCH_ROWS) {
405        next = block_on(target.append_encoded(chunk.to_vec(), next))
406            .map_err(|error| write_error(error.to_string()))?
407            .map_err(|error| write_error(error.to_string()))?;
408    }
409    rows.iter().try_fold(0_u64, |total, row| {
410        u64::try_from(row.len())
411            .ok()
412            .and_then(|len| total.checked_add(len))
413            .ok_or_else(|| write_error("generation size exceeds u64".to_owned()))
414    })
415}