Skip to main content

alopex_server/ops/
distributed_read.rs

1//! Server-owned authorization entry point for a remote range worker.
2//!
3//! The cluster crate validates signed transport facts. This module injects the
4//! server-local data policy into that validation and binds a successful
5//! delegation subject to a fresh worker session before P2.10 opens storage.
6
7use std::sync::{Arc, Mutex, MutexGuard};
8use std::time::{Duration, SystemTime};
9
10use alopex_cluster::{
11    authorize_remote_read, LocalReadAuthorizationRecheck, ReadDelegationVerifier,
12    RemoteReadAuthorizationEnvelope, RequestId, VerifiedPeerIdentity,
13};
14use alopex_sql::distributed_read::{PreparedResult, PreparedResultStream};
15use dashmap::DashMap;
16use serde::Serialize;
17use uuid::Uuid;
18
19use crate::error::{Result, ServerError};
20use crate::session::{SessionId, SessionManager};
21
22/// The ID shared by the coordinator, every range-worker request, the terminal
23/// summary, and the HTTP cancellation route.
24///
25/// This deliberately reuses the cluster request ID rather than introducing a
26/// second correlation value which could be accidentally omitted from a worker
27/// cancellation message.
28pub type ReadExecutionId = RequestId;
29
30const READ_EXECUTION_SUMMARY_VERSION: u32 = 1;
31
32/// Authenticated ownership of a coordinator-side read execution.
33///
34/// The profile is never included in the public summary. It is retained only
35/// to ensure that another authenticated caller cannot inspect or cancel the
36/// execution. A supplied SQL session is correlation state, not a substitute
37/// for the profile authorization check.
38#[derive(Clone, Debug, PartialEq, Eq)]
39pub struct ReadExecutionOwner {
40    profile: String,
41    session_id: Option<SessionId>,
42}
43
44impl ReadExecutionOwner {
45    pub fn new(profile: impl Into<String>, session_id: Option<SessionId>) -> Result<Self> {
46        let profile = profile.into();
47        if profile.trim().is_empty() {
48            return Err(ServerError::Unauthorized(
49                "distributed read requires an authenticated profile".into(),
50            ));
51        }
52        Ok(Self {
53            profile,
54            session_id,
55        })
56    }
57
58    fn permits(&self, profile: Option<&str>) -> bool {
59        profile == Some(self.profile.as_str())
60    }
61
62    pub fn session_id(&self) -> Option<&SessionId> {
63        self.session_id.as_ref()
64    }
65}
66
67/// Immutable routing evidence which must accompany every public summary.
68#[derive(Clone, Debug, PartialEq, Eq)]
69pub struct ReadExecutionPlanSummary {
70    pub requested_mode: String,
71    pub effective_mode: String,
72    pub metadata_version: u64,
73    pub ranges: Vec<String>,
74    pub freshness: String,
75    pub retry_count: u32,
76    pub failover_count: u32,
77}
78
79impl ReadExecutionPlanSummary {
80    pub fn validate(&self) -> Result<()> {
81        if self.requested_mode.trim().is_empty()
82            || self.effective_mode.trim().is_empty()
83            || self.freshness.trim().is_empty()
84        {
85            return Err(ServerError::BadRequest(
86                "distributed read summary requires requested/effective mode and freshness".into(),
87            ));
88        }
89        if self.ranges.is_empty() || self.ranges.iter().any(|range| range.trim().is_empty()) {
90            return Err(ServerError::BadRequest(
91                "distributed read summary requires at least one range".into(),
92            ));
93        }
94        Ok(())
95    }
96}
97
98/// Stable terminal classification exposed to HTTP and CLI adapters.
99#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
100#[serde(rename_all = "snake_case")]
101pub enum ReadExecutionOutcome {
102    Preparing,
103    Success,
104    RetryableFailure,
105    TerminalFailure,
106    Cancelled,
107}
108
109/// Versioned public terminal summary. It contains no per-range rows.
110#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
111pub struct ReadExecutionSummary {
112    pub schema_version: u32,
113    pub execution_id: ReadExecutionId,
114    pub outcome: ReadExecutionOutcome,
115    pub requested_mode: String,
116    pub effective_mode: String,
117    pub metadata_version: u64,
118    pub ranges: Vec<String>,
119    pub freshness: String,
120    pub retry_count: u32,
121    pub failover_count: u32,
122    pub resource_outcome: String,
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub row_count: Option<usize>,
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub reason: Option<String>,
127}
128
129impl ReadExecutionSummary {
130    fn preparing(execution_id: ReadExecutionId, plan: ReadExecutionPlanSummary) -> Self {
131        Self {
132            schema_version: READ_EXECUTION_SUMMARY_VERSION,
133            execution_id,
134            outcome: ReadExecutionOutcome::Preparing,
135            requested_mode: plan.requested_mode,
136            effective_mode: plan.effective_mode,
137            metadata_version: plan.metadata_version,
138            ranges: plan.ranges,
139            freshness: plan.freshness,
140            retry_count: plan.retry_count,
141            failover_count: plan.failover_count,
142            resource_outcome: "preparing".into(),
143            row_count: None,
144            reason: None,
145        }
146    }
147}
148
149/// One idempotent cancellation result for the HTTP route.
150#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
151pub struct ReadCancellation {
152    pub summary: ReadExecutionSummary,
153    /// Number of registered peer cleanup deliveries made by this call. It is
154    /// zero for repeated cancellation and for an already-finished result.
155    pub peer_cleanup_deliveries: usize,
156    pub already_terminal: bool,
157}
158
159/// Coordinator failure type. It intentionally separates retryability from a
160/// terminal failure before any result row can become visible.
161#[derive(Clone, Debug, PartialEq, Eq)]
162pub enum ReadExecutionFailure {
163    Retryable(String),
164    Terminal(String),
165}
166
167/// A cancellation delivery registered before a peer range request is sent.
168/// The shared execution ID is passed to the transport-specific cancellation
169/// implementation, which may in turn call `RangeReadWorker::cancel`.
170pub type PeerCancellation = Arc<dyn Fn(&ReadExecutionId) + Send + Sync>;
171
172/// Server-owned coordinator registry. It is deliberately independent of the
173/// HTTP connection: timeout, disconnect, and explicit cancel all use the same
174/// idempotent transition and invoke peer cleanup outside its entry lock.
175#[derive(Clone, Default)]
176pub struct DistributedReadRegistry {
177    entries: Arc<DashMap<ReadExecutionId, Arc<Mutex<ReadExecutionEntry>>>>,
178}
179
180struct ReadExecutionEntry {
181    owner: ReadExecutionOwner,
182    summary: ReadExecutionSummary,
183    prepared: Option<PreparedResult>,
184    peer_cancellations: Vec<PeerCancellation>,
185    updated_at: SystemTime,
186}
187
188/// A prepared stream lease. Dropping a partially consumed HTTP response uses
189/// the same registry cancellation path as `POST .../cancel`.
190pub struct PreparedReadLease {
191    registry: DistributedReadRegistry,
192    execution_id: ReadExecutionId,
193    entry: Arc<Mutex<ReadExecutionEntry>>,
194    stream: PreparedResultStream,
195    closed: bool,
196}
197
198impl PreparedReadLease {
199    pub fn columns(&self) -> Vec<alopex_sql::executor::ColumnInfo> {
200        let entry = lock_entry(&self.entry);
201        entry
202            .prepared
203            .as_ref()
204            .map(|result| result.columns().to_vec())
205            .unwrap_or_default()
206    }
207
208    /// Open a second immutable cursor for response-size preflight. It never
209    /// exposes rows to a caller and leaves the delivery cursor untouched.
210    pub fn preview_stream(&self) -> Option<PreparedResultStream> {
211        let entry = lock_entry(&self.entry);
212        (entry.summary.outcome == ReadExecutionOutcome::Success)
213            .then(|| {
214                entry
215                    .prepared
216                    .as_ref()
217                    .map(PreparedResult::open_prepared_stream)
218            })
219            .flatten()
220    }
221
222    /// Returns a row only while the registered execution remains successful.
223    /// A cancellation that arrives between HTTP body polls is observed before
224    /// the next row is made available to the response stream.
225    pub fn next_row(&mut self) -> Option<Vec<alopex_sql::storage::SqlValue>> {
226        if self.summary().outcome != ReadExecutionOutcome::Success {
227            return None;
228        }
229        self.stream.next_row()
230    }
231
232    pub fn summary(&self) -> ReadExecutionSummary {
233        lock_entry(&self.entry).summary.clone()
234    }
235
236    /// Discard coordinator-owned result state after the terminal summary was
237    /// delivered successfully. A small terminal tombstone remains only until
238    /// registry cleanup, keeping repeated cancellation idempotent.
239    pub fn finish(mut self) {
240        self.registry
241            .finish_delivery(&self.execution_id, &self.entry);
242        self.closed = true;
243    }
244}
245
246impl Drop for PreparedReadLease {
247    fn drop(&mut self) {
248        if !self.closed {
249            let _ = self.registry.cancel_unchecked(&self.execution_id);
250        }
251    }
252}
253
254impl DistributedReadRegistry {
255    pub fn new() -> Self {
256        Self::default()
257    }
258
259    /// Register before dispatching any worker request. Supplying the planner's
260    /// `request_id` makes that one ID the coordinator/worker/HTTP correlation
261    /// key required by the distributed-read protocol.
262    pub fn register_with_id(
263        &self,
264        execution_id: ReadExecutionId,
265        owner: ReadExecutionOwner,
266        plan: ReadExecutionPlanSummary,
267        peer_cancellations: Vec<PeerCancellation>,
268    ) -> Result<()> {
269        plan.validate()?;
270        if self.entries.contains_key(&execution_id) {
271            return Err(ServerError::Conflict(format!(
272                "distributed read execution '{}' is already registered",
273                execution_id.as_str()
274            )));
275        }
276        let entry = Arc::new(Mutex::new(ReadExecutionEntry {
277            owner,
278            summary: ReadExecutionSummary::preparing(execution_id.clone(), plan),
279            prepared: None,
280            peer_cancellations,
281            updated_at: SystemTime::now(),
282        }));
283        if self.entries.insert(execution_id.clone(), entry).is_some() {
284            return Err(ServerError::Conflict(format!(
285                "distributed read execution '{}' is already registered",
286                execution_id.as_str()
287            )));
288        }
289        Ok(())
290    }
291
292    /// Allocate a fresh request ID for a coordinator that does not already
293    /// have one from route planning.
294    pub fn register(
295        &self,
296        owner: ReadExecutionOwner,
297        plan: ReadExecutionPlanSummary,
298        peer_cancellations: Vec<PeerCancellation>,
299    ) -> Result<ReadExecutionId> {
300        let execution_id = RequestId::new(Uuid::new_v4().to_string());
301        self.register_with_id(execution_id.clone(), owner, plan, peer_cancellations)?;
302        Ok(execution_id)
303    }
304
305    /// Publish only P2.11's immutable prepared result. This is the sole
306    /// transition that permits the HTTP adapter to open a row stream.
307    pub fn publish_prepared(
308        &self,
309        execution_id: &ReadExecutionId,
310        prepared: PreparedResult,
311    ) -> Result<ReadExecutionSummary> {
312        let entry = self.entry(execution_id)?;
313        let mut entry = lock_entry(&entry);
314        match entry.summary.outcome {
315            ReadExecutionOutcome::Preparing => {
316                entry.summary.outcome = ReadExecutionOutcome::Success;
317                entry.summary.resource_outcome = "prepared".into();
318                entry.summary.row_count = Some(prepared.row_count());
319                entry.summary.reason = None;
320                entry.prepared = Some(prepared);
321                entry.updated_at = SystemTime::now();
322                Ok(entry.summary.clone())
323            }
324            ReadExecutionOutcome::Cancelled => Err(ServerError::Conflict(
325                "distributed read was cancelled before preparation completed".into(),
326            )),
327            _ => Err(ServerError::Conflict(
328                "distributed read no longer accepts a prepared result".into(),
329            )),
330        }
331    }
332
333    /// Record a classified coordinator failure and cancel every registered
334    /// peer exactly once. No prepared rows survive this transition.
335    pub fn fail(
336        &self,
337        execution_id: &ReadExecutionId,
338        failure: ReadExecutionFailure,
339    ) -> Result<ReadExecutionSummary> {
340        let entry = self.entry(execution_id)?;
341        let (summary, callbacks) = {
342            let mut entry = lock_entry(&entry);
343            if !matches!(
344                entry.summary.outcome,
345                ReadExecutionOutcome::Preparing | ReadExecutionOutcome::Success
346            ) {
347                return Ok(entry.summary.clone());
348            }
349            let (outcome, reason) = match failure {
350                ReadExecutionFailure::Retryable(reason) => {
351                    (ReadExecutionOutcome::RetryableFailure, reason)
352                }
353                ReadExecutionFailure::Terminal(reason) => {
354                    (ReadExecutionOutcome::TerminalFailure, reason)
355                }
356            };
357            entry.summary.outcome = outcome;
358            entry.summary.resource_outcome = "released".into();
359            entry.summary.reason = Some(reason);
360            entry.prepared = None;
361            entry.updated_at = SystemTime::now();
362            let callbacks = std::mem::take(&mut entry.peer_cancellations);
363            (entry.summary.clone(), callbacks)
364        };
365        deliver_cancellations(execution_id, callbacks);
366        Ok(summary)
367    }
368
369    pub fn cancel(
370        &self,
371        execution_id: &ReadExecutionId,
372        requester_profile: Option<&str>,
373    ) -> Result<ReadCancellation> {
374        let entry = self.entry(execution_id)?;
375        {
376            let entry = lock_entry(&entry);
377            if !entry.owner.permits(requester_profile) {
378                return Err(ServerError::Unauthorized(
379                    "distributed read belongs to a different authenticated profile".into(),
380                ));
381            }
382        }
383        Ok(self.cancel_entry(execution_id, &entry))
384    }
385
386    pub fn summary(
387        &self,
388        execution_id: &ReadExecutionId,
389        requester_profile: Option<&str>,
390    ) -> Result<ReadExecutionSummary> {
391        let entry = self.entry(execution_id)?;
392        let entry = lock_entry(&entry);
393        if !entry.owner.permits(requester_profile) {
394            return Err(ServerError::Unauthorized(
395                "distributed read belongs to a different authenticated profile".into(),
396            ));
397        }
398        Ok(entry.summary.clone())
399    }
400
401    /// Obtain a read-only lease only after P2.11 preparation succeeded.
402    pub fn open_prepared(
403        &self,
404        execution_id: &ReadExecutionId,
405        requester_profile: Option<&str>,
406    ) -> Result<PreparedReadLease> {
407        let entry = self.entry(execution_id)?;
408        let prepared = {
409            let entry = lock_entry(&entry);
410            if !entry.owner.permits(requester_profile) {
411                return Err(ServerError::Unauthorized(
412                    "distributed read belongs to a different authenticated profile".into(),
413                ));
414            }
415            if entry.summary.outcome != ReadExecutionOutcome::Success {
416                return Err(ServerError::Conflict(format!(
417                    "distributed read is {:?}, not prepared",
418                    entry.summary.outcome
419                )));
420            }
421            entry.prepared.clone().ok_or_else(|| {
422                ServerError::Conflict("distributed read result has already been released".into())
423            })?
424        };
425        Ok(PreparedReadLease {
426            registry: self.clone(),
427            execution_id: execution_id.clone(),
428            entry,
429            stream: prepared.open_prepared_stream(),
430            closed: false,
431        })
432    }
433
434    /// Remove only small, terminal tombstones and never a live preparation or
435    /// a retained prepared result. The server calls this with session TTL.
436    pub fn cleanup_terminal_before(&self, cutoff: SystemTime) {
437        let removable = self
438            .entries
439            .iter()
440            .filter_map(|entry| {
441                let state = lock_entry(entry.value());
442                (state.updated_at <= cutoff && state.prepared.is_none())
443                    .then(|| entry.key().clone())
444            })
445            .collect::<Vec<_>>();
446        for execution_id in removable {
447            self.entries.remove(&execution_id);
448        }
449    }
450
451    pub fn cleanup_after(&self, ttl: Duration) {
452        let cutoff = SystemTime::now()
453            .checked_sub(ttl)
454            .unwrap_or(SystemTime::UNIX_EPOCH);
455        self.cleanup_terminal_before(cutoff);
456    }
457
458    #[cfg(test)]
459    fn active_count(&self) -> usize {
460        self.entries.len()
461    }
462
463    fn entry(&self, execution_id: &ReadExecutionId) -> Result<Arc<Mutex<ReadExecutionEntry>>> {
464        self.entries
465            .get(execution_id)
466            .map(|entry| entry.value().clone())
467            .ok_or_else(|| {
468                ServerError::NotFound(format!(
469                    "distributed read execution '{}' was not found",
470                    execution_id.as_str()
471                ))
472            })
473    }
474
475    fn cancel_unchecked(&self, execution_id: &ReadExecutionId) -> Result<ReadCancellation> {
476        let entry = self.entry(execution_id)?;
477        Ok(self.cancel_entry(execution_id, &entry))
478    }
479
480    fn cancel_entry(
481        &self,
482        execution_id: &ReadExecutionId,
483        entry: &Arc<Mutex<ReadExecutionEntry>>,
484    ) -> ReadCancellation {
485        let (summary, callbacks, already_terminal) = {
486            let mut entry = lock_entry(entry);
487            if !matches!(
488                entry.summary.outcome,
489                ReadExecutionOutcome::Preparing | ReadExecutionOutcome::Success
490            ) || entry.prepared.is_none()
491                && entry.summary.outcome == ReadExecutionOutcome::Success
492            {
493                (entry.summary.clone(), Vec::new(), true)
494            } else {
495                entry.summary.outcome = ReadExecutionOutcome::Cancelled;
496                entry.summary.resource_outcome = "released".into();
497                entry.summary.reason = Some("cancelled".into());
498                entry.prepared = None;
499                entry.updated_at = SystemTime::now();
500                let callbacks = std::mem::take(&mut entry.peer_cancellations);
501                (entry.summary.clone(), callbacks, false)
502            }
503        };
504        let peer_cleanup_deliveries = callbacks.len();
505        deliver_cancellations(execution_id, callbacks);
506        ReadCancellation {
507            summary,
508            peer_cleanup_deliveries,
509            already_terminal,
510        }
511    }
512
513    fn finish_delivery(
514        &self,
515        _execution_id: &ReadExecutionId,
516        entry: &Arc<Mutex<ReadExecutionEntry>>,
517    ) {
518        let mut entry = lock_entry(entry);
519        if entry.summary.outcome == ReadExecutionOutcome::Success {
520            entry.prepared = None;
521            entry.peer_cancellations.clear();
522            entry.summary.resource_outcome = "released".into();
523            entry.updated_at = SystemTime::now();
524        }
525    }
526}
527
528fn lock_entry(entry: &Arc<Mutex<ReadExecutionEntry>>) -> MutexGuard<'_, ReadExecutionEntry> {
529    entry
530        .lock()
531        .unwrap_or_else(|poisoned| poisoned.into_inner())
532}
533
534fn deliver_cancellations(execution_id: &ReadExecutionId, callbacks: Vec<PeerCancellation>) {
535    for callback in callbacks {
536        callback(execution_id);
537    }
538}
539
540/// Injects the configured server-local read policy at every worker boundary.
541#[derive(Clone)]
542pub struct RemoteReadWorkerAuthorizer {
543    local_authorizer: Arc<dyn LocalReadAuthorizationRecheck>,
544}
545
546impl RemoteReadWorkerAuthorizer {
547    /// Create a worker authorizer from the exact policy used for local reads.
548    pub fn new(local_authorizer: Arc<dyn LocalReadAuthorizationRecheck>) -> Self {
549        Self { local_authorizer }
550    }
551
552    /// Verify the transport delegation and locally recheck the end-user's
553    /// permission before any worker storage/session operation occurs.
554    pub fn authorize(
555        &self,
556        peer: VerifiedPeerIdentity,
557        envelope: &RemoteReadAuthorizationEnvelope,
558        now_ms: u64,
559        verifier: &dyn ReadDelegationVerifier,
560    ) -> Result<()> {
561        authorize_remote_read(
562            peer,
563            envelope,
564            now_ms,
565            verifier,
566            self.local_authorizer.as_ref(),
567        )
568        .map_err(|error| {
569            ServerError::Unauthorized(format!("remote read delegation rejected: {error}"))
570        })
571    }
572
573    /// Authorize a delegation and create a new subject-bound worker session.
574    ///
575    /// A rejected scope never creates a session, so a later worker cannot use
576    /// a broader authority than the corresponding local read.
577    pub async fn begin_authorized_worker_session(
578        &self,
579        sessions: &SessionManager,
580        peer: VerifiedPeerIdentity,
581        envelope: &RemoteReadAuthorizationEnvelope,
582        now_ms: u64,
583        verifier: &dyn ReadDelegationVerifier,
584    ) -> Result<SessionId> {
585        self.authorize(peer, envelope, now_ms, verifier)?;
586        sessions
587            .create_authenticated_session(envelope.credential.subject.clone())
588            .await
589    }
590}
591
592#[cfg(test)]
593mod tests {
594    use std::collections::BTreeSet;
595    use std::sync::atomic::{AtomicUsize, Ordering};
596    use std::time::Duration;
597
598    use alopex_cluster::{
599        AuthenticatedSubject, ClusterId, LocalReadAuthorizationRequest, NodeId, RangeId,
600        ReadDelegationCredential, ReadOperationScope, RequestId,
601    };
602    use alopex_core::ReadAtPoint;
603    use alopex_sql::distributed_read::{
604        AssemblerRow, AssemblyPlan, DistributedReadBudget, GlobalResultAssembler,
605        RangeAssemblerInput, RangeAssemblerPayload, RangeTerminal, ResultPresentation,
606        RowMergePlan,
607    };
608    use alopex_sql::executor::ColumnInfo;
609    use alopex_sql::planner::ResolvedType;
610    use alopex_sql::storage::SqlValue;
611    use sha2::{Digest, Sha256};
612
613    use super::*;
614    use crate::auth::{LocalReadAuthorizationPolicy, ServerLocalReadAuthorizationRecheck};
615    use crate::session::{SessionConfig, TransactionFactory};
616
617    struct DigestVerifier;
618
619    impl ReadDelegationVerifier for DigestVerifier {
620        fn verify(&self, _key_id: &str, payload: &[u8], signature: &[u8]) -> bool {
621            signature == Sha256::digest(payload).as_slice()
622        }
623    }
624
625    struct TableSevenPolicy;
626
627    impl LocalReadAuthorizationPolicy for TableSevenPolicy {
628        fn authorize_local_read(
629            &self,
630            request: &LocalReadAuthorizationRequest,
631        ) -> std::result::Result<(), String> {
632            (request.subject.as_str() == "user-a"
633                && request.table_id == 7
634                && request.range_id == RangeId::new("range-a"))
635            .then_some(())
636            .ok_or_else(|| "not permitted by local table/range policy".into())
637        }
638    }
639
640    fn envelope() -> RemoteReadAuthorizationEnvelope {
641        let mut credential = ReadDelegationCredential {
642            issuer: NodeId::new("gateway-a"),
643            cluster_id: ClusterId::new("cluster-a"),
644            subject: AuthenticatedSubject::new("user-a"),
645            operation: ReadOperationScope::Select,
646            table_id: 7,
647            allowed_ranges: BTreeSet::from([RangeId::new("range-a")]),
648            query_digest: "query-a".into(),
649            request_id: RequestId::new("request-a"),
650            read_fence_digest: "fence-a".into(),
651            audience: NodeId::new("gateway-a"),
652            read_at: ReadAtPoint::new(4, 3, 2, 1),
653            issued_at_ms: 10,
654            expires_at_ms: 20,
655            key_id: "test-key".into(),
656            signature: Vec::new(),
657        };
658        credential.signature = Sha256::digest(credential.signed_payload().unwrap()).to_vec();
659        RemoteReadAuthorizationEnvelope {
660            range_id: RangeId::new("range-a"),
661            table_id: 7,
662            operation: ReadOperationScope::Select,
663            request_id: RequestId::new("request-a"),
664            query_digest: "query-a".into(),
665            read_fence_digest: "fence-a".into(),
666            read_at: ReadAtPoint::new(4, 3, 2, 1),
667            credential,
668        }
669    }
670
671    fn authorizer() -> RemoteReadWorkerAuthorizer {
672        RemoteReadWorkerAuthorizer::new(Arc::new(ServerLocalReadAuthorizationRecheck::new(
673            Arc::new(TableSevenPolicy),
674        )))
675    }
676
677    fn peer() -> VerifiedPeerIdentity {
678        VerifiedPeerIdentity::new("gateway-a", "cluster-a")
679    }
680
681    fn session_manager() -> SessionManager {
682        let factory: TransactionFactory = Arc::new(|| {
683            Box::pin(async {
684                Err(ServerError::Internal(
685                    "test factory must not create a transaction".into(),
686                ))
687            })
688        });
689        SessionManager::new(
690            SessionConfig {
691                ttl: Duration::from_secs(60),
692            },
693            factory,
694        )
695    }
696
697    fn execution_plan() -> ReadExecutionPlanSummary {
698        ReadExecutionPlanSummary {
699            requested_mode: "strong".into(),
700            effective_mode: "strong".into(),
701            metadata_version: 41,
702            ranges: vec!["range-a".into()],
703            freshness: "current_committed_prefix".into(),
704            retry_count: 0,
705            failover_count: 0,
706        }
707    }
708
709    fn execution_owner() -> ReadExecutionOwner {
710        ReadExecutionOwner::new("profile-a", None).unwrap()
711    }
712
713    fn prepared_result() -> PreparedResult {
714        let columns = vec![ColumnInfo::new("name", ResolvedType::Text)];
715        let plan = AssemblyPlan::Rows(RowMergePlan {
716            presentation: ResultPresentation {
717                columns: columns.clone(),
718                distinct: false,
719                order: Vec::new(),
720                final_order_key_indexes: Vec::new(),
721                offset: 0,
722                limit: None,
723            },
724        });
725        let mut assembler = GlobalResultAssembler::new(
726            vec!["range-a".into()],
727            plan,
728            DistributedReadBudget::default(),
729        )
730        .unwrap();
731        assembler
732            .push_range(RangeAssemblerInput {
733                range_id: "range-a".into(),
734                columns,
735                payloads: vec![RangeAssemblerPayload::Rows(vec![AssemblerRow {
736                    values: vec![SqlValue::Text("prepared".into())],
737                    order_keys: Vec::new(),
738                    row_key: 1,
739                }])],
740                terminal: RangeTerminal::Completed {
741                    cleanup_acknowledged: true,
742                },
743            })
744            .unwrap();
745        assembler.prepare().unwrap()
746    }
747
748    #[tokio::test]
749    async fn worker_injection_creates_a_session_bound_to_the_locally_allowed_subject() {
750        let sessions = session_manager();
751        let session = authorizer()
752            .begin_authorized_worker_session(&sessions, peer(), &envelope(), 15, &DigestVerifier)
753            .await
754            .unwrap();
755        assert_eq!(
756            sessions
757                .authenticated_subject(&session)
758                .await
759                .unwrap()
760                .as_str(),
761            "user-a"
762        );
763    }
764
765    #[tokio::test]
766    async fn scope_tamper_is_rejected_without_creating_a_worker_session() {
767        let sessions = session_manager();
768        let mut tampered = envelope();
769        tampered.range_id = RangeId::new("range-b");
770        let result = authorizer()
771            .begin_authorized_worker_session(&sessions, peer(), &tampered, 15, &DigestVerifier)
772            .await;
773        assert!(matches!(result, Err(ServerError::Unauthorized(_))));
774        assert_eq!(sessions.active_session_count(), 0);
775    }
776
777    #[tokio::test]
778    async fn valid_transport_cannot_broaden_a_locally_denied_table_scope() {
779        let sessions = session_manager();
780        let mut denied = envelope();
781        denied.credential.table_id = 8;
782        denied.table_id = 8;
783        denied.credential.signature =
784            Sha256::digest(denied.credential.signed_payload().unwrap()).to_vec();
785        let result = authorizer()
786            .begin_authorized_worker_session(&sessions, peer(), &denied, 15, &DigestVerifier)
787            .await;
788        assert!(matches!(result, Err(ServerError::Unauthorized(_))));
789        assert_eq!(sessions.active_session_count(), 0);
790    }
791
792    #[test]
793    fn cancellation_is_owner_bound_idempotent_and_delivered_once_outside_registry_state() {
794        let registry = DistributedReadRegistry::new();
795        let execution_id = RequestId::new("read-cancel");
796        let deliveries = Arc::new(AtomicUsize::new(0));
797        let observed_id = Arc::new(std::sync::Mutex::new(None));
798        let callback_deliveries = deliveries.clone();
799        let callback_id = observed_id.clone();
800        registry
801            .register_with_id(
802                execution_id.clone(),
803                execution_owner(),
804                execution_plan(),
805                vec![Arc::new(move |id| {
806                    callback_deliveries.fetch_add(1, Ordering::SeqCst);
807                    *callback_id.lock().unwrap() = Some(id.clone());
808                })],
809            )
810            .unwrap();
811
812        assert!(matches!(
813            registry.cancel(&execution_id, Some("profile-b")),
814            Err(ServerError::Unauthorized(_))
815        ));
816        assert_eq!(deliveries.load(Ordering::SeqCst), 0);
817
818        let cancelled = registry.cancel(&execution_id, Some("profile-a")).unwrap();
819        assert_eq!(cancelled.summary.outcome, ReadExecutionOutcome::Cancelled);
820        assert_eq!(cancelled.peer_cleanup_deliveries, 1);
821        assert!(!cancelled.already_terminal);
822        assert_eq!(deliveries.load(Ordering::SeqCst), 1);
823        assert_eq!(observed_id.lock().unwrap().as_ref(), Some(&execution_id));
824
825        let repeated = registry.cancel(&execution_id, Some("profile-a")).unwrap();
826        assert_eq!(repeated.summary.outcome, ReadExecutionOutcome::Cancelled);
827        assert_eq!(repeated.peer_cleanup_deliveries, 0);
828        assert!(repeated.already_terminal);
829        assert_eq!(deliveries.load(Ordering::SeqCst), 1);
830        assert!(matches!(
831            registry.open_prepared(&execution_id, Some("profile-a")),
832            Err(ServerError::Conflict(_))
833        ));
834    }
835
836    #[test]
837    fn only_prepared_results_open_and_disconnect_uses_the_same_peer_cleanup_once() {
838        let registry = DistributedReadRegistry::new();
839        let execution_id = RequestId::new("read-prepared");
840        let deliveries = Arc::new(AtomicUsize::new(0));
841        let callback_deliveries = deliveries.clone();
842        registry
843            .register_with_id(
844                execution_id.clone(),
845                execution_owner(),
846                execution_plan(),
847                vec![Arc::new(move |_| {
848                    callback_deliveries.fetch_add(1, Ordering::SeqCst);
849                })],
850            )
851            .unwrap();
852
853        assert!(matches!(
854            registry.open_prepared(&execution_id, Some("profile-a")),
855            Err(ServerError::Conflict(_))
856        ));
857        let summary = registry
858            .publish_prepared(&execution_id, prepared_result())
859            .unwrap();
860        assert_eq!(summary.outcome, ReadExecutionOutcome::Success);
861        assert_eq!(summary.row_count, Some(1));
862
863        let mut lease = registry
864            .open_prepared(&execution_id, Some("profile-a"))
865            .unwrap();
866        assert_eq!(
867            lease.next_row(),
868            Some(vec![SqlValue::Text("prepared".into())])
869        );
870        // Simulate client disconnect before the HTTP terminal summary.
871        drop(lease);
872
873        assert_eq!(deliveries.load(Ordering::SeqCst), 1);
874        assert_eq!(
875            registry
876                .summary(&execution_id, Some("profile-a"))
877                .unwrap()
878                .outcome,
879            ReadExecutionOutcome::Cancelled
880        );
881        registry.cleanup_terminal_before(SystemTime::now());
882        assert_eq!(registry.active_count(), 0);
883    }
884}