1use 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
37const WRITE_BATCH_ROWS: usize = 256;
39
40#[derive(Clone, Debug, Default, PartialEq, Eq)]
42pub struct ParticipantLogMigration {
43 pub conversations: Vec<ConversationLogMigration>,
45}
46
47#[derive(Clone, Debug, PartialEq, Eq)]
49pub struct ConversationLogMigration {
50 pub conversation_id: u64,
52 pub outcome: ConversationLogOutcome,
54}
55
56#[derive(Clone, Debug, PartialEq, Eq)]
58pub enum ConversationLogOutcome {
59 Empty,
62 Current {
64 generation: u64,
66 rows: u64,
68 },
69 Migrated {
71 from_generation: u64,
73 to_generation: u64,
75 rows: u64,
77 converted_rows: u64,
79 bytes: u64,
81 },
82}
83
84#[derive(Debug, thiserror::Error)]
87pub enum ParticipantLogMigrationError {
88 #[error("participant outbox limit configuration failed: {0}")]
90 Configuration(String),
91 #[error("the participant conversation registry could not be read: {0}")]
93 Registry(String),
94 #[error(
96 "participant conversation {conversation_id} did not replay and was not migrated: {reason}"
97 )]
98 Replay {
99 conversation_id: u64,
101 reason: String,
103 },
104 #[error(
106 "participant conversation {conversation_id}: generation {generation} could not be written: {reason}"
107 )]
108 Write {
109 conversation_id: u64,
111 generation: u64,
113 reason: String,
115 },
116 #[error(
120 "participant conversation {conversation_id}: generation {generation} already holds a different row at sequence {sequence}"
121 )]
122 GenerationDrift {
123 conversation_id: u64,
125 generation: u64,
127 sequence: u64,
129 },
130 #[error(
133 "participant conversation {conversation_id}: generation {generation} replays to a different state than generation {source_generation}; not switched"
134 )]
135 StateDrift {
136 conversation_id: u64,
138 source_generation: u64,
140 generation: u64,
142 },
143 #[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 conversation_id: u64,
151 expected: u64,
153 to: u64,
155 reason: String,
157 },
158 #[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 conversation_id: u64,
169 expected: u64,
171 to: u64,
173 reason: String,
175 },
176}
177
178pub 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(ConversationAuthority::replay_rewriting(
330 conversation_id,
331 log,
332 &outbox_log,
333 config,
334 limits,
335 rewrite,
336 ))
337 .map_err(|error| refused(error.to_string()))?
338 .map_err(|error| {
339 refused(match error {
340 RestoreError::Extension(error) => error.to_string(),
341 RestoreError::Semantic(error) => error.to_string(),
342 })
343 })
344}
345
346fn write_generation(
350 target: &OperationLog,
351 rows: &[Vec<u8>],
352) -> Result<u64, ParticipantLogMigrationError> {
353 let conversation_id = target.conversation_id();
354 let generation = block_on(target.generation())
355 .map_err(|error| error.to_string())
356 .and_then(|read| read.map_err(|error| error.to_string()))
357 .map_err(|reason| ParticipantLogMigrationError::Write {
358 conversation_id,
359 generation: 0,
360 reason,
361 })?;
362 let write_error = |reason: String| ParticipantLogMigrationError::Write {
363 conversation_id,
364 generation,
365 reason,
366 };
367 let mut present = 0_u64;
368 let mut expected = rows.iter();
369 loop {
370 let page = block_on(target.read_raw_page(present))
371 .map_err(|error| write_error(error.to_string()))?
372 .map_err(|error| write_error(error.to_string()))?;
373 let short = page.len() < super::log::READ_BATCH_SIZE;
374 for entry in page {
375 let matches = expected.next().is_some_and(|row| *row == entry.payload);
376 if entry.sequence != present || !matches {
377 return Err(ParticipantLogMigrationError::GenerationDrift {
378 conversation_id,
379 generation,
380 sequence: entry.sequence,
381 });
382 }
383 present = present
384 .checked_add(1)
385 .ok_or_else(|| write_error("row count overflow".to_owned()))?;
386 }
387 if short {
388 break;
389 }
390 }
391 if present > 0 {
392 tracing::warn!(
393 conversation_id,
394 generation,
395 present,
396 "participant log migration: completing a generation an interrupted run left part-written"
397 );
398 }
399 let remaining: Vec<Vec<u8>> = expected.cloned().collect();
400 let mut next = present;
401 for chunk in remaining.chunks(WRITE_BATCH_ROWS) {
402 next = block_on(target.append_encoded(chunk.to_vec(), next))
403 .map_err(|error| write_error(error.to_string()))?
404 .map_err(|error| write_error(error.to_string()))?;
405 }
406 rows.iter().try_fold(0_u64, |total, row| {
407 u64::try_from(row.len())
408 .ok()
409 .and_then(|len| total.checked_add(len))
410 .ok_or_else(|| write_error("generation size exceeds u64".to_owned()))
411 })
412}