hammerwork 2.4.0

A high-performance, database-driven job queue for Rust with PostgreSQL and MySQL support, featuring job prioritization, cron scheduling, event streaming (Kafka/Kinesis/PubSub), webhooks, rate limiting, Prometheus metrics, and comprehensive monitoring
Documentation
# Webhooks

`WebhookManager` (module `hammerwork::webhooks`, feature `webhooks`, on by default)
delivers job lifecycle events to HTTP endpoints, with retries, authentication and
HMAC signatures. (Alert notifications are a separate system; see
[Monitoring & Alerting](monitoring.md).)

```rust
# async fn example() -> hammerwork::Result<()> {
use hammerwork::events::{EventFilter, EventManager, JobLifecycleEventType};
use hammerwork::webhooks::{WebhookConfig, WebhookManager};
use std::{sync::Arc, time::Duration};

let events = Arc::new(EventManager::new_default());
let webhooks = WebhookManager::new_default(events.clone());

let hook = WebhookConfig::new(
    "failures".to_string(),
    "https://hooks.example.com/hammerwork".to_string(),
)
.with_filter(EventFilter::new().with_event_types(vec![JobLifecycleEventType::Failed]))
.with_secret("signing-secret".to_string());
webhooks.add_webhook(hook).await?;

// ... on shutdown:
webhooks.shutdown(Duration::from_secs(10)).await;
# Ok(())
# }
```

Without a template, the request body is the `JobLifecycleEvent` as JSON. When a
`secret` is set, `X-Hammerwork-Signature: sha256=<hex>` is the HMAC-SHA256 of the body
that was actually sent (the rendered template, if one is configured).

## Job payloads

Events published by workers leave out the job payload unless a webhook asks for it:
set `EventFilter::include_payload` (`EventFilter::new().include_payload()`) on the
webhook's filter, or `EventConfig::include_payload_default` on the event manager to
include it for every subscriber. Payloads larger than
`EventConfig::max_payload_size_bytes` are dropped and noted in the event's
`payload_truncated` metadata. The same applies to event streams.

The payload of a job with payload encryption (an encryption config, or a queue that
encrypts its jobs) is never included: its events always have `payload: null`, so
neither its plaintext nor its stored ciphertext reaches webhooks or streams.

All subscribers of an event manager share one channel, so when one of them asks for
payloads the others receive events with the payload too. The webhook and stream
managers drop it for subscribers that did not ask; code that reads an
`EventSubscription` directly should do the same when
`EventManager::delivers_payload(&filter)` is false.

## Delivery

`add_webhook` subscribes before it returns, so an event published right after the
call is delivered. Each webhook has one listener, which starts a delivery (with
retries per its `RetryPolicy`) for every matching event.

- **Updating.** `update_webhook` swaps the configuration in place: the next event
  uses the new URL, filter, headers and so on, statistics are kept, and no event is
  missed or sent twice. Removing a webhook, or calling `add_webhook` again with the
  same id, stops its old listener immediately.
- **Timeout.** `timeout_secs` covers the whole attempt: connecting, sending, the
  response headers and the response body. When the status arrived in time but the
  body did not, the attempt counts by its status and the body is not recorded.
  `timeout_secs = 0` (or leaving it out of a configuration file) uses the manager's
  `default_timeout_secs` (30 s by default).
- **Response bodies.** At most `max_response_body_size` bytes are read (64 KiB by
  default); the rest of a longer body is not downloaded.
- **Concurrency.** `max_concurrent_deliveries` limits the requests in flight across
  all webhooks. A delivery waiting out a retry delay does not hold a slot.
- **Backlog limit.** At most `max_pending_deliveries` deliveries per webhook (1000 by
  default) are pending: waiting for a slot, in flight, or waiting to retry. When a
  webhook reaches the limit, for example because its endpoint is down, its listener
  stops taking events until a delivery finishes. Events keep arriving in the event
  manager's broadcast buffer (`EventConfig::max_buffer_size`); once that is full the
  oldest are dropped for this webhook, a warning is logged, and
  `WebhookStats::dropped_events` counts them. Memory use stays bounded however long
  the endpoint is down.

```rust
use hammerwork::webhooks::WebhookManagerConfig;

let config = WebhookManagerConfig {
    max_concurrent_deliveries: 50,
    default_timeout_secs: 10,
    max_pending_deliveries: 500,
    ..Default::default()
};
assert_eq!(config.max_response_body_size, 64 * 1024);
```

In `hammerwork.toml`, `[webhooks.global_settings]` accepts the same
`default_timeout_secs` and `max_pending_deliveries` keys; both are optional.

## Payload templates

Set `payload_template` (or call `WebhookConfig::with_payload_template`) to send the JSON
a receiver expects instead of the raw event. A template is a JSON document. JSON
strings in it may contain `{{ path }}` placeholders:

```rust
# use hammerwork::webhooks::WebhookConfig;
# fn example(slack_url: String) -> hammerwork::Result<()> {
let hook = WebhookConfig::new("slack".to_string(), slack_url)
    .with_payload_template(r#"{
        "text": "Job {{event.job_id}} on {{event.queue_name}} {{event.event_type}}: {{event.error.message}}",
        "metadata": {
            "job_id": "{{event.job_id}}",
            "attempt": "{{event.error.retry_attempt}}",
            "payload": "{{event.payload}}"
        }
    }"#);
# Ok(())
# }
```

Rendering rules:

- **A string that is exactly one placeholder** (`"{{event.payload}}"`) is replaced by the
  value itself and keeps its JSON type: object, array, number, boolean or `null`.
- **A placeholder inside other text** is replaced by the value's text. Strings are
  inserted without quotes, `null` becomes an empty string, and other values are
  inserted as compact JSON.
- **Event data cannot inject JSON.** Substitution works on the parsed template, never on
  its source text, and values are escaped when the body is serialized. A queue name
  such as `q", "admin": true` stays inside its string.
- Values that are absent from a particular event (such as `event.error.message` on a
  completed job) render as `null`.
- Placeholders are not allowed in object keys, and there is no escape for a literal `{{`.

### Available paths

| Path | Value |
|------|-------|
| `event` | the whole event (the default body) |
| `event.event_id`, `event.job_id` | UUID strings |
| `event.queue_name` | queue name |
| `event.event_type` | `enqueued`, `started`, `completed`, `failed`, `retried`, `dead`, `timed_out`, `cancelled`, `archived`, `restored` |
| `event.priority` | `Background`, `Low`, `Normal`, `High`, `Critical` |
| `event.timestamp` | RFC 3339 timestamp |
| `event.processing_time_ms` | number or `null` |
| `event.error` | error object or `null` |
| `event.error.message`, `.error_type`, `.details`, `.retry_attempt` | error fields |
| `event.payload` | job payload when events include it (see [Job payloads]#job-payloads), otherwise `null` |
| `event.payload.<key>.<key>...` | nested payload values. A numeric segment indexes an array (`event.payload.items.0`) |
| `event.metadata`, `event.metadata.<key>` | event metadata |

### Validation

Templates are validated by `WebhookConfig::validate()`, which runs in
`WebhookManager::add_webhook`, `update_webhook`, `WebhookManager::from_config` and
`HammerworkConfig::from_file`. Invalid JSON, an unknown path (`{{event.nope}}`,
`{{job.id}}`, `{{event.queue_name.x}}`), an unterminated `{{` or a placeholder in an
object key is a `HammerworkError::Config` error. The webhook is not registered, and a
failed `update_webhook` leaves the previous configuration in place.

`PayloadTemplate::parse` and `PayloadTemplate::render` are public, so you can check
or preview a template yourself.