Skip to main content

roomrs_async/
lib.rs

1//! roomrs-async — runtime-agnostic async facade (명세 §2.4).
2//!
3//! Reads and writes are offloaded to a blocking worker pool (or
4//! `tokio::task::spawn_blocking` with the `tokio` feature); completion is
5//! signalled through a runtime-neutral oneshot channel, so the returned
6//! futures can be awaited on any executor (tokio, smol, `futures::executor`).
7//! The pool is selected when the returned future is **first polled**: with
8//! the `tokio` feature, a job polled outside a tokio runtime falls back to
9//! the self-managed pool.
10//!
11//! Internal crate — use the `roomrs` facade instead.
12#![deny(unsafe_code)]
13
14use roomrs_core::rusqlite::types::FromSql;
15use roomrs_core::{Database, DatabaseInner, Error, FromRow, Params, Result, SyncHandle};
16use std::sync::Arc;
17
18pub use roomrs_core::rusqlite;
19
20// ─────────────────────── 워커 풀 ───────────────────────
21
22/// 블로킹 작업 오프로드 — tokio 런타임 안이면 spawn_blocking, 아니면 자체 풀
23mod offload {
24    /// 작업 타입
25    pub(crate) type Job = Box<dyn FnOnce() + Send + 'static>;
26
27    /// 자체 워커 풀 — `tokio` feature에서도 런타임 밖 폴백용으로 항상 컴파일 (H-6)
28    mod pool {
29        use super::Job;
30        use std::sync::LazyLock;
31        use std::sync::mpsc::{Sender, channel};
32
33        /// 워커 수 상한 — 환경변수 폭주로 인한 스레드 폭탄·초기화 지연 방지 (M-7)
34        const MAX_WORKERS: usize = 1024;
35
36        /// 워커 수 결정(순수 함수) — env 값 우선(0·비숫자는 무시), 1024 초과는 클램프,
37        /// 기본 max(4, parallelism). 단위 테스트 대상 (M-7)
38        fn effective_worker_count(env_val: Option<&str>, parallelism: usize) -> usize {
39            if let Some(v) = env_val {
40                if let Ok(n) = v.trim().parse::<usize>() {
41                    if n > MAX_WORKERS {
42                        // 상한 초과 — 클램프하고 경고
43                        log::warn!(
44                            "ROOMRS_ASYNC_WORKERS={n} exceeds the cap; \
45                             clamped to {MAX_WORKERS}"
46                        );
47                        return MAX_WORKERS;
48                    }
49                    if n > 0 {
50                        return n;
51                    }
52                }
53            }
54            parallelism.max(4)
55        }
56
57        /// 워커 수 결정 — 환경변수 `ROOMRS_ASYNC_WORKERS` 우선(0·비숫자는 무시),
58        /// 기본 max(4, 코어 수), 상한 1024 (L-10, M-7)
59        fn worker_count() -> usize {
60            let parallelism = std::thread::available_parallelism()
61                .map(|n| n.get())
62                .unwrap_or(1);
63            effective_worker_count(
64                std::env::var("ROOMRS_ASYNC_WORKERS").ok().as_deref(),
65                parallelism,
66            )
67        }
68
69        /// 전역 워커 풀 — 프로세스 수명, 프로세스 내 모든 Database 인스턴스 공유 (명세 §2.4)
70        static POOL: LazyLock<Sender<Job>> = LazyLock::new(|| {
71            let (tx, rx) = channel::<Job>();
72            let rx = std::sync::Arc::new(std::sync::Mutex::new(rx));
73            for i in 0..worker_count() {
74                let rx = std::sync::Arc::clone(&rx);
75                // 스레드 생성 실패는 무시 — 전부 실패하면 수신단이 닫혀
76                // send 실패 → oneshot 취소 → Error::Internal 로 감지된다
77                let _ = std::thread::Builder::new()
78                    .name(format!("roomrs-worker-{i}"))
79                    .spawn(move || {
80                        loop {
81                            // 락 안에서 recv — 경합은 작업 분배 시점뿐이라 병목 아님
82                            let job = {
83                                rx.lock()
84                                    .expect(
85                                        "논리적 불가능: 락 구간은 recv뿐이고 사용자 코드는 \
86                                         catch_unwind로 격리되어 poisoned 될 수 없음",
87                                    )
88                                    .recv()
89                            };
90                            match job {
91                                // 사용자 클로저 패닉 격리 — 워커는 죽지 않고 다음 작업 계속 (H-5).
92                                // 패닉한 job은 oneshot 송신단을 drop → 수신측이 Internal로 매핑
93                                Ok(job) => {
94                                    if let Err(payload) =
95                                        std::panic::catch_unwind(std::panic::AssertUnwindSafe(job))
96                                    {
97                                        // 패닉 메시지 추출 — &str/String 페이로드만,
98                                        // 그 외 타입은 대체 문구 (L-9)
99                                        let msg = payload
100                                            .downcast_ref::<&str>()
101                                            .map(|s| (*s).to_owned())
102                                            .or_else(|| payload.downcast_ref::<String>().cloned())
103                                            .unwrap_or_else(|| {
104                                                "<non-string panic payload>".to_owned()
105                                            });
106                                        // 로거 백엔드가 패닉해도 워커가 죽지 않도록
107                                        // warn 호출 자체도 격리 (정보-1)
108                                        let _ = std::panic::catch_unwind(move || {
109                                            log::warn!(
110                                                "async job panicked — isolated, \
111                                                 worker continues: {msg}"
112                                            );
113                                        });
114                                    }
115                                }
116                                Err(_) => break, // 송신단 소멸 = 프로세스 종료 중
117                            }
118                        }
119                    });
120            }
121            tx
122        });
123
124        /// 작업 제출 — 실패 시 job drop → oneshot 취소로 호출측 Err 반환(패닉 금지, H-5)
125        pub(crate) fn spawn(job: Job) {
126            let _ = POOL.send(job);
127        }
128
129        /// effective_worker_count 단위 테스트 (M-7)
130        #[cfg(test)]
131        mod tests {
132            use super::{MAX_WORKERS, effective_worker_count};
133
134            // env 미설정 — 기본 max(4, parallelism)
135            #[test]
136            fn default_is_max_of_4_and_parallelism() {
137                assert_eq!(effective_worker_count(None, 1), 4);
138                assert_eq!(effective_worker_count(None, 4), 4);
139                assert_eq!(effective_worker_count(None, 16), 16);
140            }
141
142            // 0·비숫자·음수 값 무시 — 기본값 사용
143            #[test]
144            fn zero_and_invalid_are_ignored() {
145                assert_eq!(effective_worker_count(Some("0"), 8), 8);
146                assert_eq!(effective_worker_count(Some("abc"), 8), 8);
147                assert_eq!(effective_worker_count(Some(""), 8), 8);
148                assert_eq!(effective_worker_count(Some("-3"), 8), 8);
149            }
150
151            // 유효 값 채택 — 앞뒤 공백 트림 포함
152            #[test]
153            fn valid_value_is_used() {
154                assert_eq!(effective_worker_count(Some("2"), 8), 2);
155                assert_eq!(effective_worker_count(Some(" 32 "), 8), 32);
156            }
157
158            // 1024 초과 클램프 — 스레드 폭탄 방지 (M-7)
159            #[test]
160            fn huge_value_is_clamped_to_1024() {
161                assert_eq!(effective_worker_count(Some("1024"), 8), 1024);
162                assert_eq!(effective_worker_count(Some("1025"), 8), MAX_WORKERS);
163                assert_eq!(effective_worker_count(Some("999999999"), 8), MAX_WORKERS);
164            }
165        }
166    }
167
168    #[cfg(not(feature = "tokio"))]
169    pub(crate) use pool::spawn;
170
171    /// tokio 통합 — 런타임 안이면 spawn_blocking, 밖이면 자체 풀 폴백 (명세 §2.4, H-6).
172    /// feature는 가산적 — 의존성이 tokio feature를 켜도 smol/futures 사용자가 깨지면 안 된다
173    #[cfg(feature = "tokio")]
174    pub(crate) fn spawn(job: Job) {
175        match tokio::runtime::Handle::try_current() {
176            // spawn_blocking 핸들은 버린다 — 완료 통지는 oneshot이 담당
177            Ok(h) => drop(h.spawn_blocking(job)),
178            // tokio 런타임 밖 — 자체 풀로 폴백
179            Err(_) => pool::spawn(job),
180        }
181    }
182}
183
184/// oneshot 송신단 drop(작업이 결과 없이 소멸) → Internal 에러 생성 —
185/// run_on_worker · build_async 공용 매핑 (L-11, H-5)
186fn job_lost_error() -> Error {
187    Error::Internal("비동기 작업이 결과 없이 종료되었습니다(클로저 패닉 또는 워커 풀 소멸)".into())
188}
189
190/// 워커에 클로저를 제출하고 완료를 await — 모든 async 표면의 공통 기반
191async fn run_on_worker<R, F>(inner: Arc<DatabaseInner>, f: F) -> Result<R>
192where
193    R: Send + 'static,
194    F: FnOnce(SyncHandle<'_>) -> Result<R> + Send + 'static,
195{
196    let (tx, rx) = futures_channel::oneshot::channel::<Result<R>>();
197    offload::spawn(Box::new(move || {
198        let out = f(inner.sync_handle());
199        // 수신단 drop(Future 취소) = 결과 폐기 — 에러 아님
200        let _ = tx.send(out);
201    }));
202    // 송신단 drop = 작업이 결과 없이 사라짐(클로저 패닉 또는 워커 풀 소멸) → Internal (H-5)
203    rx.await.map_err(|_| job_lost_error())?
204}
205
206// ─────────────────────── AsyncHandle ───────────────────────
207
208/// Async handle returned by `db.run_async()`.
209///
210/// SQL and parameters are owned so submitted jobs satisfy the `'static` bound.
211///
212/// # Cancellation
213///
214/// Dropping a future returned by this handle **before it is first polled**
215/// means the job is never submitted. Dropping it **after** it has been polled
216/// does not cancel the operation: the work (including a transaction commit)
217/// still runs to completion on the worker and only the result is discarded.
218///
219/// # Worker pool
220///
221/// Without the `tokio` feature — or with it, when the returned future is
222/// first polled outside a tokio runtime — jobs run on a self-managed blocking
223/// pool that is **global to the process** and shared by every `Database`
224/// instance. Long-running jobs on one database can therefore delay jobs for
225/// other databases. The pool size defaults to
226/// `max(4, available parallelism)` and can be overridden with the
227/// `ROOMRS_ASYNC_WORKERS` environment variable (invalid or zero values are
228/// ignored; values above 1024 are clamped to 1024).
229/// The variable is read once when the self-managed pool is first used; later
230/// environment changes do not resize the process-global pool. With `tokio`,
231/// this initialization may be deferred until the first fallback submission.
232///
233/// ## Connection-pool contention
234///
235/// Every operation exclusively checks out one read/write connection. When all
236/// connections for a database are busy, waiting jobs can occupy every worker
237/// in the self-managed pool and delay jobs for other databases. Configure
238/// `DatabaseBuilder::queue_timeout` to bound checkout waits, keep transactions
239/// short, and/or increase `ROOMRS_ASYNC_WORKERS`. SQLite lock contention is
240/// governed separately by the configured `busy_timeout`. The tokio
241/// `spawn_blocking` path is less susceptible to worker starvation because its
242/// blocking pool can grow independently.
243#[derive(Clone)]
244pub struct AsyncHandle {
245    inner: Arc<DatabaseInner>,
246}
247
248impl AsyncHandle {
249    /// Constructs a handle from a database.
250    ///
251    /// This method is intended for code generated by `#[database]`.
252    #[doc(hidden)]
253    pub fn from_database(db: &Database) -> Self {
254        Self {
255            inner: db.inner_arc(),
256        }
257    }
258
259    /// Runs an arbitrary synchronous operation on a worker.
260    ///
261    /// This method is intended for generated DAO code. Its cancellation
262    /// semantics match the [`AsyncHandle`] cancellation section: dropping the
263    /// future after its first poll does not cancel the operation.
264    #[doc(hidden)]
265    pub fn run<R, F>(&self, f: F) -> impl Future<Output = Result<R>> + Send + use<R, F>
266    where
267        R: Send + 'static,
268        F: FnOnce(SyncHandle<'_>) -> Result<R> + Send + 'static,
269    {
270        run_on_worker(Arc::clone(&self.inner), f)
271    }
272
273    /// Executes a statement and returns the affected row count.
274    ///
275    /// Dropping the returned future after it has been polled does not cancel
276    /// the write; it still runs on the worker and only the result is
277    /// discarded (see the [`AsyncHandle`] cancellation notes).
278    pub fn execute<S: Into<String>, P>(
279        &self,
280        sql: S,
281        params: P,
282    ) -> impl Future<Output = Result<u64>> + Send + use<S, P>
283    where
284        P: Params + Send + 'static,
285    {
286        let sql = sql.into();
287        self.run(move |h| h.execute(&sql, params))
288    }
289
290    /// Queries exactly one row, returning `Error::NotFound` when no row exists.
291    pub fn query_one<S: Into<String>, T, P>(
292        &self,
293        sql: S,
294        params: P,
295    ) -> impl Future<Output = Result<T>> + Send + use<S, T, P>
296    where
297        T: FromRow + Send + 'static,
298        P: Params + Send + 'static,
299    {
300        let sql = sql.into();
301        self.run(move |h| h.query_one(&sql, params))
302    }
303
304    /// Queries zero or one row.
305    pub fn query_optional<S: Into<String>, T, P>(
306        &self,
307        sql: S,
308        params: P,
309    ) -> impl Future<Output = Result<Option<T>>> + Send + use<S, T, P>
310    where
311        T: FromRow + Send + 'static,
312        P: Params + Send + 'static,
313    {
314        let sql = sql.into();
315        self.run(move |h| h.query_optional(&sql, params))
316    }
317
318    /// Queries one scalar value.
319    pub fn query_scalar<S: Into<String>, T, P>(
320        &self,
321        sql: S,
322        params: P,
323    ) -> impl Future<Output = Result<T>> + Send + use<S, T, P>
324    where
325        T: FromSql + Send + 'static,
326        P: Params + Send + 'static,
327    {
328        let sql = sql.into();
329        self.run(move |h| h.query_scalar(&sql, params))
330    }
331
332    /// Queries zero or more rows.
333    pub fn query_all<S: Into<String>, T, P>(
334        &self,
335        sql: S,
336        params: P,
337    ) -> impl Future<Output = Result<Vec<T>>> + Send + use<S, T, P>
338    where
339        T: FromRow + Send + 'static,
340        P: Params + Send + 'static,
341    {
342        let sql = sql.into();
343        self.run(move |h| h.query_all(&sql, params))
344    }
345
346    /// Runs a transaction with a synchronous closure on a worker.
347    ///
348    /// The closure keeps the same checked-out connection from `BEGIN
349    /// IMMEDIATE` through commit or rollback. The closure cannot await.
350    ///
351    /// # Cancellation
352    ///
353    /// Dropping the returned future **before it is first polled** means the
354    /// transaction is never started. Dropping it **after** it has been polled
355    /// does not cancel the operation: the transaction still runs to completion
356    /// on the worker — including the commit (or rollback) — and only the
357    /// result is discarded. The transaction always terminates with a commit or
358    /// rollback; it is never left open.
359    pub fn transaction<R, F>(&self, f: F) -> impl Future<Output = Result<R>> + Send + use<R, F>
360    where
361        R: Send + 'static,
362        F: FnOnce(&mut roomrs_core::Tx<'_>) -> Result<R> + Send + 'static,
363    {
364        self.run(move |h| h.transaction(f))
365    }
366}
367
368// ─────────────────────── watch (live) ───────────────────────
369
370/// Live-query methods for [`AsyncHandle`].
371///
372/// `LiveQuery` is shared with the synchronous API. Prefer non-blocking
373/// `into_stream()` consumption. `recv` and `recv_timeout` block the executor
374/// thread and should not be called directly inside async tasks. Dropping the
375/// last database handle joins the notifier during shutdown.
376#[cfg(feature = "live")]
377impl AsyncHandle {
378    /// Creates a live query that returns zero or more rows.
379    pub fn watch_all<T: FromRow + Clone + Send + 'static>(
380        &self,
381        sql: &str,
382        params: &[&dyn rusqlite::ToSql],
383    ) -> roomrs_core::LiveQuery<Vec<T>> {
384        self.inner.__watch_all_dyn(sql, params)
385    }
386
387    /// Creates a live query that returns zero or one row.
388    pub fn watch_optional<T: FromRow + Clone + Send + 'static>(
389        &self,
390        sql: &str,
391        params: &[&dyn rusqlite::ToSql],
392    ) -> roomrs_core::LiveQuery<Option<T>> {
393        self.inner.__watch_optional_dyn(sql, params)
394    }
395
396    /// Creates a live query that returns one scalar value.
397    pub fn watch_scalar<T: rusqlite::types::FromSql + Clone + Send + 'static>(
398        &self,
399        sql: &str,
400        params: &[&dyn rusqlite::ToSql],
401    ) -> roomrs_core::LiveQuery<T> {
402        self.inner.__watch_scalar_dyn(sql, params)
403    }
404}
405
406/// Implements the watch context used by generated DAO code.
407#[cfg(feature = "live")]
408impl roomrs_core::WatchContext for AsyncHandle {
409    fn ctx_watch_all_named<T: FromRow + Clone + Send + 'static>(
410        &self,
411        sql: &'static str,
412        params: Result<Vec<(String, rusqlite::types::Value)>>,
413        tables: &[&str],
414    ) -> roomrs_core::LiveQuery<Vec<T>> {
415        self.inner.__watch_all_named(sql, params, tables)
416    }
417    fn ctx_watch_optional_named<T: FromRow + Clone + Send + 'static>(
418        &self,
419        sql: &'static str,
420        params: Result<Vec<(String, rusqlite::types::Value)>>,
421        tables: &[&str],
422    ) -> roomrs_core::LiveQuery<Option<T>> {
423        self.inner.__watch_optional_named(sql, params, tables)
424    }
425    fn ctx_watch_scalar_named<T: rusqlite::types::FromSql + Clone + Send + 'static>(
426        &self,
427        sql: &'static str,
428        params: Result<Vec<(String, rusqlite::types::Value)>>,
429        tables: &[&str],
430    ) -> roomrs_core::LiveQuery<T> {
431        self.inner.__watch_scalar_named(sql, params, tables)
432    }
433}
434
435// ─────────────────────── 쿼리빌더 실행 (명세 §5.3 [C-6]) ───────────────────────
436
437/// Provides boxed futures for generated async DAO code.
438impl roomrs_core::Execute for &AsyncHandle {
439    type Out<R: Send + 'static> =
440        std::pin::Pin<Box<dyn Future<Output = Result<R>> + Send + 'static>>;
441
442    fn run_all<T: FromRow + Send + 'static>(
443        self,
444        sql: String,
445        params: Vec<rusqlite::types::Value>,
446    ) -> Self::Out<Vec<T>> {
447        Box::pin(self.query_all(sql, roomrs_core::params_from_iter(params)))
448    }
449    fn run_optional<T: FromRow + Send + 'static>(
450        self,
451        sql: String,
452        params: Vec<rusqlite::types::Value>,
453    ) -> Self::Out<Option<T>> {
454        Box::pin(self.query_optional(sql, roomrs_core::params_from_iter(params)))
455    }
456    fn run_one<T: FromRow + Send + 'static>(
457        self,
458        sql: String,
459        params: Vec<rusqlite::types::Value>,
460    ) -> Self::Out<T> {
461        Box::pin(self.query_one(sql, roomrs_core::params_from_iter(params)))
462    }
463    fn run_scalar(self, sql: String, params: Vec<rusqlite::types::Value>) -> Self::Out<i64> {
464        Box::pin(self.query_scalar(sql, roomrs_core::params_from_iter(params)))
465    }
466    fn fail<R: Send + 'static>(e: Error) -> Self::Out<R> {
467        Box::pin(async move { Err(e) })
468    }
469}
470
471// ─────────────────────── build_async ───────────────────────
472
473/// Asynchronous build extensions for `DatabaseBuilder`.
474pub trait BuildAsyncExt<T> {
475    /// Opens the database on a blocking worker.
476    fn build_async(self) -> impl Future<Output = Result<T>> + Send;
477}
478
479impl<T> BuildAsyncExt<T> for roomrs_core::DatabaseBuilder<T>
480where
481    T: roomrs_core::DatabaseSpec + Send + 'static,
482{
483    /// Opens the database on a blocking worker.
484    async fn build_async(self) -> Result<T> {
485        let (tx, rx) = futures_channel::oneshot::channel::<Result<T>>();
486        offload::spawn(Box::new(move || {
487            let _ = tx.send(self.build());
488        }));
489        // 송신단 drop = 작업이 결과 없이 사라짐(패닉 또는 워커 풀 소멸) → Internal (H-5, L-11)
490        rx.await.map_err(|_| job_lost_error())?
491    }
492}