A 3-node rehearsal (PostgreSQL + NATS + Garage) found two ways a crash leaves work stuck: Pool hangs. The PostgreSQL pool (deadpool) was built with no timeouts, so a request waited for a free connection, and for one to be opened or recycled, for as long as it took: forever when the server stopped answering. MySQL's pool (mysql_async) has no wait timeout at all. - PostgreSQL: wait 30 s (or the store's timeout if longer), create the store's timeout or 15 s (it bounds the whole handshake, where tokio-postgres's connect_timeout covers only the TCP connect), recycle 10 s. The pool config is now always set, not only with poolMaxConnections. - MySQL: every connection is taken through MysqlStore::conn(), which gives up after 30 s. - Both: TCP keepalive after 60 s idle, so a server that vanished without closing the connection is noticed in minutes rather than the two-hour system default. The DataStore schema has no pool timeout settings, so these are fixed defaults; the store's own timeout bounds connecting on PostgreSQL. Task locks. A task lock lasted an hour, so after a hard crash the dead node's tasks waited up to an hour and five minutes. The lock is now a five-minute lease: while this node runs a task, the task manager renews its lock every third of the lifetime (InMemoryStore::renew_lock, a compare-and-set on the store backends and SET XX EX on Redis, which leaves a lock that already expired alone). A killed node's tasks run elsewhere within about five minutes plus the claim recheck. A task this node holds isn't handed to a worker again by the scan. store::pool_timeout (new): a local listener that accepts connections and never answers plays a hung server; a PostgreSQL store with a 2 s timeout returns an error in about 4 s, and a MySQL store in 30 s. Without the timeouts both wait for good. store::task_locks gains a task held for 1.5 lock lifetimes: its lease is still held, and released when the task ends.
211 lines
6.9 KiB
Rust
211 lines
6.9 KiB
Rust
/*
|
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
|
*
|
|
* SPDX-License-Identifier: AGPL-3.0-only
|
|
*/
|
|
|
|
//! Task locks across nodes: tasks claimed by a node that then disappears
|
|
//! run elsewhere once its locks expire, and a node that stops gracefully
|
|
//! hands its locks back at once. The other node is played by writing its
|
|
//! locks straight into the shared in-memory store, as a node that claimed
|
|
//! the tasks and died leaves them.
|
|
|
|
use crate::utils::server::TestServerBuilder;
|
|
use common::{KV_LOCK_TASK, Server};
|
|
use registry::schema::{
|
|
enums::IndexDocumentType,
|
|
structs::{Task, TaskIndexDocument, TaskStatus},
|
|
};
|
|
use services::task_manager::lock::{TaskLockManager, release_task_locks};
|
|
use std::time::{Duration, Instant};
|
|
use store::{
|
|
ValueKey,
|
|
write::{BatchBuilder, TaskQueueClass, ValueClass},
|
|
};
|
|
use utils::snowflake::SnowflakeIdGenerator;
|
|
|
|
// Short enough for a test, long enough that the recheck interval (a twelfth
|
|
// of it) is well below it
|
|
const LOCK_EXPIRY: u64 = 12;
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
pub async fn task_lock_tests() {
|
|
let test = TestServerBuilder::new("task_lock_tests")
|
|
.await
|
|
.build()
|
|
.await;
|
|
let server = test.server.clone();
|
|
println!(
|
|
"Running task lock tests on {}...",
|
|
std::env::var("STORE").unwrap_or_default()
|
|
);
|
|
server.inner.ipc.task_locks.set_expiry(LOCK_EXPIRY);
|
|
|
|
// 1. Another node claimed the tasks and died. Its locks block them until
|
|
// they expire; then this node runs them, without waiting for anything
|
|
// else to wake it
|
|
let ids = new_task_ids(4);
|
|
for id in &ids {
|
|
assert!(foreign_lock(&server, *id, LOCK_EXPIRY).await);
|
|
}
|
|
schedule(&server, &ids).await;
|
|
let started = Instant::now();
|
|
server.notify_task_queue();
|
|
tokio::time::sleep(Duration::from_secs(3)).await;
|
|
assert_eq!(pending(&server, &ids).await, ids.len(), "held by the other node");
|
|
wait_until_done(&server, &ids, Duration::from_secs(LOCK_EXPIRY + 10)).await;
|
|
let elapsed = started.elapsed();
|
|
assert!(
|
|
elapsed >= Duration::from_secs(LOCK_EXPIRY - 2),
|
|
"ran before the other node's locks expired: {elapsed:?}"
|
|
);
|
|
|
|
// 2. The other node's locks outlive what this node expects: claimed just
|
|
// after this node looked, or by a node whose clock runs ahead. This node
|
|
// keeps checking at the recheck interval, so the tasks run soon after
|
|
// those locks expire, not a whole lock lifetime later
|
|
let held_for = LOCK_EXPIRY + LOCK_EXPIRY / 2;
|
|
let ids = new_task_ids(4);
|
|
for id in &ids {
|
|
assert!(foreign_lock(&server, *id, held_for).await);
|
|
}
|
|
schedule(&server, &ids).await;
|
|
let started = Instant::now();
|
|
server.notify_task_queue();
|
|
wait_until_done(&server, &ids, Duration::from_secs(held_for + 8)).await;
|
|
let elapsed = started.elapsed();
|
|
assert!(
|
|
elapsed >= Duration::from_secs(held_for - 2),
|
|
"ran before the other node's locks expired: {elapsed:?}"
|
|
);
|
|
|
|
// 3. A task that runs longer than a lock lifetime keeps its claim: the
|
|
// task manager renews the lease while this node holds it, and the claim
|
|
// ends when the task does. (Before, a lock simply lasted an hour.)
|
|
let [id] = new_task_ids(1)[..] else {
|
|
unreachable!()
|
|
};
|
|
assert!(server.try_lock_task(id).await, "claim {id}");
|
|
tokio::time::sleep(Duration::from_secs(LOCK_EXPIRY + LOCK_EXPIRY / 2)).await;
|
|
assert!(
|
|
!foreign_lock(&server, id, LOCK_EXPIRY).await,
|
|
"lease lapsed while the task ran"
|
|
);
|
|
server.remove_index_lock(id).await;
|
|
assert!(
|
|
foreign_lock(&server, id, LOCK_EXPIRY).await,
|
|
"released when the task ended"
|
|
);
|
|
let _ = server
|
|
.in_memory_store()
|
|
.remove_lock(KV_LOCK_TASK, &id.to_be_bytes())
|
|
.await;
|
|
assert!(
|
|
common::ipc::TaskLocks::DEFAULT_EXPIRY <= 5 * 60,
|
|
"a dead node's tasks wait no more than a few minutes"
|
|
);
|
|
|
|
// 4. A graceful stop releases the locks this node holds: another node
|
|
// can claim those tasks at once, and this one claims nothing more
|
|
let ids = new_task_ids(3);
|
|
for id in &ids {
|
|
assert!(server.try_lock_task(*id).await, "claim {id}");
|
|
}
|
|
assert_eq!(server.inner.ipc.task_locks.held(), ids.len());
|
|
for id in &ids {
|
|
assert!(
|
|
!foreign_lock(&server, *id, LOCK_EXPIRY).await,
|
|
"held while this node runs"
|
|
);
|
|
}
|
|
assert_eq!(release_task_locks(&server).await, ids.len());
|
|
assert_eq!(server.inner.ipc.task_locks.held(), 0);
|
|
for id in &ids {
|
|
assert!(
|
|
foreign_lock(&server, *id, LOCK_EXPIRY).await,
|
|
"released on stop: {id}"
|
|
);
|
|
}
|
|
let [id] = new_task_ids(1)[..] else {
|
|
unreachable!()
|
|
};
|
|
assert!(!server.try_lock_task(id).await, "a stopping node claims nothing");
|
|
|
|
for id in ids {
|
|
let _ = server
|
|
.in_memory_store()
|
|
.remove_lock(KV_LOCK_TASK, &id.to_be_bytes())
|
|
.await;
|
|
}
|
|
if test.is_reset() {
|
|
test.temp_dir.delete();
|
|
}
|
|
}
|
|
|
|
fn new_task_ids(count: usize) -> Vec<u64> {
|
|
(0..count)
|
|
.map(|_| SnowflakeIdGenerator::global_id().unwrap())
|
|
.collect()
|
|
}
|
|
|
|
/// The other node's claim on a task, as its task manager takes it.
|
|
async fn foreign_lock(server: &Server, id: u64, seconds: u64) -> bool {
|
|
server
|
|
.in_memory_store()
|
|
.try_lock(KV_LOCK_TASK, &id.to_be_bytes(), seconds)
|
|
.await
|
|
.unwrap()
|
|
}
|
|
|
|
/// Unindex tasks for files that don't exist: files aren't search-indexed and
|
|
/// there is no undelete note, so running one only drops it from the queue.
|
|
async fn schedule(server: &Server, ids: &[u64]) {
|
|
let mut batch = BatchBuilder::new();
|
|
for (n, id) in ids.iter().enumerate() {
|
|
batch.schedule_task_with_id(
|
|
*id,
|
|
Task::UnindexDocument(TaskIndexDocument {
|
|
account_id: 0u32.into(),
|
|
document_id: (u32::MAX - n as u32).into(),
|
|
document_type: IndexDocumentType::File,
|
|
status: TaskStatus::now(),
|
|
}),
|
|
);
|
|
}
|
|
server.store().write(batch.build_all()).await.unwrap();
|
|
}
|
|
|
|
async fn pending(server: &Server, ids: &[u64]) -> usize {
|
|
let mut count = 0;
|
|
for id in ids {
|
|
if server
|
|
.store()
|
|
.get_value::<Task>(ValueKey::from(ValueClass::TaskQueue(
|
|
TaskQueueClass::Task { id: *id },
|
|
)))
|
|
.await
|
|
.unwrap()
|
|
.is_some()
|
|
{
|
|
count += 1;
|
|
}
|
|
}
|
|
count
|
|
}
|
|
|
|
async fn wait_until_done(server: &Server, ids: &[u64], within: Duration) {
|
|
let started = Instant::now();
|
|
loop {
|
|
let left = pending(server, ids).await;
|
|
if left == 0 {
|
|
return;
|
|
}
|
|
assert!(
|
|
started.elapsed() < within,
|
|
"{left} task(s) still pending after {:?}",
|
|
started.elapsed()
|
|
);
|
|
tokio::time::sleep(Duration::from_millis(250)).await;
|
|
}
|
|
}
|