Skip to content
This repository was archived by the owner on Apr 11, 2026. It is now read-only.

Commit f96ba73

Browse files
z23ccclaude
andcommitted
fix: DB connection pooling + sync.rs cleanup
P0 fixes from design review: 1. DB connection pooling: DaemonState now holds Mutex<Connection> instead of opening a new connection per HTTP request. All handlers use state.db.lock() for shared access. Connection created once in create_state() helper. 2. sync.rs cleanup: Removed all re-exports of dual-write functions (write_epic, write_task, check_staleness, etc). Module kept as #[allow(dead_code)] for reference. Updated lib.rs docs to reflect SQLite-as-single-source-of-truth architecture. Deferred to next session: - P0: Leptos SSR integration into daemon (needs cargo-leptos config) - P1: Split admin.rs (1700 lines) and task.rs (1200 lines) - P2: Add daemon/MCP/export test coverage 0 clippy warnings, 224/224 tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ae1ceaa commit f96ba73

4 files changed

Lines changed: 48 additions & 103 deletions

File tree

flowctl/crates/flowctl-daemon/src/handlers.rs

Lines changed: 22 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,11 @@ use crate::lifecycle::DaemonRuntime;
2020
/// Shared application state for all handlers.
2121
pub type AppState = Arc<DaemonState>;
2222

23-
/// Combined daemon state: runtime + event bus.
23+
/// Combined daemon state: runtime + event bus + shared DB connection.
2424
pub struct DaemonState {
2525
pub runtime: DaemonRuntime,
2626
pub event_bus: flowctl_scheduler::EventBus,
27+
pub db: std::sync::Mutex<rusqlite::Connection>,
2728
}
2829

2930
/// GET /api/v1/health -- simple liveness check.
@@ -68,35 +69,11 @@ pub async fn status_handler(State(state): State<AppState>) -> impl IntoResponse
6869
///
6970
/// Returns a JSON array of epics. On database errors, returns 500.
7071
pub async fn epics_handler(State(state): State<AppState>) -> impl IntoResponse {
71-
let db_path = state
72-
.runtime
73-
.paths
74-
.state_dir
75-
.parent()
76-
.map(|flow_dir| flow_dir.join("flowctl.db"));
77-
78-
let Some(db_path) = db_path else {
79-
return (
80-
StatusCode::INTERNAL_SERVER_ERROR,
81-
Json(serde_json::json!({"error": "cannot resolve db path"})),
82-
);
83-
};
84-
85-
match flowctl_db::open(&db_path) {
86-
Ok(conn) => {
87-
let repo = flowctl_db::EpicRepo::new(&conn);
88-
match repo.list(None) {
89-
Ok(epics) => (StatusCode::OK, Json(serde_json::to_value(&epics).unwrap())),
90-
Err(e) => (
91-
StatusCode::INTERNAL_SERVER_ERROR,
92-
Json(serde_json::json!({"error": e.to_string()})),
93-
),
94-
}
95-
}
96-
Err(e) => (
97-
StatusCode::INTERNAL_SERVER_ERROR,
98-
Json(serde_json::json!({"error": format!("db open failed: {e}")})),
99-
),
72+
let conn = state.db.lock().unwrap();
73+
let repo = flowctl_db::EpicRepo::new(&conn);
74+
match repo.list(None) {
75+
Ok(epics) => (StatusCode::OK, Json(serde_json::to_value(&epics).unwrap())),
76+
Err(e) => db_error(&e.to_string()),
10077
}
10178
}
10279

@@ -105,40 +82,16 @@ pub async fn tasks_handler(
10582
State(state): State<AppState>,
10683
axum::extract::Query(params): axum::extract::Query<TasksQuery>,
10784
) -> impl IntoResponse {
108-
let db_path = state
109-
.runtime
110-
.paths
111-
.state_dir
112-
.parent()
113-
.map(|flow_dir| flow_dir.join("flowctl.db"));
114-
115-
let Some(db_path) = db_path else {
116-
return (
117-
StatusCode::INTERNAL_SERVER_ERROR,
118-
Json(serde_json::json!({"error": "cannot resolve db path"})),
119-
);
85+
let conn = state.db.lock().unwrap();
86+
let repo = flowctl_db::TaskRepo::new(&conn);
87+
let result = if let Some(ref epic_id) = params.epic_id {
88+
repo.list_by_epic(epic_id)
89+
} else {
90+
repo.list_all(None, None)
12091
};
121-
122-
match flowctl_db::open(&db_path) {
123-
Ok(conn) => {
124-
let repo = flowctl_db::TaskRepo::new(&conn);
125-
let result = if let Some(ref epic_id) = params.epic_id {
126-
repo.list_by_epic(epic_id)
127-
} else {
128-
repo.list_all(None, None)
129-
};
130-
match result {
131-
Ok(tasks) => (StatusCode::OK, Json(serde_json::to_value(&tasks).unwrap())),
132-
Err(e) => (
133-
StatusCode::INTERNAL_SERVER_ERROR,
134-
Json(serde_json::json!({"error": e.to_string()})),
135-
),
136-
}
137-
}
138-
Err(e) => (
139-
StatusCode::INTERNAL_SERVER_ERROR,
140-
Json(serde_json::json!({"error": format!("db open failed: {e}")})),
141-
),
92+
match result {
93+
Ok(tasks) => (StatusCode::OK, Json(serde_json::to_value(&tasks).unwrap())),
94+
Err(e) => db_error(&e.to_string()),
14295
}
14396
}
14497

@@ -155,10 +108,7 @@ pub async fn create_task_handler(
155108
State(state): State<AppState>,
156109
Json(body): Json<CreateTaskRequest>,
157110
) -> impl IntoResponse {
158-
let conn = match open_db(&state) {
159-
Ok(c) => c,
160-
Err(resp) => return resp,
161-
};
111+
let conn = state.db.lock().unwrap();
162112
let task = flowctl_core::types::Task {
163113
schema_version: 1,
164114
id: body.id.clone(),
@@ -188,10 +138,7 @@ pub async fn start_task_handler(
188138
State(state): State<AppState>,
189139
Json(body): Json<TaskIdRequest>,
190140
) -> impl IntoResponse {
191-
let conn = match open_db(&state) {
192-
Ok(c) => c,
193-
Err(resp) => return resp,
194-
};
141+
let conn = state.db.lock().unwrap();
195142
let repo = flowctl_db::TaskRepo::new(&conn);
196143
match repo.update_status(&body.task_id, flowctl_core::state_machine::Status::InProgress) {
197144
Ok(()) => (StatusCode::OK, Json(serde_json::json!({"success": true, "id": body.task_id}))),
@@ -204,10 +151,7 @@ pub async fn done_task_handler(
204151
State(state): State<AppState>,
205152
Json(body): Json<TaskIdRequest>,
206153
) -> impl IntoResponse {
207-
let conn = match open_db(&state) {
208-
Ok(c) => c,
209-
Err(resp) => return resp,
210-
};
154+
let conn = state.db.lock().unwrap();
211155
let repo = flowctl_db::TaskRepo::new(&conn);
212156
match repo.update_status(&body.task_id, flowctl_core::state_machine::Status::Done) {
213157
Ok(()) => (StatusCode::OK, Json(serde_json::json!({"success": true, "id": body.task_id}))),
@@ -229,16 +173,9 @@ pub struct TaskIdRequest {
229173
pub task_id: String,
230174
}
231175

232-
/// Helper: open a DB connection from daemon state.
233-
fn open_db(state: &DaemonState) -> Result<rusqlite::Connection, (StatusCode, Json<serde_json::Value>)> {
234-
let db_path = state.runtime.paths.state_dir.parent()
235-
.map(|flow_dir| flow_dir.join("flowctl.db"));
236-
let Some(db_path) = db_path else {
237-
return Err((StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "cannot resolve db path"}))));
238-
};
239-
flowctl_db::open(&db_path).map_err(|e| {
240-
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": format!("db open failed: {e}")})))
241-
})
176+
/// Helper: acquire DB lock from shared state.
177+
fn db_error(msg: &str) -> (StatusCode, Json<serde_json::Value>) {
178+
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": msg})))
242179
}
243180

244181
/// GET /api/v1/events -- WebSocket upgrade for live event streaming.

flowctl/crates/flowctl-daemon/src/server.rs

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,22 @@ use crate::handlers::{
1717
};
1818
use crate::lifecycle::{set_socket_permissions, DaemonRuntime};
1919

20+
/// Create shared app state with a DB connection.
21+
fn create_state(runtime: DaemonRuntime, event_bus: flowctl_scheduler::EventBus) -> Result<(AppState, tokio_util::sync::CancellationToken)> {
22+
let db_path = runtime.paths.state_dir.parent()
23+
.map(|flow_dir| flow_dir.join("flowctl.db"))
24+
.context("cannot resolve db path")?;
25+
let conn = flowctl_db::open(&db_path)
26+
.with_context(|| format!("failed to open db: {}", db_path.display()))?;
27+
let cancel = runtime.cancel.clone();
28+
let state = Arc::new(DaemonState {
29+
runtime,
30+
event_bus,
31+
db: std::sync::Mutex::new(conn),
32+
});
33+
Ok((state, cancel))
34+
}
35+
2036
/// Build the Axum router with all daemon API routes.
2137
fn build_router(state: AppState) -> axum::Router {
2238
let cors = CorsLayer::new()
@@ -54,12 +70,7 @@ pub async fn serve(runtime: DaemonRuntime, event_bus: flowctl_scheduler::EventBu
5470

5571
info!("daemon API listening on {}", socket_path.display());
5672

57-
let cancel = runtime.cancel.clone();
58-
59-
let state: AppState = Arc::new(DaemonState {
60-
runtime,
61-
event_bus,
62-
});
73+
let (state, cancel) = create_state(runtime, event_bus)?;
6374

6475
let router = build_router(state);
6576

@@ -87,12 +98,7 @@ pub async fn serve_tcp(
8798

8899
info!("daemon API listening on http://{addr}");
89100

90-
let cancel = runtime.cancel.clone();
91-
92-
let state: AppState = Arc::new(DaemonState {
93-
runtime,
94-
event_bus,
95-
});
101+
let (state, cancel) = create_state(runtime, event_bus)?;
96102

97103
let router = build_router(state);
98104

@@ -119,6 +125,8 @@ mod tests {
119125
let flow_dir = tmp.path().join(".flow");
120126
let paths = DaemonPaths::new(&flow_dir);
121127
paths.ensure_state_dir().unwrap();
128+
// Create DB so create_state() works.
129+
let _ = flowctl_db::open(&flow_dir);
122130
let runtime = DaemonRuntime::new(paths);
123131
let (event_bus, _critical_rx) = flowctl_scheduler::EventBus::with_default_capacity();
124132
(tmp, runtime, event_bus)

flowctl/crates/flowctl-db/src/lib.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55
//!
66
//! # Architecture
77
//!
8-
//! - **Markdown is canonical, SQLite is cache.** The `flowctl reindex`
9-
//! command can fully rebuild the indexed tables from Markdown frontmatter.
10-
//! Runtime-only data (locks, heartbeats, events, metrics) is not recoverable.
8+
//! - **SQLite is the single source of truth.** All reads and writes go through
9+
//! the repository layer. Markdown files are an export format (`flowctl export`).
10+
//! `flowctl import` (reindex) rebuilds the DB from Markdown for migration.
1111
//!
1212
//! - **PRAGMAs are per-connection**, not in migration files. WAL mode,
1313
//! busy_timeout, and foreign_keys are set on every connection open.
@@ -22,7 +22,8 @@ pub mod metrics;
2222
pub mod migration;
2323
pub mod pool;
2424
pub mod repo;
25-
pub mod sync;
25+
#[allow(dead_code)]
26+
mod sync; // Legacy dual-write module, kept for backward compatibility but not re-exported.
2627

2728
pub use error::DbError;
2829
pub use pool::{cleanup, open, open_memory, resolve_db_path, resolve_state_dir};
@@ -31,6 +32,5 @@ pub use migration::{migrate_runtime_state, needs_reindex, has_legacy_state, Migr
3132
pub use repo::{EpicRepo, EvidenceRepo, EventRepo, EventRow, FileLockRepo, PhaseProgressRepo, RuntimeRepo, TaskRepo};
3233
pub use events::{EventLog, TokenRecord};
3334
pub use metrics::StatsQuery;
34-
pub use sync::{write_epic, write_task, write_task_with_legacy, check_staleness, refresh_if_stale, retry_pending, SyncStatus};
3535

3636
pub use flowctl_core;

flowctl/crates/flowctl-db/src/sync.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
//! Bidirectional Markdown-SQLite sync.
1+
//! Legacy bidirectional Markdown-SQLite sync (deprecated).
22
//!
33
//! **Invariant**: SQLite is updated first (in a transaction), then Markdown
44
//! frontmatter is written. If the Markdown write fails after SQLite commit,

0 commit comments

Comments
 (0)