Skip to main content

fakecloud_rds_data/
service.rs

1//! RDS Data API (`rds-data`) restJson1 dispatch + real SQL execution.
2
3use std::collections::HashMap;
4use std::sync::atomic::{AtomicU64, Ordering};
5use std::sync::Arc;
6use std::sync::Mutex as StdMutex;
7
8use async_trait::async_trait;
9use http::{Method, StatusCode};
10use serde_json::{json, Map, Value};
11use tokio::sync::Mutex;
12use tokio::time::{Duration, Instant};
13
14use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
15use fakecloud_rds::SharedRdsState;
16
17const SUPPORTED_ACTIONS: &[&str] = &[
18    "ExecuteStatement",
19    "BatchExecuteStatement",
20    "BeginTransaction",
21    "CommitTransaction",
22    "RollbackTransaction",
23    "ExecuteSql",
24];
25
26/// Idle transactions are rolled back and their connection released after this
27/// long with no statement, mirroring the AWS RDS Data API's ~3-minute idle
28/// transaction timeout. Without this, a client that begins a transaction and
29/// never commits (crash, dropped handle) would leak a backing DB connection
30/// forever, eventually exhausting the container's `max_connections`.
31const TXN_IDLE_TIMEOUT: Duration = Duration::from_secs(180);
32
33/// Connection parameters captured from a resolved `DbInstance` (owned so the
34/// state read-lock is dropped before we touch the network).
35struct DbConn {
36    engine: String,
37    host: String,
38    port: u16,
39    user: String,
40    password: String,
41    db_name: String,
42    /// Optional schema (`search_path` on Postgres); ignored on MySQL where the
43    /// schema is the database.
44    schema: Option<String>,
45}
46
47/// A live connection held open across requests for the lifetime of a
48/// transaction. Postgres drives its connection on a spawned task that lives as
49/// long as the `Client`; MySQL owns its socket.
50enum HeldConn {
51    Pg(tokio_postgres::Client),
52    MySql(mysql_async::Conn),
53}
54
55/// One open transaction. The connection has its own lock so the global
56/// transactions-map lock is never held across an awaited SQL statement (a slow
57/// statement in one transaction must not block begin/commit/rollback of
58/// another). `last_used` drives the idle reaper.
59struct TxnEntry {
60    /// `None` once the transaction has been committed/rolled-back or reaped.
61    conn: Mutex<Option<HeldConn>>,
62    last_used: StdMutex<Instant>,
63    /// Number of statements currently executing against this transaction. Bumped
64    /// while the transactions-map lock is held (so the reaper, which samples
65    /// under the same lock, can never see a live in-flight transaction as
66    /// reapable) and cleared when the statement finishes. A transaction is only
67    /// reapable when this is zero.
68    in_flight: AtomicU64,
69}
70
71impl TxnEntry {
72    fn touch(&self) {
73        if let Ok(mut t) = self.last_used.lock() {
74            *t = Instant::now();
75        }
76    }
77
78    /// Has this transaction been idle longer than [`TXN_IDLE_TIMEOUT`] as of
79    /// `now`? A poisoned `last_used` lock is treated as not-stale so a spurious
80    /// panic elsewhere can never trigger a rollback.
81    fn is_stale(&self, now: Instant) -> bool {
82        self.last_used
83            .lock()
84            .map(|t| now.duration_since(*t) > TXN_IDLE_TIMEOUT)
85            .unwrap_or(false)
86    }
87
88    /// May the reaper roll this transaction back? Only when no statement is
89    /// in-flight *and* it has been idle past the timeout. The `in_flight` check
90    /// closes the window where a statement has taken a reference to the entry but
91    /// has not yet refreshed `last_used`.
92    fn is_reapable(&self, now: Instant) -> bool {
93        self.in_flight.load(Ordering::SeqCst) == 0 && self.is_stale(now)
94    }
95}
96
97/// RAII marker that keeps a transaction pinned against the idle reaper for the
98/// duration of a statement. Incremented under the transactions-map lock in
99/// [`RdsDataService::acquire_txn`]; on drop it refreshes the idle clock and then
100/// releases the pin, so the moment the pin is gone `last_used` is already fresh.
101struct InFlightGuard {
102    entry: Arc<TxnEntry>,
103}
104
105impl InFlightGuard {
106    fn pin(entry: Arc<TxnEntry>) -> Self {
107        entry.in_flight.fetch_add(1, Ordering::SeqCst);
108        entry.touch();
109        Self { entry }
110    }
111}
112
113impl Drop for InFlightGuard {
114    fn drop(&mut self) {
115        // Refresh the idle clock *before* dropping the pin: once `in_flight`
116        // reaches zero the reaper may observe the entry, and it must then see an
117        // up-to-date `last_used` rather than the stale pre-statement value.
118        self.entry.touch();
119        self.entry.in_flight.fetch_sub(1, Ordering::SeqCst);
120    }
121}
122
123type TxnMap = Arc<Mutex<HashMap<String, Arc<TxnEntry>>>>;
124
125pub struct RdsDataService {
126    rds_state: SharedRdsState,
127    /// Open transactions: `transactionId` -> the held connection running its
128    /// `BEGIN`.
129    transactions: TxnMap,
130}
131
132impl RdsDataService {
133    pub fn new(rds_state: SharedRdsState) -> Self {
134        let transactions: TxnMap = Arc::new(Mutex::new(HashMap::new()));
135        // Reap idle transactions so abandoned ones don't leak DB connections.
136        // Only spawn when a tokio runtime is present (it always is in the
137        // server; absent in some unit-test constructions).
138        if let Ok(handle) = tokio::runtime::Handle::try_current() {
139            handle.spawn(reap_idle_transactions(transactions.clone()));
140        }
141        Self {
142            rds_state,
143            transactions,
144        }
145    }
146
147    /// Map the restJson1 request (`POST /<Op>`) to its operation name.
148    fn resolve_action(req: &AwsRequest) -> Option<&'static str> {
149        if req.method != Method::POST {
150            return None;
151        }
152        match req.path_segments.first().map(String::as_str)? {
153            "Execute" => Some("ExecuteStatement"),
154            "BatchExecute" => Some("BatchExecuteStatement"),
155            "BeginTransaction" => Some("BeginTransaction"),
156            "CommitTransaction" => Some("CommitTransaction"),
157            "RollbackTransaction" => Some("RollbackTransaction"),
158            "ExecuteSql" => Some("ExecuteSql"),
159            _ => None,
160        }
161    }
162
163    /// Resolve a `resourceArn` (a DB instance or Aurora cluster ARN) to live
164    /// connection parameters from the RDS service state. Returns the writer
165    /// instance for a cluster ARN.
166    fn resolve_conn(&self, account_id: &str, resource_arn: &str) -> Option<DbConn> {
167        let cluster_id = resource_arn
168            .rsplit_once(":cluster:")
169            .map(|(_, id)| id.to_string());
170        let accounts = self.rds_state.read();
171        let state = accounts.get(account_id)?;
172        let inst = state.instances.values().find(|i| {
173            i.db_instance_arn == resource_arn
174                || cluster_id
175                    .as_deref()
176                    .is_some_and(|c| i.db_cluster_identifier.as_deref() == Some(c))
177        })?;
178        Some(DbConn {
179            engine: inst.engine.clone(),
180            // Use the address the RDS runtime actually recorded for this
181            // instance (Docker sibling host or k8s Pod IP). Re-deriving a
182            // Docker-only sibling host here would break the k8s backend and
183            // shell out to `docker info` on every request.
184            host: inst.endpoint_address.clone(),
185            port: inst.host_port,
186            user: inst.master_username.clone(),
187            password: inst.master_user_password.clone(),
188            db_name: inst.db_name.clone().unwrap_or_default(),
189            schema: None,
190        })
191    }
192
193    /// Common front-matter for the credentialed ops: validate `secretArn`,
194    /// resolve the `resourceArn` to a connection, and apply the request's
195    /// `database`/`schema` overrides (AWS lets a single resource serve many
196    /// databases — Aurora clusters often have no default DB name at all).
197    fn require_conn(&self, req: &AwsRequest, body: &Value) -> Result<DbConn, AwsServiceError> {
198        let resource_arn = body
199            .get("resourceArn")
200            .and_then(Value::as_str)
201            .ok_or_else(|| bad_request("resourceArn is required"))?;
202        // secretArn is required by AWS but fakecloud trusts the resourceArn for
203        // credential resolution (the secret would carry the same master creds).
204        if body.get("secretArn").and_then(Value::as_str).is_none() {
205            return Err(bad_request("secretArn is required"));
206        }
207        let mut conn = self
208            .resolve_conn(&req.account_id, resource_arn)
209            .ok_or_else(|| {
210                bad_request(format!(
211                    "HttpEndpoint is not enabled for resource {resource_arn} (no such DB)"
212                ))
213            })?;
214        if let Some(db) = body.get("database").and_then(Value::as_str) {
215            if !db.is_empty() {
216                conn.db_name = db.to_string();
217            }
218        }
219        conn.schema = body
220            .get("schema")
221            .and_then(Value::as_str)
222            .filter(|s| !s.is_empty())
223            .map(str::to_string);
224        Ok(conn)
225    }
226
227    async fn execute_statement(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
228        let body = req.json_body();
229        let sql = body
230            .get("sql")
231            .and_then(Value::as_str)
232            .ok_or_else(|| bad_request("sql is required"))?;
233        let include_metadata = body
234            .get("includeResultMetadata")
235            .and_then(Value::as_bool)
236            .unwrap_or(false);
237        let format_json = body
238            .get("formatRecordsAs")
239            .and_then(Value::as_str)
240            .map(|f| f == "JSON")
241            .unwrap_or(false);
242        let params = parse_parameters(body.get("parameters"));
243
244        // Inside a transaction: run on the held connection so the statement
245        // shares the open transaction's visibility and is committed/rolled back
246        // atomically with the others.
247        if let Some(txid) = body.get("transactionId").and_then(Value::as_str) {
248            // Validate the resource still resolves (AWS still checks it), but the
249            // actual execution rides the held connection.
250            self.require_conn(req, &body)?;
251            let pin = self.acquire_txn(txid).await?;
252            // Lock only this transaction's connection, not the whole map, so a
253            // slow statement here can't block other transactions. The pin keeps
254            // the reaper off this transaction for the whole statement.
255            let mut guard = pin.entry.conn.lock().await;
256            let held = guard.as_mut().ok_or_else(|| not_found_txn(txid))?;
257            let value = match held {
258                HeldConn::Pg(client) => {
259                    pg_run(client, sql, &params, include_metadata, format_json).await?
260                }
261                HeldConn::MySql(conn) => {
262                    my_run(conn, sql, &params, include_metadata, format_json).await?
263                }
264            };
265            return Ok(AwsResponse::ok_json(value));
266        }
267
268        let conn = self.require_conn(req, &body)?;
269        let engine = conn.engine.to_lowercase();
270        let value = if engine.contains("postgres") {
271            let client = pg_connect(&conn).await?;
272            pg_run(&client, sql, &params, include_metadata, format_json).await?
273        } else if is_mysql(&engine) {
274            let mut c = my_connect(&conn).await?;
275            let v = my_run(&mut c, sql, &params, include_metadata, format_json).await;
276            let _ = c.disconnect().await;
277            v?
278        } else {
279            return Err(unsupported_engine(&conn.engine));
280        };
281        Ok(AwsResponse::ok_json(value))
282    }
283
284    async fn begin_transaction(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
285        let body = req.json_body();
286        let conn = self.require_conn(req, &body)?;
287        let engine = conn.engine.to_lowercase();
288
289        let held = if engine.contains("postgres") {
290            let client = pg_connect(&conn).await?;
291            client
292                .batch_execute("BEGIN")
293                .await
294                .map_err(|e| bad_request(format!("could not begin transaction: {e}")))?;
295            HeldConn::Pg(client)
296        } else if is_mysql(&engine) {
297            use mysql_async::prelude::Queryable;
298            let mut c = my_connect(&conn).await?;
299            c.query_drop("START TRANSACTION")
300                .await
301                .map_err(|e| bad_request(format!("could not begin transaction: {e}")))?;
302            HeldConn::MySql(c)
303        } else {
304            return Err(unsupported_engine(&conn.engine));
305        };
306
307        let txid = gen_transaction_id();
308        let entry = Arc::new(TxnEntry {
309            conn: Mutex::new(Some(held)),
310            last_used: StdMutex::new(Instant::now()),
311            in_flight: AtomicU64::new(0),
312        });
313        self.transactions.lock().await.insert(txid.clone(), entry);
314        Ok(AwsResponse::ok_json(json!({ "transactionId": txid })))
315    }
316
317    /// Look up an open transaction and pin it against the idle reaper for the
318    /// duration of a statement. The `in_flight` bump happens while the
319    /// transactions-map lock is still held, so the reaper (which samples under
320    /// that same lock) can never observe this transaction as reapable between the
321    /// lookup and the pin — closing the clone-then-touch race. The returned guard
322    /// refreshes `last_used` and releases the pin on drop.
323    async fn acquire_txn(&self, txid: &str) -> Result<InFlightGuard, AwsServiceError> {
324        let map = self.transactions.lock().await;
325        let entry = map.get(txid).cloned().ok_or_else(|| not_found_txn(txid))?;
326        Ok(InFlightGuard::pin(entry))
327    }
328
329    /// Shared body for Commit/Rollback: pull the held connection out of the map
330    /// and run the terminal SQL on it.
331    async fn finish_transaction(
332        &self,
333        req: &AwsRequest,
334        sql_keyword: &str,
335        status: &str,
336    ) -> Result<AwsResponse, AwsServiceError> {
337        let body = req.json_body();
338        let txid = body
339            .get("transactionId")
340            .and_then(Value::as_str)
341            .ok_or_else(|| bad_request("transactionId is required"))?;
342        let entry = self
343            .transactions
344            .lock()
345            .await
346            .remove(txid)
347            .ok_or_else(|| not_found_txn(txid))?;
348        // Take ownership of the connection (waiting for any in-flight statement
349        // on it to finish) and run the terminal SQL.
350        let held = entry
351            .conn
352            .lock()
353            .await
354            .take()
355            .ok_or_else(|| not_found_txn(txid))?;
356        finish_held(held, sql_keyword).await?;
357        Ok(AwsResponse::ok_json(json!({ "transactionStatus": status })))
358    }
359
360    async fn batch_execute_statement(
361        &self,
362        req: &AwsRequest,
363    ) -> Result<AwsResponse, AwsServiceError> {
364        let body = req.json_body();
365        let sql = body
366            .get("sql")
367            .and_then(Value::as_str)
368            .ok_or_else(|| bad_request("sql is required"))?;
369        let sets = parse_parameter_sets(body.get("parameterSets"));
370
371        // Run every parameter set on one connection so the whole batch shares a
372        // transaction when `transactionId` is given.
373        if let Some(txid) = body.get("transactionId").and_then(Value::as_str) {
374            self.require_conn(req, &body)?;
375            let pin = self.acquire_txn(txid).await?;
376            let mut guard = pin.entry.conn.lock().await;
377            let held = guard.as_mut().ok_or_else(|| not_found_txn(txid))?;
378            let mut results = Vec::with_capacity(sets.len().max(1));
379            match held {
380                HeldConn::Pg(client) => {
381                    for set in run_each(&sets) {
382                        let v = pg_run(client, sql, set, false, false).await?;
383                        results.push(batch_update_result(&v));
384                    }
385                }
386                HeldConn::MySql(conn) => {
387                    for set in run_each(&sets) {
388                        let v = my_run(conn, sql, set, false, false).await?;
389                        results.push(batch_update_result(&v));
390                    }
391                }
392            }
393            return Ok(AwsResponse::ok_json(json!({ "updateResults": results })));
394        }
395
396        let conn = self.require_conn(req, &body)?;
397        let engine = conn.engine.to_lowercase();
398        let mut results = Vec::with_capacity(sets.len().max(1));
399        if engine.contains("postgres") {
400            let client = pg_connect(&conn).await?;
401            for set in run_each(&sets) {
402                let v = pg_run(&client, sql, set, false, false).await?;
403                results.push(batch_update_result(&v));
404            }
405        } else if is_mysql(&engine) {
406            let mut c = my_connect(&conn).await?;
407            for set in run_each(&sets) {
408                match my_run(&mut c, sql, set, false, false).await {
409                    Ok(v) => results.push(batch_update_result(&v)),
410                    Err(e) => {
411                        let _ = c.disconnect().await;
412                        return Err(e);
413                    }
414                }
415            }
416            let _ = c.disconnect().await;
417        } else {
418            return Err(unsupported_engine(&conn.engine));
419        }
420        Ok(AwsResponse::ok_json(json!({ "updateResults": results })))
421    }
422}
423
424#[async_trait]
425impl AwsService for RdsDataService {
426    fn service_name(&self) -> &str {
427        "rds-data"
428    }
429
430    fn supported_actions(&self) -> &[&str] {
431        SUPPORTED_ACTIONS
432    }
433
434    async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
435        let Some(action) = Self::resolve_action(&req) else {
436            return Err(AwsServiceError::aws_error(
437                StatusCode::NOT_FOUND,
438                "ResourceNotFoundException",
439                format!("Unknown operation: {} {}", req.method, req.raw_path),
440            ));
441        };
442        match action {
443            "ExecuteStatement" => self.execute_statement(&req).await,
444            "BatchExecuteStatement" => self.batch_execute_statement(&req).await,
445            "BeginTransaction" => self.begin_transaction(&req).await,
446            "CommitTransaction" => {
447                self.finish_transaction(&req, "COMMIT", "Transaction Committed")
448                    .await
449            }
450            "RollbackTransaction" => {
451                self.finish_transaction(&req, "ROLLBACK", "Rollback Complete")
452                    .await
453            }
454            // ExecuteSql is the deprecated, secret-less precursor to
455            // ExecuteStatement and was removed from the public API. Return an
456            // honest error rather than a fake success.
457            other => Err(AwsServiceError::aws_error(
458                StatusCode::BAD_REQUEST,
459                "BadRequestException",
460                format!("rds-data operation {other} is not supported"),
461            )),
462        }
463    }
464}
465
466fn bad_request(msg: impl Into<String>) -> AwsServiceError {
467    AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "BadRequestException", msg.into())
468}
469
470fn not_found_txn(txid: &str) -> AwsServiceError {
471    AwsServiceError::aws_error(
472        StatusCode::BAD_REQUEST,
473        "BadRequestException",
474        format!("Transaction {txid} is not found"),
475    )
476}
477
478fn unsupported_engine(engine: &str) -> AwsServiceError {
479    bad_request(format!("RDS Data API is not supported for engine {engine}"))
480}
481
482fn is_mysql(engine_lower: &str) -> bool {
483    engine_lower.contains("mysql") || engine_lower.contains("maria")
484}
485
486/// AWS transaction ids are long opaque base64-ish tokens; mirror the shape.
487fn gen_transaction_id() -> String {
488    use rand::RngCore;
489    let mut bytes = [0u8; 48];
490    rand::thread_rng().fill_bytes(&mut bytes);
491    b64_encode(&bytes)
492}
493
494/// Run the terminal SQL (`COMMIT`/`ROLLBACK`) on a connection taken out of a
495/// transaction entry and release it.
496async fn finish_held(held: HeldConn, sql_keyword: &str) -> Result<(), AwsServiceError> {
497    match held {
498        HeldConn::Pg(client) => {
499            client
500                .batch_execute(sql_keyword)
501                .await
502                .map_err(|e| bad_request(format!("{e}")))?;
503        }
504        HeldConn::MySql(mut conn) => {
505            use mysql_async::prelude::Queryable;
506            conn.query_drop(sql_keyword)
507                .await
508                .map_err(|e| bad_request(format!("{e}")))?;
509            let _ = conn.disconnect().await;
510        }
511    }
512    Ok(())
513}
514
515/// Background sweep: roll back and release any transaction idle longer than
516/// [`TXN_IDLE_TIMEOUT`], so an abandoned `BeginTransaction` never leaks its
517/// backing DB connection forever.
518async fn reap_idle_transactions(transactions: TxnMap) {
519    let mut tick = tokio::time::interval(Duration::from_secs(30));
520    loop {
521        tick.tick().await;
522        reap_once(&transactions).await;
523    }
524}
525
526/// Sample the transactions that look idle as of `now`, cloning the `Arc`s so the
527/// map lock is dropped before any connection is touched.
528async fn collect_stale(transactions: &TxnMap, now: Instant) -> Vec<(String, Arc<TxnEntry>)> {
529    let map = transactions.lock().await;
530    map.iter()
531        .filter(|(_, e)| e.is_reapable(now))
532        .map(|(id, e)| (id.clone(), e.clone()))
533        .collect()
534}
535
536/// Roll back and release each sampled transaction — but only after re-verifying,
537/// under the map lock, that it is *still* reapable and still the same entry.
538/// `is_reapable` rejects a transaction that a statement has pinned in-flight
539/// since the sample (the statement bumps `in_flight` under this same lock, so
540/// there is no window where a live statement's transaction looks idle), and the
541/// `ptr_eq` guard rejects one that was removed and replaced by a new
542/// BeginTransaction reusing the id.
543async fn reap_stale(transactions: &TxnMap, stale: Vec<(String, Arc<TxnEntry>)>) {
544    for (id, entry) in stale {
545        let mut map = transactions.lock().await;
546        let still_reapable = entry.is_reapable(Instant::now())
547            && map.get(&id).is_some_and(|e| Arc::ptr_eq(e, &entry));
548        if !still_reapable {
549            continue;
550        }
551        map.remove(&id);
552        drop(map);
553        if let Some(held) = entry.conn.lock().await.take() {
554            let _ = finish_held(held, "ROLLBACK").await;
555        }
556    }
557}
558
559/// One reaper sweep: sample the idle set, then roll back each after a re-check.
560async fn reap_once(transactions: &TxnMap) {
561    let stale = collect_stale(transactions, Instant::now()).await;
562    reap_stale(transactions, stale).await;
563}
564
565/// A single positional SQL parameter resolved from an AWS `SqlParameter`.
566enum SqlValue {
567    Null,
568    Bool(bool),
569    Long(i64),
570    Double(f64),
571    Text(String),
572    Blob(Vec<u8>),
573}
574
575type Params = Vec<(String, SqlValue)>;
576
577/// Parse `parameters[]` into `(name, value)` pairs in request order.
578fn parse_parameters(params: Option<&Value>) -> Params {
579    let Some(arr) = params.and_then(Value::as_array) else {
580        return Vec::new();
581    };
582    arr.iter()
583        .filter_map(|p| {
584            let name = p.get("name").and_then(Value::as_str)?.to_string();
585            let v = p.get("value")?;
586            let sv = if v.get("isNull").and_then(Value::as_bool) == Some(true) {
587                SqlValue::Null
588            } else if let Some(b) = v.get("booleanValue").and_then(Value::as_bool) {
589                SqlValue::Bool(b)
590            } else if let Some(n) = v.get("longValue").and_then(Value::as_i64) {
591                SqlValue::Long(n)
592            } else if let Some(d) = v.get("doubleValue").and_then(Value::as_f64) {
593                SqlValue::Double(d)
594            } else if let Some(s) = v.get("stringValue").and_then(Value::as_str) {
595                SqlValue::Text(s.to_string())
596            } else if let Some(b) = v.get("blobValue").and_then(Value::as_str) {
597                // blobValue is base64 in the Data API JSON.
598                SqlValue::Blob(b64_decode(b))
599            } else {
600                SqlValue::Null
601            };
602            Some((name, sv))
603        })
604        .collect()
605}
606
607/// Parse `parameterSets` (a list of parameter lists) for BatchExecuteStatement.
608fn parse_parameter_sets(sets: Option<&Value>) -> Vec<Params> {
609    let Some(arr) = sets.and_then(Value::as_array) else {
610        return Vec::new();
611    };
612    arr.iter().map(|s| parse_parameters(Some(s))).collect()
613}
614
615/// A batch with no parameter sets still runs the statement once (AWS treats an
616/// empty `parameterSets` as a single parameterless execution).
617fn run_each(sets: &[Params]) -> Vec<&[(String, SqlValue)]> {
618    if sets.is_empty() {
619        vec![&[]]
620    } else {
621        sets.iter().map(Vec::as_slice).collect()
622    }
623}
624
625/// Substitute AWS `:name` named placeholders with the SQL literal rendering of
626/// each parameter. We inline (rather than bind) so the engine parses each value
627/// with the *column's* type — binding a Data API `longValue` (i64) to an int4
628/// column otherwise fails type-serialization. Data API parameters are trusted
629/// server-side fixtures, and results still come back fully typed because we run
630/// the literal-substituted SQL through the typed `query` path.
631fn inline_params(
632    sql: &str,
633    params: &[(String, SqlValue)],
634    lit: impl Fn(&SqlValue) -> String,
635) -> String {
636    let mut out = String::with_capacity(sql.len());
637    let bytes = sql.as_bytes();
638    let mut i = 0;
639    while i < bytes.len() {
640        if bytes[i] == b':' && i + 1 < bytes.len() && is_ident_start(bytes[i + 1]) {
641            let mut j = i + 1;
642            while j < bytes.len() && is_ident_char(bytes[j]) {
643                j += 1;
644            }
645            let name = &sql[i + 1..j];
646            if let Some((_, v)) = params.iter().find(|(n, _)| n == name) {
647                out.push_str(&lit(v));
648                i = j;
649                continue;
650            }
651        }
652        // Copy one whole UTF-8 character. `:` and identifier bytes are ASCII, so
653        // the placeholder scan above only ever fires on a char boundary; here we
654        // must advance by the full character width, not a single byte, or
655        // multi-byte literals (e.g. 'café') would be corrupted into mojibake.
656        let ch = sql[i..]
657            .chars()
658            .next()
659            .expect("byte index is on a char boundary");
660        out.push(ch);
661        i += ch.len_utf8();
662    }
663    out
664}
665
666fn sql_quote(s: &str) -> String {
667    format!("'{}'", s.replace('\'', "''"))
668}
669
670/// Quote an SQL identifier (schema/table name) so a caller-supplied `schema`
671/// can't break out of the `SET search_path` statement.
672fn quote_ident(s: &str) -> String {
673    format!("\"{}\"", s.replace('"', "\"\""))
674}
675
676/// SQL literal for postgres.
677fn pg_literal(v: &SqlValue) -> String {
678    match v {
679        SqlValue::Null => "NULL".to_string(),
680        SqlValue::Bool(b) => if *b { "TRUE" } else { "FALSE" }.to_string(),
681        SqlValue::Long(n) => n.to_string(),
682        SqlValue::Double(d) => d.to_string(),
683        SqlValue::Text(s) => sql_quote(s),
684        SqlValue::Blob(b) => format!("'\\x{}'::bytea", hex(b)),
685    }
686}
687
688/// SQL literal for mysql/mariadb.
689fn mysql_literal(v: &SqlValue) -> String {
690    match v {
691        SqlValue::Null => "NULL".to_string(),
692        SqlValue::Bool(b) => if *b { "1" } else { "0" }.to_string(),
693        SqlValue::Long(n) => n.to_string(),
694        SqlValue::Double(d) => d.to_string(),
695        SqlValue::Text(s) => sql_quote(s),
696        SqlValue::Blob(b) => format!("x'{}'", hex(b)),
697    }
698}
699
700/// Is the statement a data-modifying `INSERT`/`UPDATE`/`DELETE ... RETURNING`?
701/// These come back through the row-returning path (they have a result set) but,
702/// unlike a plain `SELECT`, they also change rows — so `numberOfRecordsUpdated`
703/// must reflect the affected count rather than 0.
704fn is_returning_dml(sql: &str) -> bool {
705    let head = sql.trim_start().to_ascii_uppercase();
706    (head.starts_with("INSERT") || head.starts_with("UPDATE") || head.starts_with("DELETE"))
707        && head.contains(" RETURNING ")
708}
709
710/// Build one `updateResults[]` entry for BatchExecuteStatement from a single
711/// statement's run output, carrying through any `generatedFields` the write
712/// produced (e.g. a MySQL auto-increment id) rather than always emitting `[]`.
713fn batch_update_result(run_output: &Value) -> Value {
714    let generated = run_output
715        .get("generatedFields")
716        .cloned()
717        .unwrap_or_else(|| json!([]));
718    json!({ "generatedFields": generated })
719}
720
721/// AWS-shaped `columnMetadata` for a Postgres result description.
722fn pg_column_metadata(cols: &[tokio_postgres::Column]) -> Value {
723    Value::Array(
724        cols.iter()
725            .map(|c| {
726                json!({
727                    "name": c.name(),
728                    "label": c.name(),
729                    "typeName": c.type_().name(),
730                    "nullable": 2, // unknown
731                })
732            })
733            .collect(),
734    )
735}
736
737/// AWS-shaped `columnMetadata` for a MySQL result description.
738fn my_column_metadata(cols: &[mysql_async::Column]) -> Value {
739    Value::Array(
740        cols.iter()
741            .map(|c| {
742                json!({
743                    "name": c.name_str().to_string(),
744                    "label": c.name_str().to_string(),
745                    "typeName": format!("{:?}", c.column_type()),
746                    "nullable": 2,
747                })
748            })
749            .collect(),
750    )
751}
752
753/// Does the SQL statement return a result set (vs. an affected-rows write)?
754fn returns_rows(sql: &str) -> bool {
755    let head = sql.trim_start().to_ascii_uppercase();
756    head.starts_with("SELECT")
757        || head.starts_with("WITH")
758        || head.starts_with("SHOW")
759        || head.starts_with("VALUES")
760        || head.starts_with("TABLE")
761        || head.starts_with("EXPLAIN")
762        || head.contains(" RETURNING ")
763}
764
765fn hex(b: &[u8]) -> String {
766    let mut s = String::with_capacity(b.len() * 2);
767    for byte in b {
768        s.push_str(&format!("{byte:02x}"));
769    }
770    s
771}
772
773fn is_ident_start(b: u8) -> bool {
774    b.is_ascii_alphabetic() || b == b'_'
775}
776fn is_ident_char(b: u8) -> bool {
777    b.is_ascii_alphanumeric() || b == b'_'
778}
779
780async fn pg_connect(conn: &DbConn) -> Result<tokio_postgres::Client, AwsServiceError> {
781    use tokio_postgres::NoTls;
782    let cs = format!(
783        "host={} port={} user={} password={} dbname={}",
784        conn.host, conn.port, conn.user, conn.password, conn.db_name
785    );
786    let (client, connection) = tokio_postgres::connect(&cs, NoTls)
787        .await
788        .map_err(|e| bad_request(format!("could not connect to database: {e}")))?;
789    tokio::spawn(async move {
790        let _ = connection.await;
791    });
792    if let Some(schema) = &conn.schema {
793        client
794            .batch_execute(&format!("SET search_path TO {}", quote_ident(schema)))
795            .await
796            .map_err(|e| bad_request(format!("could not set schema: {e}")))?;
797    }
798    Ok(client)
799}
800
801async fn pg_run(
802    client: &tokio_postgres::Client,
803    sql: &str,
804    params: &[(String, SqlValue)],
805    include_metadata: bool,
806    format_json: bool,
807) -> Result<Value, AwsServiceError> {
808    let stmt = inline_params(sql, params, pg_literal);
809    let no_params: &[&(dyn tokio_postgres::types::ToSql + Sync)] = &[];
810
811    let mut out = Map::new();
812    // Route by statement kind so a write runs EXACTLY once (query() executes the
813    // statement too, so running execute() after would double-apply it).
814    if !returns_rows(&stmt) {
815        let affected = client
816            .execute(stmt.as_str(), no_params)
817            .await
818            .map_err(|e| bad_request(format!("{e}")))?;
819        out.insert("numberOfRecordsUpdated".into(), json!(affected));
820        out.insert("records".into(), json!([]));
821        return Ok(Value::Object(out));
822    }
823
824    // Prepare first, then run the prepared statement: the `Statement`'s column
825    // description is available even when the query returns zero rows, so a
826    // `SELECT ... WHERE 1=0` still reports its columns under
827    // `includeResultMetadata` (a bare `client.query()` would leave us with no
828    // row to read columns from).
829    let statement = client
830        .prepare(stmt.as_str())
831        .await
832        .map_err(|e| bad_request(format!("{e}")))?;
833    let rows = client
834        .query(&statement, no_params)
835        .await
836        .map_err(|e| bad_request(format!("{e}")))?;
837    let cols = statement.columns();
838
839    // A pure SELECT never updates rows (numberOfRecordsUpdated = 0); a DML
840    // `... RETURNING` (INSERT/UPDATE/DELETE) reports the affected count, which
841    // equals the number of returned rows. Emit the field unconditionally so
842    // typed SDKs read a present value rather than defaulting it silently.
843    let updated = if is_returning_dml(&stmt) {
844        rows.len() as i64
845    } else {
846        0
847    };
848    out.insert("numberOfRecordsUpdated".into(), json!(updated));
849
850    if include_metadata {
851        out.insert("columnMetadata".into(), pg_column_metadata(cols));
852    }
853
854    if rows.is_empty() {
855        if format_json {
856            out.insert("formattedRecords".into(), json!("[]"));
857        } else {
858            out.insert("records".into(), json!([]));
859        }
860        return Ok(Value::Object(out));
861    }
862
863    let mut records: Vec<Value> = Vec::with_capacity(rows.len());
864    let mut json_rows: Vec<Map<String, Value>> = Vec::new();
865    for row in &rows {
866        let mut rec: Vec<Value> = Vec::with_capacity(cols.len());
867        let mut jr = Map::new();
868        for (i, col) in cols.iter().enumerate() {
869            let field = pg_field(row, i, col.type_());
870            if format_json {
871                jr.insert(col.name().to_string(), field_to_plain(&field));
872            }
873            rec.push(field);
874        }
875        records.push(Value::Array(rec));
876        if format_json {
877            json_rows.push(jr);
878        }
879    }
880    if format_json {
881        out.insert(
882            "formattedRecords".into(),
883            json!(serde_json::to_string(&json_rows).unwrap_or_default()),
884        );
885    } else {
886        out.insert("records".into(), Value::Array(records));
887    }
888    Ok(Value::Object(out))
889}
890
891/// Append a fractional-seconds suffix only when non-zero, with trailing zeros
892/// trimmed, matching Postgres's own `timestamp::text` rendering.
893fn pg_timestamp_text(base: impl std::fmt::Display, micros: u32) -> String {
894    if micros == 0 {
895        return base.to_string();
896    }
897    let frac = format!("{micros:06}");
898    let frac = frac.trim_end_matches('0');
899    format!("{base}.{frac}")
900}
901
902fn pg_field(row: &tokio_postgres::Row, i: usize, ty: &tokio_postgres::types::Type) -> Value {
903    use tokio_postgres::types::Type;
904    macro_rules! opt {
905        ($t:ty, $key:expr, $conv:expr) => {{
906            let v: Option<$t> = row.try_get(i).unwrap_or(None);
907            match v {
908                Some(x) => json!({ $key: $conv(x) }),
909                None => json!({ "isNull": true }),
910            }
911        }};
912    }
913    // Types that AWS surfaces as `stringValue` (numeric/decimal, temporal,
914    // UUID, JSON): decode them with their real Rust representation and render
915    // the canonical text, rather than letting them fall through to the
916    // `String` arm — which fails for these binary-format columns and would
917    // wrongly report `isNull`.
918    macro_rules! strv {
919        ($t:ty, $conv:expr) => {{
920            let v: Option<$t> = row.try_get(i).unwrap_or(None);
921            match v {
922                Some(x) => json!({ "stringValue": $conv(x) }),
923                None => json!({ "isNull": true }),
924            }
925        }};
926    }
927    match *ty {
928        Type::BOOL => opt!(bool, "booleanValue", |x| x),
929        Type::INT2 => opt!(i16, "longValue", |x| x as i64),
930        Type::INT4 => opt!(i32, "longValue", |x| x as i64),
931        Type::INT8 => opt!(i64, "longValue", |x: i64| x),
932        Type::FLOAT4 => opt!(f32, "doubleValue", |x| x as f64),
933        Type::FLOAT8 => opt!(f64, "doubleValue", |x: f64| x),
934        Type::NUMERIC => strv!(rust_decimal::Decimal, |d: rust_decimal::Decimal| d
935            .to_string()),
936        Type::UUID => strv!(uuid::Uuid, |u: uuid::Uuid| u.to_string()),
937        Type::JSON | Type::JSONB => {
938            strv!(serde_json::Value, |j: serde_json::Value| j.to_string())
939        }
940        Type::TIMESTAMP => strv!(chrono::NaiveDateTime, |t: chrono::NaiveDateTime| {
941            pg_timestamp_text(
942                t.format("%Y-%m-%d %H:%M:%S"),
943                t.and_utc().timestamp_subsec_micros(),
944            )
945        }),
946        Type::TIMESTAMPTZ => strv!(chrono::DateTime<chrono::Utc>, |t: chrono::DateTime<
947            chrono::Utc,
948        >| format!(
949            "{}{}",
950            pg_timestamp_text(t.format("%Y-%m-%d %H:%M:%S"), t.timestamp_subsec_micros()),
951            t.format("%:z")
952        )),
953        Type::DATE => strv!(chrono::NaiveDate, |d: chrono::NaiveDate| d.to_string()),
954        Type::TIME => strv!(chrono::NaiveTime, |t: chrono::NaiveTime| t.to_string()),
955        Type::BYTEA => {
956            let v: Option<Vec<u8>> = row.try_get(i).unwrap_or(None);
957            match v {
958                Some(b) => json!({ "blobValue": b64_encode(&b) }),
959                None => json!({ "isNull": true }),
960            }
961        }
962        _ => {
963            let v: Option<String> = row.try_get(i).unwrap_or(None);
964            match v {
965                Some(s) => json!({ "stringValue": s }),
966                None => json!({ "isNull": true }),
967            }
968        }
969    }
970}
971
972async fn my_connect(conn: &DbConn) -> Result<mysql_async::Conn, AwsServiceError> {
973    use mysql_async::OptsBuilder;
974    let opts = OptsBuilder::default()
975        .ip_or_hostname(conn.host.as_str())
976        .tcp_port(conn.port)
977        .user(Some(&conn.user))
978        .pass(Some(&conn.password))
979        .db_name(Some(&conn.db_name));
980    mysql_async::Conn::new(opts)
981        .await
982        .map_err(|e| bad_request(format!("could not connect to database: {e}")))
983}
984
985async fn my_run(
986    c: &mut mysql_async::Conn,
987    sql: &str,
988    params: &[(String, SqlValue)],
989    include_metadata: bool,
990    format_json: bool,
991) -> Result<Value, AwsServiceError> {
992    use mysql_async::prelude::Queryable;
993    use mysql_async::{Column, Row};
994
995    let stmt = inline_params(sql, params, mysql_literal);
996    let mut out = Map::new();
997
998    // Route writes through `query_drop` so the connection retains the INSERT's
999    // OK packet (and thus `last_insert_id`); `query` collecting rows can leave a
1000    // result-set terminator as the last packet.
1001    if !returns_rows(&stmt) {
1002        c.query_drop(stmt.as_str())
1003            .await
1004            .map_err(|e| bad_request(format!("{e}")))?;
1005        out.insert("numberOfRecordsUpdated".into(), json!(c.affected_rows()));
1006        // AWS Aurora MySQL surfaces the auto-increment key in `generatedFields`
1007        // (Postgres needs `RETURNING` instead, so its arm omits this). Read it
1008        // back explicitly on the same connection — robust across the text
1009        // protocol regardless of which OK packet the driver retained.
1010        let generated: Option<u64> = c
1011            .query_first("SELECT LAST_INSERT_ID()")
1012            .await
1013            .ok()
1014            .flatten()
1015            .filter(|id: &u64| *id != 0);
1016        if let Some(id) = generated {
1017            out.insert(
1018                "generatedFields".into(),
1019                json!([{ "longValue": id as i64 }]),
1020            );
1021        }
1022        out.insert("records".into(), json!([]));
1023        return Ok(Value::Object(out));
1024    }
1025
1026    // Run through `query_iter` so the result-set column definitions are read off
1027    // the wire (and captured) before the rows are drained — a zero-row result
1028    // (`WHERE 1=0`) therefore still exposes its columns under
1029    // `includeResultMetadata`, which `rows[0].columns()` cannot do when there is
1030    // no row 0.
1031    let result = c
1032        .query_iter(stmt.as_str())
1033        .await
1034        .map_err(|e| bad_request(format!("{e}")))?;
1035    let cols: std::sync::Arc<[Column]> = result
1036        .columns()
1037        .unwrap_or_else(|| std::sync::Arc::from(Vec::<Column>::new()));
1038    let rows: Vec<Row> = result
1039        .collect_and_drop::<Row>()
1040        .await
1041        .map_err(|e| bad_request(format!("{e}")))?;
1042    let affected = c.affected_rows();
1043
1044    // A pure SELECT never updates rows; a DML `... RETURNING` (MariaDB) reports
1045    // the affected count. Keep the field consistent with the Postgres arm.
1046    let updated = if is_returning_dml(&stmt) {
1047        affected as i64
1048    } else {
1049        0
1050    };
1051    out.insert("numberOfRecordsUpdated".into(), json!(updated));
1052
1053    if include_metadata {
1054        out.insert("columnMetadata".into(), my_column_metadata(&cols));
1055    }
1056
1057    if rows.is_empty() {
1058        if format_json {
1059            out.insert("formattedRecords".into(), json!("[]"));
1060        } else {
1061            out.insert("records".into(), json!([]));
1062        }
1063        return Ok(Value::Object(out));
1064    }
1065
1066    let mut records: Vec<Value> = Vec::with_capacity(rows.len());
1067    let mut json_rows: Vec<Map<String, Value>> = Vec::new();
1068    for row in &rows {
1069        let mut rec: Vec<Value> = Vec::with_capacity(cols.len());
1070        let mut jr = Map::new();
1071        for (i, col) in cols.iter().enumerate() {
1072            let field = my_field(row, i, col.column_type());
1073            if format_json {
1074                jr.insert(col.name_str().to_string(), field_to_plain(&field));
1075            }
1076            rec.push(field);
1077        }
1078        records.push(Value::Array(rec));
1079        if format_json {
1080            json_rows.push(jr);
1081        }
1082    }
1083    if format_json {
1084        out.insert(
1085            "formattedRecords".into(),
1086            json!(serde_json::to_string(&json_rows).unwrap_or_default()),
1087        );
1088    } else {
1089        out.insert("records".into(), Value::Array(records));
1090    }
1091    Ok(Value::Object(out))
1092}
1093
1094fn my_field(row: &mysql_async::Row, i: usize, ty: mysql_async::consts::ColumnType) -> Value {
1095    use mysql_async::Value as V;
1096    match row.as_ref(i) {
1097        Some(V::NULL) | None => json!({ "isNull": true }),
1098        // Binary-protocol typed values (kept for completeness).
1099        Some(V::Int(n)) => json!({ "longValue": n }),
1100        Some(V::UInt(n)) => json!({ "longValue": *n as i64 }),
1101        Some(V::Float(f)) => json!({ "doubleValue": *f as f64 }),
1102        Some(V::Double(d)) => json!({ "doubleValue": d }),
1103        // Under the text protocol every column arrives as raw bytes, so coerce
1104        // numeric columns to the right typed Field by the column's declared
1105        // type rather than always returning `stringValue`.
1106        Some(V::Bytes(b)) => my_bytes_field(b, ty),
1107        // Temporal columns arrive as typed Date/Time values under the binary
1108        // protocol; render the canonical SQL text instead of the debug form.
1109        Some(V::Date(y, mo, d, h, mi, s, us)) => {
1110            json!({ "stringValue": format_mysql_datetime(*y, *mo, *d, *h, *mi, *s, *us) })
1111        }
1112        Some(V::Time(neg, days, h, mi, s, us)) => {
1113            json!({ "stringValue": format_mysql_time(*neg, *days, *h, *mi, *s, *us) })
1114        }
1115    }
1116}
1117
1118/// Coerce a text-protocol byte column to a typed AWS Field by its MySQL type.
1119/// Integers -> `longValue`, real/double -> `doubleValue`; decimals, dates,
1120/// times, and strings stay `stringValue`; non-UTF-8 bytes (true binary/blob)
1121/// become `blobValue`.
1122fn my_bytes_field(b: &[u8], ty: mysql_async::consts::ColumnType) -> Value {
1123    use mysql_async::consts::ColumnType::*;
1124    let as_str = std::str::from_utf8(b).ok();
1125    match ty {
1126        MYSQL_TYPE_TINY | MYSQL_TYPE_SHORT | MYSQL_TYPE_LONG | MYSQL_TYPE_LONGLONG
1127        | MYSQL_TYPE_INT24 | MYSQL_TYPE_YEAR => {
1128            if let Some(n) = as_str.and_then(|s| s.parse::<i64>().ok()) {
1129                return json!({ "longValue": n });
1130            }
1131        }
1132        MYSQL_TYPE_FLOAT | MYSQL_TYPE_DOUBLE => {
1133            if let Some(d) = as_str.and_then(|s| s.parse::<f64>().ok()) {
1134                return json!({ "doubleValue": d });
1135            }
1136        }
1137        _ => {}
1138    }
1139    match as_str {
1140        Some(s) => json!({ "stringValue": s }),
1141        None => json!({ "blobValue": b64_encode(b) }),
1142    }
1143}
1144
1145/// Render a MySQL `DATE`/`DATETIME`/`TIMESTAMP` value as canonical SQL text,
1146/// dropping the time portion for a pure date.
1147#[allow(clippy::too_many_arguments)]
1148fn format_mysql_datetime(
1149    year: u16,
1150    month: u8,
1151    day: u8,
1152    hour: u8,
1153    min: u8,
1154    sec: u8,
1155    micros: u32,
1156) -> String {
1157    let date = format!("{year:04}-{month:02}-{day:02}");
1158    if hour == 0 && min == 0 && sec == 0 && micros == 0 {
1159        return date;
1160    }
1161    if micros == 0 {
1162        format!("{date} {hour:02}:{min:02}:{sec:02}")
1163    } else {
1164        format!("{date} {hour:02}:{min:02}:{sec:02}.{micros:06}")
1165    }
1166}
1167
1168/// Render a MySQL `TIME` value (a signed duration) as canonical SQL text.
1169fn format_mysql_time(neg: bool, days: u32, hours: u8, mins: u8, secs: u8, micros: u32) -> String {
1170    let sign = if neg { "-" } else { "" };
1171    let total_hours = days * 24 + hours as u32;
1172    if micros == 0 {
1173        format!("{sign}{total_hours:02}:{mins:02}:{secs:02}")
1174    } else {
1175        format!("{sign}{total_hours:02}:{mins:02}:{secs:02}.{micros:06}")
1176    }
1177}
1178
1179/// Collapse a typed Field to a plain JSON scalar for `formatRecordsAs=JSON`.
1180fn field_to_plain(field: &Value) -> Value {
1181    let o = field.as_object();
1182    if o.and_then(|m| m.get("isNull")).is_some() {
1183        return Value::Null;
1184    }
1185    for k in [
1186        "stringValue",
1187        "longValue",
1188        "doubleValue",
1189        "booleanValue",
1190        "blobValue",
1191    ] {
1192        if let Some(v) = o.and_then(|m| m.get(k)) {
1193            return v.clone();
1194        }
1195    }
1196    Value::Null
1197}
1198
1199fn b64_encode(b: &[u8]) -> String {
1200    use base64::Engine;
1201    base64::engine::general_purpose::STANDARD.encode(b)
1202}
1203fn b64_decode(s: &str) -> Vec<u8> {
1204    use base64::Engine;
1205    base64::engine::general_purpose::STANDARD
1206        .decode(s)
1207        .unwrap_or_default()
1208}
1209
1210#[cfg(test)]
1211mod tests {
1212    use super::*;
1213
1214    #[test]
1215    fn is_returning_dml_matches_only_dml_returning() {
1216        assert!(is_returning_dml("INSERT INTO t VALUES (1) RETURNING id"));
1217        assert!(is_returning_dml("  update t set x = 1 returning *"));
1218        assert!(is_returning_dml("DELETE FROM t WHERE id = 1 RETURNING id"));
1219        // A plain SELECT is not a write, even when a column is named `returning`.
1220        assert!(!is_returning_dml("SELECT 1"));
1221        assert!(!is_returning_dml("SELECT returning_col FROM t"));
1222        // An INSERT without RETURNING goes through the affected-rows write path.
1223        assert!(!is_returning_dml("INSERT INTO t VALUES (1)"));
1224    }
1225
1226    #[test]
1227    fn batch_update_result_carries_generated_fields() {
1228        // The MySQL write path populates generatedFields; the batch entry must
1229        // surface it rather than discarding it for an empty list.
1230        let out = json!({
1231            "numberOfRecordsUpdated": 1,
1232            "generatedFields": [{ "longValue": 42 }],
1233            "records": [],
1234        });
1235        assert_eq!(
1236            batch_update_result(&out),
1237            json!({ "generatedFields": [{ "longValue": 42 }] })
1238        );
1239    }
1240
1241    #[test]
1242    fn batch_update_result_defaults_to_empty_generated_fields() {
1243        // A statement that produced no generated key (e.g. Postgres, or a
1244        // non-auto-increment INSERT) still yields a well-formed entry.
1245        let out = json!({ "numberOfRecordsUpdated": 1, "records": [] });
1246        assert_eq!(batch_update_result(&out), json!({ "generatedFields": [] }));
1247    }
1248
1249    fn idle_entry(last_used: Instant) -> Arc<TxnEntry> {
1250        // conn = None so the reaper never tries to touch a real database.
1251        Arc::new(TxnEntry {
1252            conn: Mutex::new(None),
1253            last_used: StdMutex::new(last_used),
1254            in_flight: AtomicU64::new(0),
1255        })
1256    }
1257
1258    #[tokio::test(start_paused = true)]
1259    async fn reaper_rolls_back_idle_transaction() {
1260        let transactions: TxnMap = Arc::new(Mutex::new(HashMap::new()));
1261        let base = Instant::now();
1262        transactions
1263            .lock()
1264            .await
1265            .insert("tx1".to_string(), idle_entry(base));
1266        // Advance past the idle window, then sweep.
1267        tokio::time::advance(TXN_IDLE_TIMEOUT + Duration::from_secs(1)).await;
1268        reap_once(&transactions).await;
1269        assert!(
1270            transactions.lock().await.is_empty(),
1271            "a genuinely idle transaction is reaped"
1272        );
1273    }
1274
1275    #[tokio::test(start_paused = true)]
1276    async fn reaper_spares_transaction_with_in_flight_statement() {
1277        // Reproduces the Cubic P1 race: a statement has taken a reference to the
1278        // entry (pin) but has NOT yet refreshed last_used (it is a slow query
1279        // still running). Even though last_used is stale, the in-flight pin must
1280        // keep the reaper from rolling the live transaction back.
1281        let transactions: TxnMap = Arc::new(Mutex::new(HashMap::new()));
1282        let base = Instant::now();
1283        let entry = idle_entry(base);
1284        transactions
1285            .lock()
1286            .await
1287            .insert("tx1".to_string(), entry.clone());
1288        tokio::time::advance(TXN_IDLE_TIMEOUT + Duration::from_secs(1)).await;
1289        // A statement is now in-flight: pinned, but last_used still points at the
1290        // pre-statement (stale) instant.
1291        entry.in_flight.fetch_add(1, Ordering::SeqCst);
1292        reap_once(&transactions).await;
1293        assert!(
1294            transactions.lock().await.contains_key("tx1"),
1295            "a transaction with an in-flight statement is not reaped despite a stale last_used"
1296        );
1297    }
1298
1299    #[tokio::test(start_paused = true)]
1300    async fn reaper_rechecks_staleness_before_rollback() {
1301        let transactions: TxnMap = Arc::new(Mutex::new(HashMap::new()));
1302        let base = Instant::now();
1303        let entry = idle_entry(base);
1304        transactions
1305            .lock()
1306            .await
1307            .insert("tx1".to_string(), entry.clone());
1308        tokio::time::advance(TXN_IDLE_TIMEOUT + Duration::from_secs(1)).await;
1309        // The reaper samples the entry as stale...
1310        let stale = collect_stale(&transactions, Instant::now()).await;
1311        assert_eq!(stale.len(), 1, "entry sampled as stale");
1312        // ...but an in-flight ExecuteStatement reactivates it before removal.
1313        entry.touch();
1314        reap_stale(&transactions, stale).await;
1315        assert!(
1316            transactions.lock().await.contains_key("tx1"),
1317            "a reactivated transaction is NOT rolled back (TOCTOU guarded)"
1318        );
1319    }
1320}