Skip to main content

ai_crew_sync/store/
migrate.rs

1//! Moving one conversation's bodies between backends, under supervision.
2//!
3//! Five steps, and the order is the whole safety argument:
4//!
5//! 1. **Plan.** Count the messages and bytes, check the target is reachable
6//!    and provisioned, and report. Nothing is written, and this is what the
7//!    command does unless `--apply` is passed.
8//! 2. **Copy.** Every message's body is read from where it is, checksummed,
9//!    written to the target under a deterministic idempotency key, and
10//!    recorded. The thread keeps working throughout: bodies are still read
11//!    from their own recorded backend, which has not changed yet.
12//! 3. **Pause and copy the tail.** Writes to this one conversation are
13//!    refused, with a message saying why, for as long as the tail takes.
14//!    Nothing else on the bus is affected.
15//! 4. **Verify.** Every body is read back *from the target* and compared to
16//!    the checksum taken from the source. A mismatch fails the move with
17//!    the thread untouched.
18//! 5. **Cut over.** In one transaction: each verified message's
19//!    authoritative backend, then the conversation's routing, then the
20//!    pause is lifted.
21//!
22//! What this never does: invent a receipt, change an author, or delete a
23//! source body. Attachments are not involved at all — they hang off channel
24//! messages and tasks, they live in Postgres, and no backend move touches
25//! them. Cleanup is a
26//! separate, explicit operator action after the rollback window, because a
27//! rollback that has nothing to roll back to is not a rollback.
28
29use 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/// Where a move is going.
42#[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/// What one conversation would cost to move, and whether it can be.
79#[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    /// Messages already on the target. A resumed or partly-run move.
87    pub already_there: i64,
88    /// Why this conversation cannot be moved, when it cannot.
89    pub blocked: Option<String>,
90    /// True when a run is already open for it: the command continues that
91    /// one instead of starting another.
92    pub resuming: bool,
93}
94
95/// Plan a move for every conversation of a team, or for a chosen few.
96pub 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            // A body already released to its backend is empty here, and a
116            // rollback still has to copy it. The size recorded when it was
117            // moved is what that costs.
118            "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        // An open run for this direction is one to *resume*, not a reason to
132        // refuse. A process that died holding the pause would otherwise
133        // leave the thread paused for ever, with the documented command
134        // reporting success and doing nothing.
135        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            // Paused with no run behind it: something cleared the run and
148            // left the flag. Say so rather than silently writing through it.
149            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/// A move in progress, and its evidence when it finishes.
173#[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
188/// Open or resume the run for this conversation and direction. Resuming is
189/// the normal case after an interruption: what is already verified is not
190/// copied again.
191async fn open_run(
192    pool: &PgPool,
193    team_id: Uuid,
194    conversation_id: Uuid,
195    direction: Direction,
196) -> BusResult<Uuid> {
197    // One run per conversation at a time, decided under the conversation's
198    // own lock. Two operators starting a move on the same thread would
199    // otherwise each open one, and the first to fail would lift the pause
200    // out from under the other.
201    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
239/// Copy, verify and cut over one conversation.
240///
241/// `pause` is what makes the tail safe: while it is set, sends to this one
242/// thread are refused with an explanation. It is lifted by the cutover, and
243/// by [`abort`] if anything goes wrong.
244pub 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    // The pause covers copy and verification. It is one conversation, and a
255    // sender is told what is happening rather than seeing a mysterious
256    // refusal.
257    // Taken with the row lock a send uses to allocate its sequence, so a
258    // send in flight either commits before the pause or sees it.
259    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            // Nothing was cut over, so nothing is half-moved: the bodies are
285            // still authoritative where they were, and the thread reopens.
286            // If the abort itself fails, the run stays open under its pause
287            // and the same command resumes it; the operator still needs the
288            // move's own error, not the abort's.
289            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        // Read from where this body actually is, not from where the thread
343        // is going.
344        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        // A resumed run that already recorded a locator checks whether that
383        // body is there before publishing again. The broker's deduplication
384        // is windowed, so republishing after a long interruption would make
385        // a second physical copy of one logical message.
386        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        // Write to the target. The idempotency key is derived from the
414        // message itself, so a resumed run presents the same key and the
415        // broker recognises it instead of storing a second copy.
416        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        // Read it back from the target and compare. A body that does not
455        // come back identical fails the move; nothing is cut over.
456        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    // The cutover. One transaction: the messages' authority, the thread's
501    // routing, and the pause. The thread's row is taken first, in the same
502    // order as a send and as `abort`, so a cutover and an abort of the same
503    // run by two processes queue instead of deadlocking.
504    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    // A move to Postgres makes the row the storage again, so the temporary
523    // locator has no further meaning; a move to JetStream keeps the body
524    // locally until the separate cleanup drops it.
525    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
568/// Give up on a move, lift the pause and leave the thread exactly as it was.
569///
570/// The run's state, the written-back bodies and the pause move together in
571/// one transaction. Written one by one, a crash between the first and the
572/// last left the run `failed` and the thread paused with no run behind the
573/// pause: every send refused, and the documented retry blocked by `plan`
574/// because no move owned it (#180). Rolled back instead, the run stays
575/// open, so the same command resumes it.
576pub 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    // The thread's row first, the order every writer of a conversation
588    // takes (a send, the pause, the cutover): taking the run and the
589    // messages before it could deadlock against a cutover of the same run
590    // by another process.
591    sqlx::query("SELECT id FROM conversations WHERE id = $1 FOR UPDATE")
592        .bind(conversation_id)
593        .fetch_one(&mut *tx)
594        .await?;
595    // Only a run still open is given up on: one that was cut over, or
596    // already failed, by another process has nothing left to undo.
597    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    // A reverse move writes each body into its row as it copies. Nothing
611    // was cut over, so those rows are still JetStream-authoritative, and a
612    // copy that never verified must not be served as if it were the body.
613    // A verified one read back identical to what the broker held, so it is
614    // kept: a move to Postgres is what an operator runs when the broker is
615    // going bad, and that copy may be the last one. The body sweep releases
616    // it once the broker confirms the same digest, and only then (#173).
617    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    // Only if this run still owns the pause: another run may have taken the
631    // thread since, and reopening it under that one would be worse than the
632    // failure being reported.
633    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
648/// Whether a conversation's writes are paused right now: what an operator
649/// is told after a move fails, instead of assuming the abort reopened it.
650pub 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
659/// Drop source bodies a completed move no longer needs.
660///
661/// Deliberately separate, deliberately explicit, and deliberately late: a
662/// rollback with nothing to roll back to is not a rollback. It refuses to
663/// touch anything whose move is not `cut_over`, and anything younger than
664/// the rollback window an operator states.
665pub 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        // A negative window points into the future and would drop the
673        // source copies of a move that finished a moment ago.
674        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}