1use sha2::{Digest, Sha256};
30use sqlx::PgPool;
31use uuid::Uuid;
32
33use crate::{
34 error::{BusError, BusResult},
35 store::{
36 backend::{Envelope, Locator, MessagingBackend, PostgresBackend, Published},
37 jetstream::JetStreamBackend,
38 },
39};
40
41#[derive(Clone, Copy, Debug, PartialEq, Eq)]
43pub enum Direction {
44 ToJetStream,
45 ToPostgres,
46}
47
48impl Direction {
49 pub fn parse(raw: &str) -> BusResult<Self> {
50 match raw {
51 "jetstream" => Ok(Self::ToJetStream),
52 "postgres" => Ok(Self::ToPostgres),
53 other => Err(BusError::invalid(format!(
54 "unknown backend '{other}'; expected 'jetstream' or 'postgres'"
55 ))),
56 }
57 }
58 pub fn target(self) -> &'static str {
59 match self {
60 Self::ToJetStream => JetStreamBackend::NAME,
61 Self::ToPostgres => PostgresBackend::NAME,
62 }
63 }
64 pub fn source(self) -> &'static str {
65 match self {
66 Self::ToJetStream => PostgresBackend::NAME,
67 Self::ToPostgres => JetStreamBackend::NAME,
68 }
69 }
70 fn column(self) -> &'static str {
71 match self {
72 Self::ToJetStream => "to_jetstream",
73 Self::ToPostgres => "to_postgres",
74 }
75 }
76}
77
78#[derive(Clone, Debug, serde::Serialize)]
80pub struct Plan {
81 pub conversation_id: Uuid,
82 pub title: String,
83 pub current_backend: String,
84 pub messages: i64,
85 pub bytes: i64,
86 pub already_there: i64,
88 pub blocked: Option<String>,
90 pub resuming: bool,
93}
94
95pub async fn plan(
97 pool: &PgPool,
98 team_id: Uuid,
99 direction: Direction,
100 only: &[Uuid],
101) -> BusResult<Vec<Plan>> {
102 let rows: Vec<(Uuid, String, String, Option<chrono::DateTime<chrono::Utc>>)> = sqlx::query_as(
103 "SELECT id, title, backend, write_paused_at FROM conversations
104 WHERE team_id = $1 AND ($2::uuid[] = '{}' OR id = ANY($2))
105 ORDER BY created_at",
106 )
107 .bind(team_id)
108 .bind(only)
109 .fetch_all(pool)
110 .await?;
111
112 let mut plans = Vec::with_capacity(rows.len());
113 for (id, title, backend, paused) in rows {
114 let (messages, bytes, already): (i64, i64, i64) = sqlx::query_as(
115 "SELECT count(*),
119 COALESCE(sum(COALESCE(NULLIF(length(m.body), 0),
120 (SELECT i.bytes FROM conversation_migration_items i
121 WHERE i.message_id = m.id
122 ORDER BY i.bytes DESC LIMIT 1), 0)), 0)::bigint,
123 count(*) FILTER (WHERE m.backend = $2)
124 FROM conversation_messages m
125 WHERE m.conversation_id = $1 AND m.deleted_at IS NULL",
126 )
127 .bind(id)
128 .bind(direction.target())
129 .fetch_one(pool)
130 .await?;
131 let (open_runs,): (i64,) = sqlx::query_as(
136 "SELECT count(*) FROM conversation_migrations
137 WHERE conversation_id = $1 AND direction = $2
138 AND state IN ('planned', 'copying', 'verified')",
139 )
140 .bind(id)
141 .bind(direction.column())
142 .fetch_one(pool)
143 .await?;
144 let blocked = if backend == direction.target() && already == messages && open_runs == 0 {
145 Some(format!("already on {}", direction.target()))
146 } else if paused.is_some() && open_runs == 0 {
147 Some(
150 "this thread is paused and no move owns the pause; clear write_paused_at \
151 before trying again"
152 .to_owned(),
153 )
154 } else {
155 None
156 };
157 let resuming = open_runs > 0;
158 plans.push(Plan {
159 conversation_id: id,
160 title,
161 current_backend: backend,
162 messages,
163 bytes,
164 already_there: already,
165 blocked,
166 resuming,
167 });
168 }
169 Ok(plans)
170}
171
172#[derive(Clone, Debug, serde::Serialize)]
174pub struct Outcome {
175 pub migration_id: Uuid,
176 pub conversation_id: Uuid,
177 pub copied: i64,
178 pub verified: i64,
179 pub skipped: i64,
180 pub bytes: i64,
181 pub state: String,
182}
183
184fn checksum(body: &str) -> String {
185 hex::encode(Sha256::digest(body.as_bytes()))
186}
187
188async fn open_run(
192 pool: &PgPool,
193 team_id: Uuid,
194 conversation_id: Uuid,
195 direction: Direction,
196) -> BusResult<Uuid> {
197 let mut tx = pool.begin().await?;
202 sqlx::query("SELECT id FROM conversations WHERE id = $1 FOR UPDATE")
203 .bind(conversation_id)
204 .fetch_one(&mut *tx)
205 .await?;
206 let existing: Option<(Uuid, String)> = sqlx::query_as(
207 "SELECT id, direction FROM conversation_migrations
208 WHERE conversation_id = $1
209 AND state IN ('planned', 'copying', 'verified')
210 ORDER BY started_at DESC LIMIT 1",
211 )
212 .bind(conversation_id)
213 .fetch_optional(&mut *tx)
214 .await?;
215 if let Some((id, open_direction)) = existing {
216 if open_direction != direction.column() {
217 return Err(BusError::conflict(format!(
218 "a move of this conversation to {} is already open. Let it finish or fail \
219 before moving it the other way.",
220 open_direction.trim_start_matches("to_")
221 )));
222 }
223 tx.commit().await?;
224 return Ok(id);
225 }
226 let (id,): (Uuid,) = sqlx::query_as(
227 "INSERT INTO conversation_migrations (team_id, conversation_id, direction, state)
228 VALUES ($1, $2, $3, 'copying') RETURNING id",
229 )
230 .bind(team_id)
231 .bind(conversation_id)
232 .bind(direction.column())
233 .fetch_one(&mut *tx)
234 .await?;
235 tx.commit().await?;
236 Ok(id)
237}
238
239pub async fn run(
245 pool: &PgPool,
246 jetstream: &JetStreamBackend,
247 team_id: Uuid,
248 conversation_id: Uuid,
249 direction: Direction,
250) -> BusResult<Outcome> {
251 let postgres = PostgresBackend::new(pool.clone());
252 let migration_id = open_run(pool, team_id, conversation_id, direction).await?;
253
254 let mut tx = pool.begin().await?;
260 sqlx::query("SELECT id FROM conversations WHERE id = $1 FOR UPDATE")
261 .bind(conversation_id)
262 .fetch_one(&mut *tx)
263 .await?;
264 sqlx::query("UPDATE conversations SET write_paused_at = now() WHERE id = $1")
265 .bind(conversation_id)
266 .execute(&mut *tx)
267 .await?;
268 tx.commit().await?;
269
270 let outcome = copy_and_verify(
271 pool,
272 jetstream,
273 &postgres,
274 team_id,
275 conversation_id,
276 direction,
277 migration_id,
278 )
279 .await;
280
281 match outcome {
282 Ok(outcome) => Ok(outcome),
283 Err(e) => {
284 if let Err(abort_error) = abort(pool, migration_id, &e.to_string()).await {
290 tracing::error!(
291 error = %abort_error,
292 migration = %migration_id,
293 "could not abort a failed move; it stays open and paused until resumed"
294 );
295 }
296 Err(e)
297 }
298 }
299}
300
301#[allow(clippy::too_many_arguments)]
302async fn copy_and_verify(
303 pool: &PgPool,
304 jetstream: &JetStreamBackend,
305 postgres: &PostgresBackend,
306 team_id: Uuid,
307 conversation_id: Uuid,
308 direction: Direction,
309 migration_id: Uuid,
310) -> BusResult<Outcome> {
311 let rows: Vec<(Uuid, String, Option<String>, String)> = sqlx::query_as(
312 "SELECT id, body, canonical_locator, backend FROM conversation_messages
313 WHERE conversation_id = $1 AND deleted_at IS NULL
314 AND tombstoned_at IS NULL
315 ORDER BY seq",
316 )
317 .bind(conversation_id)
318 .fetch_all(pool)
319 .await?;
320
321 let mut copied = 0;
322 let mut skipped = 0;
323 let mut bytes = 0i64;
324 for (message_id, local_body, locator, backend) in &rows {
325 if backend == direction.target() {
326 skipped += 1;
327 continue;
328 }
329 let done: Option<(String,)> = sqlx::query_as(
330 "SELECT state FROM conversation_migration_items
331 WHERE migration_id = $1 AND message_id = $2",
332 )
333 .bind(migration_id)
334 .bind(message_id)
335 .fetch_optional(pool)
336 .await?;
337 if done.as_ref().map(|s| s.0.as_str()) == Some("verified") {
338 skipped += 1;
339 continue;
340 }
341
342 let body = match direction {
345 Direction::ToJetStream => local_body.clone(),
346 Direction::ToPostgres => {
347 let Some(locator) = locator.clone() else {
348 return Err(BusError::invalid(format!(
349 "message {message_id} has no locator on {}; it cannot be copied back",
350 direction.source()
351 )));
352 };
353 jetstream
354 .fetch(&Locator(locator), *message_id)
355 .await?
356 .ok_or_else(|| {
357 BusError::not_found(format!(
358 "the broker no longer holds the body of {message_id}. A body that \
359 is gone cannot be copied back; tombstone it deliberately or \
360 restore the stream first."
361 ))
362 })?
363 }
364 };
365 let sum = checksum(&body);
366 bytes += body.len() as i64;
367
368 sqlx::query(
369 "INSERT INTO conversation_migration_items
370 (migration_id, message_id, checksum, bytes, state)
371 VALUES ($1, $2, $3, $4, 'planned')
372 ON CONFLICT (migration_id, message_id)
373 DO UPDATE SET checksum = EXCLUDED.checksum, bytes = EXCLUDED.bytes",
374 )
375 .bind(migration_id)
376 .bind(message_id)
377 .bind(&sum)
378 .bind(body.len() as i64)
379 .execute(pool)
380 .await?;
381
382 let recorded: Option<(Option<String>, String)> = sqlx::query_as(
387 "SELECT target_locator, state FROM conversation_migration_items
388 WHERE migration_id = $1 AND message_id = $2",
389 )
390 .bind(migration_id)
391 .bind(message_id)
392 .fetch_optional(pool)
393 .await?;
394 if let Some((Some(recorded_locator), _)) = recorded
395 && direction == Direction::ToJetStream
396 && let Ok(Some(there)) = jetstream
397 .fetch(&Locator(recorded_locator.clone()), *message_id)
398 .await
399 && checksum(&there) == sum
400 {
401 sqlx::query(
402 "UPDATE conversation_migration_items SET state = 'verified'
403 WHERE migration_id = $1 AND message_id = $2",
404 )
405 .bind(migration_id)
406 .bind(message_id)
407 .execute(pool)
408 .await?;
409 copied += 1;
410 continue;
411 }
412
413 let target_locator = match direction {
417 Direction::ToJetStream => {
418 let envelope = Envelope {
419 message_id: *message_id,
420 conversation_id,
421 team_id,
422 body: body.clone(),
423 publish_key: *message_id,
424 };
425 match jetstream.publish(envelope).await {
426 Published::Confirmed(Locator(l)) => l,
427 Published::Retryable(why) | Published::Fatal(why) => {
428 return Err(BusError::conflict(format!(
429 "copying {message_id} failed: {why}"
430 )));
431 }
432 }
433 }
434 Direction::ToPostgres => {
435 sqlx::query("UPDATE conversation_messages SET body = $2 WHERE id = $1")
436 .bind(message_id)
437 .bind(&body)
438 .execute(pool)
439 .await?;
440 message_id.to_string()
441 }
442 };
443 sqlx::query(
444 "UPDATE conversation_migration_items
445 SET state = 'copied', target_locator = $3
446 WHERE migration_id = $1 AND message_id = $2",
447 )
448 .bind(migration_id)
449 .bind(message_id)
450 .bind(&target_locator)
451 .execute(pool)
452 .await?;
453
454 let back = match direction {
457 Direction::ToJetStream => {
458 jetstream
459 .fetch(&Locator(target_locator.clone()), *message_id)
460 .await?
461 }
462 Direction::ToPostgres => {
463 postgres
464 .fetch(&Locator(target_locator.clone()), *message_id)
465 .await?
466 }
467 };
468 let back = back.ok_or_else(|| {
469 BusError::conflict(format!(
470 "{message_id} was written to {} and could not be read back",
471 direction.target()
472 ))
473 })?;
474 if checksum(&back) != sum {
475 sqlx::query(
476 "UPDATE conversation_migration_items SET state = 'failed', last_error = $3
477 WHERE migration_id = $1 AND message_id = $2",
478 )
479 .bind(migration_id)
480 .bind(message_id)
481 .bind("checksum mismatch")
482 .execute(pool)
483 .await?;
484 return Err(BusError::conflict(format!(
485 "the body of {message_id} came back different from {}. Nothing was cut over.",
486 direction.target()
487 )));
488 }
489 sqlx::query(
490 "UPDATE conversation_migration_items SET state = 'verified'
491 WHERE migration_id = $1 AND message_id = $2",
492 )
493 .bind(migration_id)
494 .bind(message_id)
495 .execute(pool)
496 .await?;
497 copied += 1;
498 }
499
500 let mut tx = pool.begin().await?;
505 sqlx::query("SELECT id FROM conversations WHERE id = $1 FOR UPDATE")
506 .bind(conversation_id)
507 .fetch_one(&mut *tx)
508 .await?;
509 sqlx::query(
510 "UPDATE conversation_messages m
511 SET backend = $3,
512 canonical_locator = i.target_locator
513 FROM conversation_migration_items i
514 WHERE i.migration_id = $1 AND i.state = 'verified' AND m.id = i.message_id
515 AND m.conversation_id = $2",
516 )
517 .bind(migration_id)
518 .bind(conversation_id)
519 .bind(direction.target())
520 .execute(&mut *tx)
521 .await?;
522 if direction == Direction::ToPostgres {
526 sqlx::query(
527 "UPDATE conversation_messages SET canonical_locator = NULL, publication_state = 'stored'
528 WHERE conversation_id = $1 AND backend = 'postgres'",
529 )
530 .bind(conversation_id)
531 .execute(&mut *tx)
532 .await?;
533 }
534 sqlx::query(
535 "UPDATE conversations
536 SET backend = $2,
537 publication = CASE WHEN $2 = 'postgres' THEN 'sync' ELSE 'outbox' END,
538 write_paused_at = NULL
539 WHERE id = $1",
540 )
541 .bind(conversation_id)
542 .bind(direction.target())
543 .execute(&mut *tx)
544 .await?;
545 sqlx::query(
546 "UPDATE conversation_migrations
547 SET state = 'cut_over', finished_at = now(), messages = $2, bytes = $3
548 WHERE id = $1",
549 )
550 .bind(migration_id)
551 .bind(copied)
552 .bind(bytes)
553 .execute(&mut *tx)
554 .await?;
555 tx.commit().await?;
556
557 Ok(Outcome {
558 migration_id,
559 conversation_id,
560 copied,
561 verified: copied,
562 skipped,
563 bytes,
564 state: "cut_over".to_owned(),
565 })
566}
567
568pub async fn abort(pool: &PgPool, migration_id: Uuid, why: &str) -> BusResult<()> {
577 let mut tx = pool.begin().await?;
578 let run: Option<(Uuid, String)> = sqlx::query_as(
579 "SELECT conversation_id, direction FROM conversation_migrations WHERE id = $1",
580 )
581 .bind(migration_id)
582 .fetch_optional(&mut *tx)
583 .await?;
584 let Some((conversation_id, direction)) = run else {
585 return Ok(());
586 };
587 sqlx::query("SELECT id FROM conversations WHERE id = $1 FOR UPDATE")
592 .bind(conversation_id)
593 .fetch_one(&mut *tx)
594 .await?;
595 let failed: Option<(Uuid,)> = sqlx::query_as(
598 "UPDATE conversation_migrations SET state = 'failed', finished_at = now(),
599 last_error = $2
600 WHERE id = $1 AND state IN ('planned', 'copying', 'verified')
601 RETURNING id",
602 )
603 .bind(migration_id)
604 .bind(why)
605 .fetch_optional(&mut *tx)
606 .await?;
607 if failed.is_none() {
608 return Ok(());
609 }
610 if direction == "to_postgres" {
618 sqlx::query(
619 "UPDATE conversation_messages m
620 SET body = ''
621 FROM conversation_migration_items i
622 WHERE i.migration_id = $1 AND m.id = i.message_id
623 AND m.backend <> 'postgres'
624 AND i.state <> 'verified'",
625 )
626 .bind(migration_id)
627 .execute(&mut *tx)
628 .await?;
629 }
630 sqlx::query(
634 "UPDATE conversations c SET write_paused_at = NULL
635 WHERE c.id = $1
636 AND NOT EXISTS (
637 SELECT 1 FROM conversation_migrations g
638 WHERE g.conversation_id = c.id
639 AND g.state IN ('planned', 'copying', 'verified'))",
640 )
641 .bind(conversation_id)
642 .execute(&mut *tx)
643 .await?;
644 tx.commit().await?;
645 Ok(())
646}
647
648pub async fn is_paused(pool: &PgPool, conversation_id: Uuid) -> BusResult<bool> {
651 let (paused,): (bool,) =
652 sqlx::query_as("SELECT write_paused_at IS NOT NULL FROM conversations WHERE id = $1")
653 .bind(conversation_id)
654 .fetch_one(pool)
655 .await?;
656 Ok(paused)
657}
658
659pub async fn cleanup(
666 pool: &PgPool,
667 team_id: Uuid,
668 rollback_window_hours: i64,
669 apply: bool,
670) -> BusResult<(i64, i64)> {
671 if rollback_window_hours < 0 {
672 return Err(BusError::invalid(
675 "the rollback window cannot be negative; it is how long a completed move must \
676 have been finished before its source bodies may be dropped",
677 ));
678 }
679 let (count, bytes): (i64, i64) = sqlx::query_as(
680 "SELECT count(*), COALESCE(sum(length(m.body)), 0)::bigint
681 FROM conversation_messages m
682 JOIN conversations c ON c.id = m.conversation_id
683 WHERE c.team_id = $1 AND m.backend = 'jetstream' AND m.body <> ''
684 AND m.canonical_locator IS NOT NULL
685 AND (SELECT g.direction = 'to_jetstream'
686 AND g.finished_at < now() - make_interval(secs => $2)
687 FROM conversation_migrations g
688 WHERE g.conversation_id = c.id AND g.state = 'cut_over'
689 ORDER BY g.finished_at DESC LIMIT 1)",
690 )
691 .bind(team_id)
692 .bind((rollback_window_hours * 3600) as f64)
693 .fetch_one(pool)
694 .await?;
695 if !apply {
696 return Ok((count, bytes));
697 }
698 sqlx::query(
699 "UPDATE conversation_messages m
700 SET body = ''
701 FROM conversations c
702 WHERE c.id = m.conversation_id AND c.team_id = $1
703 AND m.backend = 'jetstream' AND m.body <> ''
704 AND m.canonical_locator IS NOT NULL
705 AND (SELECT g.direction = 'to_jetstream'
706 AND g.finished_at < now() - make_interval(secs => $2)
707 FROM conversation_migrations g
708 WHERE g.conversation_id = c.id AND g.state = 'cut_over'
709 ORDER BY g.finished_at DESC LIMIT 1)",
710 )
711 .bind(team_id)
712 .bind((rollback_window_hours * 3600) as f64)
713 .execute(pool)
714 .await?;
715 Ok((count, bytes))
716}