Skip to main content

mj_controller/controller/checkpoint/
lease.rs

1use super::*;
2
3#[derive(Debug, Clone)]
4pub struct CheckpointArtifact {
5    pub metadata: CheckpointMetadata,
6    pub native_session_id: String,
7    /// Digest paired with `metadata.event_frontier` at the relay barrier.
8    pub event_frontier_digest: String,
9}
10
11/// The relay connection one lifecycle operation talks to.
12///
13/// A managed operation borrows the session actor's own connection instead of
14/// opening a competing one. Exclusivity is only needed while a checkpoint
15/// latches its projection at the barrier's ready cursor; `end_latch` hands the
16/// connection back so the dashboard keeps syncing and submitting while the
17/// archive exports and transfers.
18pub(in crate::controller) enum ControllerRelayLease {
19    Managed {
20        handle: ManagedSessionHandle,
21        lease: Option<ManagedSessionLease>,
22    },
23    Standalone(StandaloneSession),
24}
25
26impl ControllerRelayLease {
27    /// The exclusively held connection. Only a latch phase, or an operation
28    /// that deliberately holds its lease to the end, may use this.
29    pub(in crate::controller) fn connection_mut(&mut self) -> &mut StandaloneSession {
30        match self {
31            Self::Managed { lease, .. } => lease
32                .as_mut()
33                .expect("checkpoint latch has already returned its connection")
34                .connection_mut(),
35            Self::Standalone(connection) => connection,
36        }
37    }
38
39    pub(super) async fn submit(
40        &mut self,
41        command_id: String,
42        command: RelayCommand,
43    ) -> Result<u64> {
44        match self {
45            Self::Managed {
46                lease: Some(lease), ..
47            } => lease.connection_mut().submit(command_id, command).await,
48            Self::Managed { handle, .. } => handle.submit(command_id, command).await,
49            Self::Standalone(connection) => connection.submit(command_id, command).await,
50        }
51    }
52
53    pub(super) async fn sync_snapshot(&mut self) -> Result<ManagedSessionSnapshot> {
54        match self {
55            Self::Managed {
56                lease: Some(lease), ..
57            } => lease.connection_mut().sync().await,
58            Self::Managed { handle, .. } => {
59                handle.sync_now().await?;
60                handle
61                    .view()
62                    .snapshot
63                    .context("managed session has no snapshot")
64            }
65            Self::Standalone(connection) => connection.sync().await,
66        }
67    }
68
69    /// Swap the proxy after the worker process behind it was restarted.
70    pub(super) fn replace_connection(&mut self, connection: StandaloneSession) {
71        match self {
72            Self::Managed {
73                lease: Some(lease), ..
74            } => lease.replace_connection(connection),
75            Self::Standalone(existing) => *existing = connection,
76            Self::Managed { lease: None, .. } => {
77                *self = Self::Standalone(connection);
78            }
79        }
80    }
81
82    /// Return the connection to its session actor now that the projection is
83    /// latched. Releasing keeps the connection alive, so the relay barrier it
84    /// opened stays open. Idempotent.
85    pub(super) fn end_latch(&mut self) {
86        if let Self::Managed { lease, .. } = self
87            && let Some(lease) = lease.take()
88        {
89            lease.release();
90        }
91    }
92
93    /// Abandon a checkpoint barrier this controller can no longer complete.
94    ///
95    /// A relay barrier belongs to the connection that opened it and only a
96    /// disconnect cancels it (`cancel_checkpoint_barrier_on_disconnect`).
97    /// Completing it instead would advance the relay's recovery floor past
98    /// history that no verified checkpoint covers, so reclaim the connection
99    /// and drop it: the worker cancels the barrier and resumes dispatch.
100    pub(super) async fn cancel_abandoned_barrier(&mut self) -> Result<()> {
101        let Self::Managed { handle, lease } = self else {
102            // A standalone connection is dropped with this value, which the
103            // worker sees as the same disconnect.
104            return Ok(());
105        };
106        match lease.take() {
107            Some(lease) => drop(lease),
108            None => drop(handle.lease_connection().await?),
109        }
110        Ok(())
111    }
112
113    pub(in crate::controller) fn release(self) {
114        if let Self::Managed {
115            lease: Some(lease), ..
116        } = self
117        {
118            lease.release();
119        }
120    }
121}
122
123/// Whether a checkpoint keeps its exclusive connection after latching.
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub(in crate::controller) enum LatchExclusivity {
126    /// Ordinary and recovery checkpoints only need exclusivity to latch the
127    /// projection at the barrier's ready cursor. Everything after that runs
128    /// through the session actor, so prompts keep flowing while the archive
129    /// exports and transfers.
130    ReleaseAfterLatch,
131    /// Close seals the relay at the exact latched cursor, so nothing else may
132    /// reach the relay between the barrier and its Close command.
133    HoldThroughClose,
134}
135
136/// Whether a latched checkpoint must export a fresh archive.
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub(in crate::controller) enum CheckpointExportPolicy {
139    /// Always export, transfer, and install a new archive.
140    Always,
141    /// Keep the installed archive when the latched projection holds the same
142    /// session content. Relay bookkeeping (the checkpoint commands themselves)
143    /// always moves the event frontier, so only content can decide this.
144    ReuseUnchangedArchive,
145}
146
147/// How a latched checkpoint ends the barrier it opened.
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub(in crate::controller) enum CheckpointCompletion {
150    /// The barrier is still open. Completing it resumes ACP dispatch and
151    /// advances the relay's recovery floor in one durable step; abandoning it
152    /// cancels the barrier and leaves the floor alone.
153    HeldBarrier,
154    /// The worker already resumed dispatch when target capture finished. All that
155    /// is left for a durably installed archive is the recovery floor move.
156    ReleasedAfterCapture,
157}
158
159pub(in crate::controller) struct LatchedCheckpoint {
160    pub(in crate::controller) artifact: CheckpointArtifact,
161    pub(in crate::controller) relay: ControllerRelayLease,
162    pub(in crate::controller) barrier_command_id: String,
163    pub(in crate::controller) cursor: RelayCursor,
164    pub(in crate::controller) completion: CheckpointCompletion,
165}
166
167/// A latched checkpoint owns an open relay barrier, and that barrier freezes
168/// ACP dispatch until something ends it. Every path out of one must therefore
169/// either [`LatchedCheckpoint::complete`] it or [`LatchedCheckpoint::abandon`]
170/// it; both consume the value so a new exit cannot quietly skip the choice.
171/// Close is the exception: it holds its lease to the end, so dropping that
172/// lease is what ends its barrier.
173impl LatchedCheckpoint {
174    /// Let the relay release the history that this installed archive covers.
175    pub(super) async fn complete(mut self) -> Result<()> {
176        let (prefix, command) = match self.completion {
177            CheckpointCompletion::HeldBarrier => (
178                "checkpoint-complete",
179                RelayCommand::CompleteCheckpoint {
180                    barrier_command_id: self.barrier_command_id.clone(),
181                },
182            ),
183            // The worker that accepted the early release also understands the
184            // floor move; they were added together.
185            CheckpointCompletion::ReleasedAfterCapture => (
186                "checkpoint-floor",
187                RelayCommand::AdvanceRecoveryFloor {
188                    through: self.cursor.clone(),
189                },
190            ),
191        };
192        let command_id = new_command_id(prefix)?;
193        self.relay.submit(command_id, command).await.map(|_| ())
194    }
195
196    /// Cancel the barrier of a checkpoint the caller could not install.
197    ///
198    /// The latch is already back with the session actor, whose connection can
199    /// stay healthy for the rest of the session, so nothing else would ever
200    /// end this barrier.
201    pub(super) async fn abandon(mut self, session_id: &str) {
202        if self.completion == CheckpointCompletion::ReleasedAfterCapture {
203            // Dispatch resumed when target capture finished, so there is no barrier
204            // left to cancel, and the recovery floor must stay behind an
205            // archive that was never installed. Doing nothing is the exit.
206            return;
207        }
208        if let Err(error) = self.relay.cancel_abandoned_barrier().await {
209            tracing::warn!(
210                session_id,
211                "abandoned checkpoint could not cancel its relay barrier: {error:#}"
212            );
213        }
214    }
215}