Skip to content

Commit d413aad

Browse files
Fix review findings for SDK semantics
1 parent 1f5c0a5 commit d413aad

11 files changed

Lines changed: 281 additions & 76 deletions

File tree

README.md

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ This repository is intentionally independent from upstream while the Rust API is
1515
## Quick start
1616

1717
```rust
18-
use absurd_rust_sdk::{Client, Result, Task, WorkerOptions};
18+
use absurd_rust_sdk::{Client, Result, Task, WorkBatchOptions};
1919
use serde::{Deserialize, Serialize};
2020
use std::time::Duration;
2121

@@ -32,11 +32,11 @@ struct Output {
3232
#[tokio::main]
3333
async fn main() -> Result<()> {
3434
let client = Client::from_env_queue("default").await?;
35-
client.create_queue(None).await?;
35+
client.create_queue().await?;
3636

3737
let hello = Task::<Params, Output>::new("hello");
3838

39-
client.register(hello.clone(), |params, mut ctx| async move {
39+
client.register(&hello, |params, mut ctx| async move {
4040
let message: String = ctx
4141
.step("greet", || async move { Ok(format!("hello, {}", params.name)) })
4242
.await?;
@@ -52,13 +52,9 @@ async fn main() -> Result<()> {
5252
)
5353
.await?;
5454

55-
let worker = client.start_worker(
56-
WorkerOptions::new()
57-
.concurrency(4)
58-
.claim_timeout(Duration::from_secs(120)),
59-
);
60-
61-
worker.close().await?;
55+
client
56+
.work_batch(WorkBatchOptions::new().claim_timeout(Duration::from_secs(120)))
57+
.await?;
6258
Ok(())
6359
}
6460
```
@@ -86,6 +82,9 @@ Each step result is checkpointed in Postgres. If the task retries, completed ste
8682

8783
The SDK calls Absurd stored procedures directly:
8884

85+
- `absurd.create_queue`
86+
- `absurd.drop_queue`
87+
- `absurd.list_queues`
8988
- `absurd.spawn_task`
9089
- `absurd.claim_task`
9190
- `absurd.complete_run`
@@ -99,6 +98,7 @@ The SDK calls Absurd stored procedures directly:
9998
- `absurd.cancel_task`
10099
- `absurd.cleanup_tasks`
101100
- `absurd.cleanup_events`
101+
- `absurd.current_time` for unknown-task deferral
102102

103103
Retry, cancellation, idempotency, event wakeups, leases, and cleanup remain database-owned.
104104

@@ -113,6 +113,8 @@ Next hardening targets:
113113
- Optional spawn/execution middleware for tracing/header propagation.
114114
- Broader TLS/pool configuration.
115115

116+
Environment resolution uses `ABSURD_DATABASE_URL`, then `DATABASE_URL`, then `PGDATABASE`, then `postgresql://localhost/absurd`.
117+
116118
## License
117119

118120
Apache-2.0

examples/hello_world.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,11 @@ async fn main() -> Result<()> {
1717
tracing_subscriber::fmt::init();
1818

1919
let client = Client::from_env_queue("default").await?;
20-
client.create_queue(None).await?;
20+
client.create_queue().await?;
2121

2222
let hello = Task::<HelloParams, HelloResult>::new("hello-world");
2323

24-
client.register(hello.clone(), |params, mut ctx| async move {
24+
client.register(&hello, |params, mut ctx| async move {
2525
let greeting: String = ctx
2626
.step("build-greeting", || async move {
2727
Ok(format!("hello, {}", params.name))

examples/worker.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,11 @@ async fn main() -> Result<()> {
2424
tracing_subscriber::fmt::init();
2525

2626
let client = Client::from_env_queue("orders").await?;
27-
client.create_queue(None).await?;
27+
client.create_queue().await?;
2828

2929
let order = Task::<OrderParams, OrderResult>::new("order-fulfillment").queue("orders");
3030

31-
client.register(order, |params, mut ctx| async move {
31+
client.register(&order, |params, mut ctx| async move {
3232
let task_id = ctx.task_id();
3333
let order_id = params.order_id;
3434
let email = params.email;
@@ -62,7 +62,7 @@ async fn main() -> Result<()> {
6262
WorkerOptions::new()
6363
.concurrency(8)
6464
.claim_timeout(Duration::from_secs(120)),
65-
);
65+
)?;
6666

6767
tokio::signal::ctrl_c()
6868
.await

src/client.rs

Lines changed: 86 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ impl Client {
5757

5858
// Fail fast on bad configuration.
5959
let _ = pool.get().await?;
60-
Ok(Self::from_pool(pool, queue_name))
60+
Self::from_pool(pool, queue_name)
6161
}
6262

6363
pub async fn from_env() -> Result<Self> {
@@ -68,13 +68,15 @@ impl Client {
6868
Self::connect_queue(resolve_database_url(None), queue_name).await
6969
}
7070

71-
pub fn from_pool(pool: Pool, queue_name: impl Into<String>) -> Self {
72-
Self {
71+
pub fn from_pool(pool: Pool, queue_name: impl Into<String>) -> Result<Self> {
72+
let queue_name = queue_name.into();
73+
validate_queue_name(&queue_name)?;
74+
Ok(Self {
7375
pool,
74-
queue_name: queue_name.into(),
76+
queue_name,
7577
default_max_attempts: 5,
7678
registry: Arc::new(RwLock::new(HashMap::new())),
77-
}
79+
})
7880
}
7981

8082
pub fn queue_name(&self) -> &str {
@@ -86,7 +88,7 @@ impl Client {
8688
self
8789
}
8890

89-
pub fn register<P, R, F, Fut>(&self, task: Task<P, R>, handler: F) -> Result<()>
91+
pub fn register<P, R, F, Fut>(&self, task: &Task<P, R>, handler: F) -> Result<()>
9092
where
9193
P: DeserializeOwned + Send + 'static,
9294
R: Serialize + Send + 'static,
@@ -139,10 +141,14 @@ impl Client {
139141
handler: erased,
140142
};
141143

142-
self.registry
144+
let mut registry = self
145+
.registry
143146
.write()
144-
.map_err(|_| Error::Config("task registry lock poisoned".to_string()))?
145-
.insert(options.name, registered);
147+
.map_err(|_| Error::Config("task registry lock poisoned".to_string()))?;
148+
if registry.contains_key(&options.name) {
149+
return Err(Error::TaskAlreadyRegistered(options.name));
150+
}
151+
registry.insert(options.name, registered);
146152
Ok(())
147153
}
148154

@@ -155,7 +161,8 @@ impl Client {
155161
where
156162
P: Serialize,
157163
{
158-
self.spawn_named(task.name(), params, options).await
164+
self.spawn_with_task_options(task.options(), params, options)
165+
.await
159166
}
160167

161168
pub async fn spawn_named<P>(
@@ -167,12 +174,39 @@ impl Client {
167174
where
168175
P: Serialize,
169176
{
170-
let task_name = task_name.as_ref();
177+
self.spawn_resolved(task_name.as_ref(), None, params, options)
178+
.await
179+
}
180+
181+
async fn spawn_with_task_options<P>(
182+
&self,
183+
task_options: &TaskOptions,
184+
params: P,
185+
options: SpawnOptions,
186+
) -> Result<SpawnResult>
187+
where
188+
P: Serialize,
189+
{
190+
self.spawn_resolved(&task_options.name, Some(task_options), params, options)
191+
.await
192+
}
193+
194+
async fn spawn_resolved<P>(
195+
&self,
196+
task_name: &str,
197+
task_options: Option<&TaskOptions>,
198+
params: P,
199+
options: SpawnOptions,
200+
) -> Result<SpawnResult>
201+
where
202+
P: Serialize,
203+
{
171204
if task_name.trim().is_empty() {
172205
return Err(Error::Config("task name must be provided".to_string()));
173206
}
174207

175-
let (queue_name, max_attempts, cancellation) = self.resolve_spawn(task_name, &options)?;
208+
let (queue_name, max_attempts, cancellation) =
209+
self.resolve_spawn(task_name, task_options, &options)?;
176210
let params = serde_json::to_value(params)?;
177211
let options_json = options.to_sql_json(max_attempts, cancellation);
178212

@@ -194,8 +228,13 @@ impl Client {
194228
})
195229
}
196230

197-
pub async fn create_queue(&self, queue_name: Option<&str>) -> Result<()> {
198-
let queue_name = queue_name.unwrap_or(&self.queue_name);
231+
pub async fn create_queue(&self) -> Result<()> {
232+
let queue_name = self.queue_name.clone();
233+
self.create_queue_in(queue_name).await
234+
}
235+
236+
pub async fn create_queue_in(&self, queue_name: impl AsRef<str>) -> Result<()> {
237+
let queue_name = queue_name.as_ref();
199238
validate_queue_name(queue_name)?;
200239
let client = self.pool.get().await?;
201240
client
@@ -205,8 +244,13 @@ impl Client {
205244
Ok(())
206245
}
207246

208-
pub async fn drop_queue(&self, queue_name: Option<&str>) -> Result<()> {
209-
let queue_name = queue_name.unwrap_or(&self.queue_name);
247+
pub async fn drop_queue(&self) -> Result<()> {
248+
let queue_name = self.queue_name.clone();
249+
self.drop_queue_in(queue_name).await
250+
}
251+
252+
pub async fn drop_queue_in(&self, queue_name: impl AsRef<str>) -> Result<()> {
253+
let queue_name = queue_name.as_ref();
210254
validate_queue_name(queue_name)?;
211255
let client = self.pool.get().await?;
212256
client
@@ -304,7 +348,7 @@ impl Client {
304348

305349
pub async fn work_batch(&self, options: WorkBatchOptions) -> Result<usize> {
306350
let worker_id = options.worker_id_value();
307-
let claim_timeout_seconds = options.claim_timeout_seconds();
351+
let claim_timeout_seconds = options.claim_timeout_seconds()?;
308352
let batch_size = options.batch_size.max(1).min(i32::MAX as usize) as i32;
309353
let tasks = self
310354
.claim_tasks(&worker_id, claim_timeout_seconds, batch_size)
@@ -327,7 +371,7 @@ impl Client {
327371
Ok(count)
328372
}
329373

330-
pub fn start_worker(&self, options: WorkerOptions) -> Worker {
374+
pub fn start_worker(&self, options: WorkerOptions) -> Result<Worker> {
331375
Worker::start(
332376
self.pool.clone(),
333377
self.queue_name.clone(),
@@ -364,6 +408,7 @@ impl Client {
364408
fn resolve_spawn(
365409
&self,
366410
task_name: &str,
411+
task_options: Option<&TaskOptions>,
367412
options: &SpawnOptions,
368413
) -> Result<(String, i32, Option<CancellationPolicy>)> {
369414
let registration = self.get_registration(task_name)?;
@@ -377,19 +422,30 @@ impl Client {
377422
)));
378423
}
379424
}
425+
if let Some(task_queue) = task_options.and_then(|task| task.queue.as_deref()) {
426+
if task_queue != registration.queue_name {
427+
return Err(Error::Config(format!(
428+
"typed task {task_name:?} targets queue {task_queue:?}, but registered queue is {:?}",
429+
registration.queue_name
430+
)));
431+
}
432+
}
380433
registration.queue_name.clone()
434+
} else if let Some(queue) = options.queue.clone() {
435+
queue
436+
} else if let Some(queue) = task_options.and_then(|task| task.queue.clone()) {
437+
queue
381438
} else {
382-
options.queue.clone().ok_or_else(|| {
383-
Error::Config(format!(
384-
"task {task_name:?} is not registered; provide SpawnOptions::queue for unregistered tasks"
385-
))
386-
})?
439+
return Err(Error::Config(format!(
440+
"task {task_name:?} is not registered; provide SpawnOptions::queue or Task::queue for unregistered tasks"
441+
)));
387442
};
388443
validate_queue_name(&queue_name)?;
389444

390445
let max_attempts = options
391446
.max_attempts
392447
.or_else(|| registration.as_ref().and_then(|r| r.default_max_attempts))
448+
.or_else(|| task_options.and_then(|task| task.default_max_attempts))
393449
.unwrap_or(self.default_max_attempts);
394450
if max_attempts < 1 {
395451
return Err(Error::Config("max attempts must be at least 1".to_string()));
@@ -398,7 +454,8 @@ impl Client {
398454
let cancellation = options
399455
.cancellation
400456
.clone()
401-
.or_else(|| registration.and_then(|r| r.default_cancellation));
457+
.or_else(|| registration.and_then(|r| r.default_cancellation))
458+
.or_else(|| task_options.and_then(|task| task.default_cancellation.clone()));
402459

403460
Ok((queue_name, max_attempts, cancellation))
404461
}
@@ -448,6 +505,11 @@ fn resolve_database_url(explicit: Option<&str>) -> String {
448505
return url;
449506
}
450507
}
508+
if let Ok(url) = std::env::var("DATABASE_URL") {
509+
if !url.trim().is_empty() {
510+
return url;
511+
}
512+
}
451513
if let Ok(pgdatabase) = std::env::var("PGDATABASE") {
452514
if !pgdatabase.trim().is_empty() {
453515
if pgdatabase.contains("://") || pgdatabase.contains('=') {

src/context.rs

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use chrono::{DateTime, Utc};
44
use deadpool_postgres::Pool;
55
use serde::{de::DeserializeOwned, Serialize};
66
use serde_json::{Map, Value};
7-
use std::collections::HashMap;
7+
use std::collections::{HashMap, HashSet};
88
use std::future::Future;
99
use std::time::Duration;
1010
use tokio::sync::watch;
@@ -18,6 +18,7 @@ pub struct TaskContext {
1818
headers: Map<String, Value>,
1919
checkpoint_cache: HashMap<String, Value>,
2020
step_name_counter: HashMap<String, usize>,
21+
reported_timeouts: HashSet<String>,
2122
claim_timeout: Duration,
2223
claim_timeout_seconds: i32,
2324
lease_tx: Option<watch::Sender<Duration>>,
@@ -61,6 +62,7 @@ impl TaskContext {
6162
headers,
6263
checkpoint_cache,
6364
step_name_counter: HashMap::new(),
65+
reported_timeouts: HashSet::new(),
6466
claim_timeout,
6567
claim_timeout_seconds: duration_seconds_ceil(claim_timeout),
6668
lease_tx,
@@ -173,6 +175,16 @@ impl TaskContext {
173175
return Ok(serde_json::from_value(cached)?);
174176
}
175177

178+
if self.task.wake_event.as_deref() == Some(event_name) && self.task.event_payload.is_none()
179+
{
180+
self.task.wake_event = None;
181+
self.task.event_payload = None;
182+
self.reported_timeouts.insert(event_name.to_string());
183+
return Err(Error::EventTimeout {
184+
event: event_name.to_string(),
185+
});
186+
}
187+
176188
let timeout_seconds = options.timeout.map(duration_seconds_ceil);
177189
let client = self.pool.get().await?;
178190
let row = client
@@ -198,10 +210,17 @@ impl TaskContext {
198210
return Err(Error::Suspended);
199211
}
200212

201-
let Some(payload) = payload else {
202-
return Err(Error::EventTimeout {
203-
event: event_name.to_string(),
204-
});
213+
let payload = match payload {
214+
Some(payload) => {
215+
self.reported_timeouts.remove(event_name);
216+
payload
217+
}
218+
None if self.reported_timeouts.remove(event_name) => Value::Null,
219+
None => {
220+
return Err(Error::EventTimeout {
221+
event: event_name.to_string(),
222+
});
223+
}
205224
};
206225

207226
self.checkpoint_cache
@@ -325,6 +344,7 @@ impl TaskContext {
325344
}
326345

327346
#[derive(Clone, Debug, Default)]
347+
#[non_exhaustive]
328348
pub struct AwaitEventOptions {
329349
pub step_name: Option<String>,
330350
pub timeout: Option<Duration>,

0 commit comments

Comments
 (0)