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

Commit 2d32338

Browse files
z23ccclaude
andcommitted
feat(file-lock): implement PID+TTL hybrid file locking [fn-110.2]
Replace fragile string-matching lock mechanism with PID-based crash detection + TTL fallback. acquire() now uses INSERT OR IGNORE with row count check, stores holder_pid and expires_at. cleanup_stale() removes locks from dead processes (via nix kill(pid,0)) and expired TTLs. Add heartbeat() method and `flowctl heartbeat --task` CLI command. Fix pre-existing private module access in lifecycle.rs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 783b80e commit 2d32338

9 files changed

Lines changed: 700 additions & 178 deletions

File tree

flowctl/Cargo.lock

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

flowctl/crates/flowctl-cli/src/commands/db_shim.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,10 @@ impl FileLockRepo {
322322
block_on(flowctl_db::FileLockRepo::new(self.0.clone()).check(file_path))
323323
}
324324

325+
pub fn heartbeat(&self, task_id: &str) -> Result<u64, DbError> {
326+
block_on(flowctl_db::FileLockRepo::new(self.0.clone()).heartbeat(task_id))
327+
}
328+
325329
/// List all active locks: (file_path, task_id, locked_at).
326330
pub fn list_all(&self) -> Result<Vec<(String, String, String)>, DbError> {
327331
let inner = self.0.clone();

flowctl/crates/flowctl-cli/src/commands/query.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -764,3 +764,24 @@ pub fn cmd_lock_check(json: bool, file: Option<String>) {
764764
}
765765
}
766766
}
767+
768+
pub fn cmd_heartbeat(json: bool, task: String) {
769+
let _flow_dir = ensure_flow_exists();
770+
let conn = require_db();
771+
let repo = crate::commands::db_shim::FileLockRepo::new(&conn);
772+
773+
match repo.heartbeat(&task) {
774+
Ok(count) => {
775+
if json {
776+
json_output(json!({
777+
"task": task,
778+
"extended": count,
779+
"message": format!("Extended TTL for {} lock(s)", count),
780+
}));
781+
} else {
782+
println!("Extended TTL for {} lock(s) for task {}", count, task);
783+
}
784+
}
785+
Err(e) => error_exit(&format!("Failed to heartbeat: {}", e)),
786+
}
787+
}

flowctl/crates/flowctl-cli/src/main.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,12 @@ enum Commands {
315315
#[arg(long)]
316316
all: bool,
317317
},
318+
/// Extend lock TTL for a task (Teams mode heartbeat).
319+
Heartbeat {
320+
/// Task ID to extend locks for.
321+
#[arg(long)]
322+
task: String,
323+
},
318324
/// Check file lock status (Teams mode).
319325
LockCheck {
320326
/// Specific file to check.
@@ -527,6 +533,7 @@ fn main() {
527533
Commands::Lock { task, files } => query::cmd_lock(json, task, files),
528534
Commands::Unlock { task, files, all } => query::cmd_unlock(json, task, files, all),
529535
Commands::LockCheck { file } => query::cmd_lock_check(json, file),
536+
Commands::Heartbeat { task } => query::cmd_heartbeat(json, task),
530537

531538
// Workflow
532539
Commands::Ready { epic } => workflow::cmd_ready(json, epic),

flowctl/crates/flowctl-db/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ serde = { workspace = true }
1515
serde_json = { workspace = true }
1616
thiserror = { workspace = true }
1717
chrono = { workspace = true }
18+
nix = { workspace = true }
1819
tracing = { workspace = true }
1920

2021
[lints]

flowctl/crates/flowctl-db/src/repo/file_lock.rs

Lines changed: 105 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
11
//! Async repository for runtime file locks (Teams mode concurrency).
2+
//!
3+
//! Uses PID-based crash detection + TTL fallback for hung processes.
4+
//! Stale locks (dead PID or expired TTL) are auto-cleaned on `acquire()`.
25
3-
use chrono::Utc;
6+
use chrono::{Duration, Utc};
47
use libsql::{params, Connection};
8+
use nix::sys::signal;
9+
use nix::unistd::Pid;
510

611
use crate::error::DbError;
712

13+
/// Default lock TTL: 45 minutes.
14+
const LOCK_TTL_MINUTES: i64 = 45;
15+
816
/// Async repository for runtime file locks. Load-bearing for Teams-mode
917
/// concurrency: `acquire` on an already-locked file returns
1018
/// `DbError::Constraint`.
@@ -17,38 +25,107 @@ impl FileLockRepo {
1725
Self { conn }
1826
}
1927

20-
/// Acquire a lock on a file for a task. Returns `DbError::Constraint`
21-
/// if the file is already locked by another task.
28+
/// Acquire a lock on a file for a task.
29+
///
30+
/// Calls `cleanup_stale()` first, then uses `INSERT OR IGNORE` with
31+
/// `holder_pid` and `expires_at`. If the row already exists (rows_affected=0),
32+
/// checks whether it belongs to this task (idempotent Ok) or another
33+
/// (returns `DbError::Constraint`).
2234
pub async fn acquire(&self, file_path: &str, task_id: &str) -> Result<(), DbError> {
23-
let res = self
35+
self.cleanup_stale().await?;
36+
37+
let pid = std::process::id();
38+
let expires_at = (Utc::now() + Duration::minutes(LOCK_TTL_MINUTES)).to_rfc3339();
39+
40+
let rows_affected = self
2441
.conn
2542
.execute(
26-
"INSERT INTO file_locks (file_path, task_id, locked_at) VALUES (?1, ?2, ?3)",
43+
"INSERT OR IGNORE INTO file_locks (file_path, task_id, locked_at, holder_pid, expires_at)
44+
VALUES (?1, ?2, ?3, ?4, ?5)",
2745
params![
2846
file_path.to_string(),
2947
task_id.to_string(),
3048
Utc::now().to_rfc3339(),
49+
pid as i64,
50+
expires_at,
3151
],
3252
)
33-
.await;
34-
35-
match res {
36-
Ok(_) => Ok(()),
37-
Err(e) => {
38-
let msg = e.to_string();
39-
let low = msg.to_lowercase();
40-
if low.contains("unique constraint")
41-
|| low.contains("constraint failed")
42-
|| low.contains("primary key")
43-
{
44-
Err(DbError::Constraint(format!(
45-
"file already locked: {file_path}"
46-
)))
47-
} else {
48-
Err(DbError::LibSql(e))
49-
}
53+
.await?;
54+
55+
if rows_affected > 0 {
56+
return Ok(());
57+
}
58+
59+
// Row already exists — check if it's our own lock (idempotent).
60+
let existing_task = self.check(file_path).await?;
61+
if existing_task.as_deref() == Some(task_id) {
62+
return Ok(());
63+
}
64+
65+
Err(DbError::Constraint(format!(
66+
"file already locked: {file_path}"
67+
)))
68+
}
69+
70+
/// Remove stale locks: dead PIDs (via `kill(pid, 0)`) and expired TTLs.
71+
pub async fn cleanup_stale(&self) -> Result<u64, DbError> {
72+
let mut total_cleaned = 0u64;
73+
74+
// 1. Delete expired locks (expires_at < now).
75+
let now = Utc::now().to_rfc3339();
76+
let expired = self
77+
.conn
78+
.execute(
79+
"DELETE FROM file_locks WHERE expires_at IS NOT NULL AND expires_at < ?1",
80+
params![now],
81+
)
82+
.await?;
83+
total_cleaned += expired;
84+
85+
// 2. Check PIDs of remaining locks — delete dead ones.
86+
let mut rows = self
87+
.conn
88+
.query(
89+
"SELECT file_path, holder_pid FROM file_locks WHERE holder_pid IS NOT NULL",
90+
(),
91+
)
92+
.await?;
93+
94+
let mut dead_files = Vec::new();
95+
while let Some(row) = rows.next().await? {
96+
let fp: String = row.get(0)?;
97+
let pid: i64 = row.get(1)?;
98+
99+
if !is_process_alive(pid as i32) {
100+
dead_files.push(fp);
50101
}
51102
}
103+
104+
for fp in &dead_files {
105+
let n = self
106+
.conn
107+
.execute(
108+
"DELETE FROM file_locks WHERE file_path = ?1",
109+
params![fp.clone()],
110+
)
111+
.await?;
112+
total_cleaned += n;
113+
}
114+
115+
Ok(total_cleaned)
116+
}
117+
118+
/// Extend `expires_at` for all locks held by a task (heartbeat).
119+
pub async fn heartbeat(&self, task_id: &str) -> Result<u64, DbError> {
120+
let new_expires = (Utc::now() + Duration::minutes(LOCK_TTL_MINUTES)).to_rfc3339();
121+
let n = self
122+
.conn
123+
.execute(
124+
"UPDATE file_locks SET expires_at = ?1 WHERE task_id = ?2",
125+
params![new_expires, task_id.to_string()],
126+
)
127+
.await?;
128+
Ok(n)
52129
}
53130

54131
/// Release locks held by a task. Returns number of rows deleted.
@@ -86,3 +163,9 @@ impl FileLockRepo {
86163
}
87164
}
88165
}
166+
167+
/// Check if a process is alive using `kill(pid, 0)`.
168+
fn is_process_alive(pid: i32) -> bool {
169+
// kill(pid, 0) returns Ok if process exists, Err(ESRCH) if not.
170+
signal::kill(Pid::from_raw(pid), None).is_ok()
171+
}

flowctl/crates/flowctl-db/src/repo/mod.rs

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,83 @@ mod tests {
501501
assert!(repo.check("src/c.rs").await.unwrap().is_none());
502502
}
503503

504+
#[tokio::test]
505+
async fn file_lock_idempotent_reacquire() {
506+
let (_db, conn) = open_memory_async().await.unwrap();
507+
let repo = FileLockRepo::new(conn.clone());
508+
509+
// Acquiring the same file for the same task twice should succeed (idempotent).
510+
repo.acquire("src/a.rs", "fn-1.1").await.unwrap();
511+
repo.acquire("src/a.rs", "fn-1.1").await.unwrap();
512+
assert_eq!(
513+
repo.check("src/a.rs").await.unwrap().as_deref(),
514+
Some("fn-1.1")
515+
);
516+
}
517+
518+
#[tokio::test]
519+
async fn file_lock_expired_cleanup() {
520+
let (_db, conn) = open_memory_async().await.unwrap();
521+
let repo = FileLockRepo::new(conn.clone());
522+
523+
// Insert a lock with an already-expired TTL directly.
524+
let past = (chrono::Utc::now() - chrono::Duration::minutes(1)).to_rfc3339();
525+
conn.execute(
526+
"INSERT INTO file_locks (file_path, task_id, locked_at, holder_pid, expires_at)
527+
VALUES ('src/expired.rs', 'fn-old', ?1, 99999, ?2)",
528+
libsql::params![past.clone(), past],
529+
)
530+
.await
531+
.unwrap();
532+
533+
// The expired lock should be visible before cleanup.
534+
assert!(repo.check("src/expired.rs").await.unwrap().is_some());
535+
536+
// cleanup_stale should remove it.
537+
let cleaned = repo.cleanup_stale().await.unwrap();
538+
assert!(cleaned >= 1);
539+
assert!(repo.check("src/expired.rs").await.unwrap().is_none());
540+
}
541+
542+
#[tokio::test]
543+
async fn file_lock_heartbeat_extends_ttl() {
544+
let (_db, conn) = open_memory_async().await.unwrap();
545+
let repo = FileLockRepo::new(conn.clone());
546+
547+
repo.acquire("src/a.rs", "fn-1.1").await.unwrap();
548+
repo.acquire("src/b.rs", "fn-1.1").await.unwrap();
549+
550+
let extended = repo.heartbeat("fn-1.1").await.unwrap();
551+
assert_eq!(extended, 2);
552+
553+
// Heartbeat on a non-existent task returns 0.
554+
let none = repo.heartbeat("fn-nonexistent").await.unwrap();
555+
assert_eq!(none, 0);
556+
}
557+
558+
#[tokio::test]
559+
async fn file_lock_acquire_cleans_expired_before_insert() {
560+
let (_db, conn) = open_memory_async().await.unwrap();
561+
let repo = FileLockRepo::new(conn.clone());
562+
563+
// Insert expired lock for a file.
564+
let past = (chrono::Utc::now() - chrono::Duration::minutes(1)).to_rfc3339();
565+
conn.execute(
566+
"INSERT INTO file_locks (file_path, task_id, locked_at, holder_pid, expires_at)
567+
VALUES ('src/a.rs', 'fn-old', ?1, 99999, ?2)",
568+
libsql::params![past.clone(), past],
569+
)
570+
.await
571+
.unwrap();
572+
573+
// Acquiring the same file should succeed because the old lock is expired.
574+
repo.acquire("src/a.rs", "fn-1.1").await.unwrap();
575+
assert_eq!(
576+
repo.check("src/a.rs").await.unwrap().as_deref(),
577+
Some("fn-1.1")
578+
);
579+
}
580+
504581
// ── PhaseProgressRepo ───────────────────────────────────────────
505582

506583
#[tokio::test]

flowctl/crates/flowctl-db/src/schema.sql

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,9 @@ CREATE TABLE IF NOT EXISTS runtime_state (
105105
CREATE TABLE IF NOT EXISTS file_locks (
106106
file_path TEXT PRIMARY KEY,
107107
task_id TEXT NOT NULL,
108-
locked_at TEXT NOT NULL
108+
locked_at TEXT NOT NULL,
109+
holder_pid INTEGER,
110+
expires_at TEXT
109111
);
110112

111113
CREATE TABLE IF NOT EXISTS heartbeats (

0 commit comments

Comments
 (0)