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}