Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 65 additions & 2 deletions MIGRATION.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Migration Guide

- [Unreleased: owned scheduled tasks](#unreleased-owned-scheduled-tasks)
- [Unreleased: shared startup](#unreleased-shared-startup)
- [Unreleased: runtime type bounds](#unreleased-runtime-type-bounds)
- [Unreleased: HTTP response contracts](#unreleased-http-response-contracts)
Expand All @@ -14,6 +15,68 @@

---

# Unreleased: owned scheduled tasks

**Task registration no longer starts background work.** The application owns its scheduled tasks
and shuts them down together with HTTP serving.

## Registration and ownership

`TaskScheduler::cron` and `interval` now take `&mut self` and only register work. Existing application
hooks already receive a mutable scheduler. Update helper functions that accepted `&TaskScheduler`
to accept `&mut TaskScheduler`.

All registrations must succeed before serving starts any task. A registration error, panic, or
cancelled startup discards the pending tasks. Do not wait inside the registration hook for a
scheduled task to execute: it cannot start until the hook returns.

Standalone callers must explicitly start the scheduler and retain the returned owner:

```rust,ignore
let mut scheduler = TaskScheduler::new(context);
scheduler.interval("cleanup", Duration::from_secs(60), cleanup);
let running = scheduler.start();
// Keep `running` alive for as long as the tasks should run.
running.shutdown(Duration::from_secs(10)).await;
```

`RunningTasks::shutdown(timeout)` stops subsequent executions, waits for current executions, and
aborts and joins unfinished tasks at the deadline. Dropping the owner or cancelling its shutdown
future requests immediate abortion; dropping cannot perform an asynchronous join. The same ownership
includes the current execution, which is no longer a separate detached task.

## Application shutdown

Builder `run` / `listen` / `listen_on` and `GotchaApp::run` now wait for Ctrl-C or Unix SIGTERM by
default, then use one cancellation signal to stop HTTP acceptance and new scheduled executions.
In-flight HTTP requests drain while scheduled tasks finish. Override the signal with a future:

```rust,no_run
use gotcha::Gotcha;
let (stop, stopped) = tokio::sync::oneshot::channel::<()>();
let app = Gotcha::new().shutdown_signal(async move { let _ = stopped.await; });
// Resolve the future by sending through `stop`; `app.run().await` waits for shutdown to finish.
```

For `GotchaApp`, override `fn shutdown_signal(&self) -> impl Future<Output = ()> + Send` instead.
That future may borrow the application. A custom signal replaces the default OS signal handling.

Scheduled executions get a 30-second grace period by default. Configure it with the builder's
`.task_shutdown_timeout(duration)` or the trait's `fn task_shutdown_timeout(&self) -> Duration`.
The deadline starts when shutdown is requested, independently of HTTP draining. It does not limit
HTTP request duration. A serve error also stops and drains the scheduled tasks; cancelling the
serving future requests abortion of all scheduled work and signals HTTP shutdown.

Cancellation requires async tasks to yield. Synchronous blocking work and tasks explicitly detached
by application code (including `Messager::spawn`) remain outside this ownership. Public preparation,
application cleanup hooks, and HTTP drain limits remain follow-up work in #82.

Single-execution panics are logged and the schedule continues, including a panic while constructing
the execution future. Interval tasks retain their existing immediate first tick and non-overlapping
executions. Invalid cron expressions and zero intervals are logged and skipped during registration.

---

# Unreleased: shared startup

**Builder and trait applications now share configuration policy, address resolution, binding,
Expand Down Expand Up @@ -78,8 +141,8 @@ error releases the listener. The initialized context's server settings contain t
address, including a dynamically assigned port, so state initialization and handlers agree.

State initialization now runs while the port is reserved, before requests are accepted. Applications
that depended on initializing state before a bind attempt must account for the new order. Task
cancellation and waiting on shutdown are unchanged and tracked separately in #93.
that depended on initializing state before a bind attempt must account for the new order. See
[owned scheduled tasks](#unreleased-owned-scheduled-tasks) for task startup and shutdown behavior.

---

Expand Down
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,26 @@ Fallback applies only to startup configuration loading; eager `build_config()` a
`GotchaApp::config()` calls still return errors. See the [migration guide](MIGRATION.md#unreleased-shared-startup)
for behavior changes and the trait example.

### Shutdown

Both application APIs stop gracefully on Ctrl-C or Unix SIGTERM. HTTP stops accepting connections,
existing requests drain, and scheduled tasks stop starting new executions. To provide your own
shutdown signal:

```rust,no_run
use gotcha::Gotcha;

let (stop, stopped) = tokio::sync::oneshot::channel::<()>();
let app = Gotcha::new()
.shutdown_signal(async move { let _ = stopped.await; });
// Send through `stop` to request shutdown; await `app.run()` to wait for it to finish.
```

The trait API offers the same `shutdown_signal(&self)` hook. With the `task` feature, in-flight
scheduled executions get 30 seconds to finish before being aborted and joined. Override that period
with builder `.task_shutdown_timeout(duration)` or trait `task_shutdown_timeout(&self)`. This timeout
applies to scheduled tasks; HTTP requests drain independently. See the [migration guide](MIGRATION.md#unreleased-owned-scheduled-tasks).

### Advanced Trait API (For complex applications)

```rust,no_run
Expand Down Expand Up @@ -397,7 +417,10 @@ Configuration supports:

### Task Scheduling

Requires the `task` feature.
Requires the `task` feature. Registration collects work; execution starts after all application
initialization succeeds. The application retains ownership until shutdown completes. Standalone
users call `scheduler.start()`, retain the returned `RunningTasks`, and call
`running.shutdown(timeout).await` to stop and join their tasks. Dropping the owner aborts its tasks.

```rust,ignore
use gotcha::prelude::*;
Expand Down
13 changes: 7 additions & 6 deletions gotcha/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,18 @@ openapi = ["dep:oas", "dep:gotcha_core", "gotcha_core/axum"]
# dozen more, so a CORS-only application should not pay for the static file machinery.
cors = ["dep:tower-http", "tower-http/cors"]
static_files = ["dep:tower-http", "tower-http/fs"]
task = ["dep:cron", "tokio/time"]
task = ["dep:cron", "dep:futures-util", "tokio/time"]


[dependencies]
async-trait = "0.1.60"
gotcha_macro = { version = "0.4.1", path = "../gotcha_macro" }
gotcha_core = { version = "0.4.1", path = "../gotcha_core", optional = true }
serde = {version = "1", features = ["derive"]}
# `rt` is needed by the message system, which is always available (it gated nothing but a
# 160-line module and this one tokio feature).
tokio = {version = "1", features = ["rt"]}
# `rt` also supports the always-available message system; macros and signals drive server shutdown.
tokio = {version = "1", features = ["rt", "macros", "signal"]}
tokio-util = { version = "0.7", features = ["rt"] }
futures-util = { version = "0.3", optional = true, default-features = false, features = ["std", "async-await"] }
tracing = "0.1"
tracing-subscriber = {version="0.3", features=["env-filter"]}
log = "0.4"
Expand Down Expand Up @@ -78,8 +79,8 @@ assert-json-diff = "2"
# Non-optional so trybuild-generated test crates (which derive Schematic and thus
# emit `::gotcha_core::` paths) have gotcha_core in their extern prelude.
gotcha_core = { version = "0.4.1", path = "../gotcha_core" }
# So `#[tokio::main]` doc examples compile (the lib's own tokio dep is minimal).
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
# Multiple-worker runtimes, paused-clock task tests, and socket-based lifecycle checks.
tokio = { version = "1", features = ["macros", "rt-multi-thread", "test-util", "io-util"] }
futures-core = "0.3.33"
futures-util = "0.3.33"
tower = "0.5.3"
47 changes: 42 additions & 5 deletions gotcha/src/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,13 @@ where
router: GotchaRouter<GotchaContext<S, C>>,
listen_options: ListenOptions,
config_error_policy: ConfigErrorPolicy<C>,
shutdown_signal: Option<std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>>>,
state: StateSource<S>,
config: Configuration<C>,
#[cfg(feature = "task")]
tasks: Vec<TaskRegistrar<S, C>>,
#[cfg(feature = "task")]
task_shutdown_timeout: std::time::Duration,
}

impl Default for Gotcha<EmptyState, EmptyConfig> {
Expand Down Expand Up @@ -267,10 +270,13 @@ where
router: GotchaRouter::default(),
listen_options: ListenOptions::default(),
config_error_policy: ConfigErrorPolicy::Strict,
shutdown_signal: None,
state,
config,
#[cfg(feature = "task")]
tasks: Vec::new(),
#[cfg(feature = "task")]
task_shutdown_timeout: crate::task::DEFAULT_SHUTDOWN_TIMEOUT,
}
}

Expand Down Expand Up @@ -715,7 +721,8 @@ where
///
/// The closure receives a [`TaskScheduler`](crate::TaskScheduler) when the
/// server starts, on which you can register `cron` / `interval` jobs. This
/// brings the builder to parity with `GotchaApp::tasks`.
/// brings the builder to parity with `GotchaApp::tasks`. Jobs start only after all
/// registrations succeed and are owned by the application until shutdown.
/// Register module tasks here on the top-level application; `nest` and `merge` accept
/// only route modules and cannot accept another application's task registrations.
///
Expand All @@ -739,6 +746,24 @@ where
self
}

/// Replace the default Ctrl-C / Unix SIGTERM signal. When this future resolves, HTTP
/// stops accepting connections and scheduled tasks stop starting new executions.
pub fn shutdown_signal<F>(mut self, signal: F) -> Self
where
F: std::future::Future<Output = ()> + Send + 'static,
{
self.shutdown_signal = Some(Box::pin(signal));
self
}

/// Set the grace period for in-flight scheduled executions (default: 30 seconds).
/// Remaining tasks are aborted and joined at the deadline. HTTP draining has no time limit here.
#[cfg(feature = "task")]
pub fn task_shutdown_timeout(mut self, timeout: std::time::Duration) -> Self {
self.task_shutdown_timeout = timeout;
self
}

/// Add CORS support (requires "cors" feature)
#[cfg(feature = "cors")]
pub fn with_cors(self) -> Self {
Expand Down Expand Up @@ -806,6 +831,18 @@ where
std::mem::take(&mut self.config_error_policy)
}

async fn shutdown_signal(&mut self) {
match self.shutdown_signal.take() {
Some(signal) => signal.await,
None => startup::shutdown_signal().await,
}
}

#[cfg(feature = "task")]
fn task_shutdown_timeout(&self) -> std::time::Duration {
self.task_shutdown_timeout
}

async fn config(&mut self) -> GotchaResult<ConfigWrapper<C>> {
self.config.resolve()
}
Expand Down Expand Up @@ -865,8 +902,8 @@ mod tests {
panic!("state was already initialized");
}
}
let app = Gotcha::with_state::<Initialized>().state(Initialized).config(ConfigWrapper::default());
startup::prepare(app, Some("127.0.0.1:0".parse().unwrap())).await.unwrap();
let mut app = Gotcha::with_state::<Initialized>().state(Initialized).config(ConfigWrapper::default());
startup::prepare(&mut app, Some("127.0.0.1:0".parse().unwrap())).await.unwrap();
}

#[tokio::test]
Expand All @@ -892,7 +929,7 @@ mod tests {
}

let calls = Arc::new(AtomicUsize::new(41));
let app = Gotcha::from_context(GotchaContext {
let mut app = Gotcha::from_context(GotchaContext {
state: RuntimeState(calls.clone()),
config: ConfigWrapper {
app: RuntimeConfig { value: "parent".into() },
Expand All @@ -902,7 +939,7 @@ mod tests {
.with_file_config("unused-required-file.toml")
.nest("/api", GotchaRouter::default().get("/context", read))
.merge(GotchaRouter::default().get("/context", read));
let prepared = startup::prepare(app, Some("127.0.0.1:0".parse().unwrap())).await.unwrap();
let prepared = startup::prepare(&mut app, Some("127.0.0.1:0".parse().unwrap())).await.unwrap();
let router = prepared.router;

for (path, expected) in [("/api/context", "parent:41"), ("/context", "parent:42")] {
Expand Down
20 changes: 18 additions & 2 deletions gotcha/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ pub use crate::openapi::schematic::EnhancedSchema;
pub use serde_json;
#[cfg(feature = "task")]
#[cfg_attr(docsrs, doc(cfg(feature = "task")))]
pub use task::TaskScheduler;
pub use task::{RunningTasks, TaskScheduler};
#[cfg(feature = "static_files")]
#[cfg_attr(docsrs, doc(cfg(feature = "static_files")))]
pub use tower_http::services::{ServeDir, ServeFile};
Expand Down Expand Up @@ -184,6 +184,21 @@ pub trait GotchaApp: Sized + Send + Sync {
ConfigErrorPolicy::Strict
}

/// Resolve to stop accepting HTTP connections and scheduling new task executions.
/// The default waits for Ctrl-C or, on Unix, SIGTERM. Existing HTTP requests drain;
/// scheduled executions wait for at most `task_shutdown_timeout()` (with the `task` feature).
fn shutdown_signal(&self) -> impl std::future::Future<Output = ()> + Send {
crate::startup::shutdown_signal()
}

/// How long to wait for in-flight scheduled executions after shutdown is requested.
/// Defaults to 30 seconds. At the deadline, remaining scheduled tasks are aborted and joined.
/// This does not limit HTTP request draining.
#[cfg(feature = "task")]
fn task_shutdown_timeout(&self) -> std::time::Duration {
crate::task::DEFAULT_SHUTDOWN_TIMEOUT
}

/// Install the tracing subscriber. The default reads `RUST_LOG`.
fn logger(&self) -> GotchaResult<()> {
tracing_subscriber::registry()
Expand All @@ -206,7 +221,8 @@ pub trait GotchaApp: Sized + Send + Sync {
fn state(&self, config: &ConfigWrapper<Self::Config>) -> impl std::future::Future<Output = GotchaResult<Self::State>> + Send;

#[cfg(feature = "task")]
/// Register background tasks. The default registers none.
/// Register background tasks without starting them. Returning an error discards all registrations.
/// Tasks start with serving after successful initialization. The default registers none.
fn tasks(&self, _task_scheduler: &mut TaskScheduler<Self::State, Self::Config>) -> impl std::future::Future<Output = GotchaResult<()>> + Send {
async { Ok(()) }
}
Expand Down
2 changes: 1 addition & 1 deletion gotcha/src/prelude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ pub use crate::{api, Responsible, Schematic};
pub use crate::layers::CorsLayer;

#[cfg(feature = "task")]
pub use crate::TaskScheduler;
pub use crate::{RunningTasks, TaskScheduler};

// Utility macros for common patterns
#[macro_export]
Expand Down
Loading
Loading