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}