Storage & background tasks
This page covers four subsystems that share one trait: they all talk to something outside your process.
- File storage — one API over the local filesystem, S3, GCS and Azure.
- Background tasks, plus the jobs and webhooks a module declares for itself — recurring work and inbound callbacks.
- Transactional outbox — events that become durable exactly when the transaction producing them commits.
- Mail — SMTP, and vendor providers that install as plugins.
Because any of them can fail independently, storage and mail calls are wrapped in circuit breakers by default.
File storage (pkg/storage)
pkg/storage is a provider-agnostic file storage abstraction, with an
interface designed to last through v1.x. The same code runs against:
- the local filesystem,
- AWS S3,
- Google Cloud Storage,
- Azure Blob Storage.
import "github.com/jcsvwinston/nucleus/pkg/storage"
// Get returns a ReadCloser and object metadata; always close the reader.
reader, info, err := a.Storage.Get(ctx, "uploads/avatar.png")
// SignedURL requires an opts argument (use zero value for defaults).
url, err := a.Storage.SignedURL(ctx, "uploads/avatar.png", 5*time.Minute, storage.URLConfig{})
// Put returns the stored ObjectInfo and an error.
info, err = a.Storage.Put(ctx, "uploads/avatar.png", body, storage.PutOptions{
ContentType: "image/png",
})
_ = reader
_ = info
Configure the backend in nucleus.yml. The exact key shape is provider-
specific — the snippet below is illustrative; the canonical schema is the
Configuration reference:
# illustrative — see the Configuration reference for the full storage.* schema
storage:
provider: s3 # local | s3 | gcs | azure
s3:
bucket: my-bucket
region: eu-west-1
Per-driver credentials and endpoints are read from environment variables or platform credential providers — never embedded in the config file.
Circuit breaker (storage)
Using a storage backend Nucleus does not ship
The four built-in providers — local, S3, GCS, Azure — are registered the same way anyone else registers one, so a backend this framework has never heard of is selectable by name without patching it:
package cephstore
func init() {
storage.RegisterProvider("ceph", New)
}
Import that package for its side effects, set storage.provider: ceph, and
the framework builds it. Everything it layers on top — the circuit breaker,
tenant prefixing, the public-URL mapper — is applied around whatever your
factory returns, so a provider never reimplements any of it.
Registering a name that is already taken is an error rather than a silent
replacement: two packages claiming s3 would otherwise make the effective
backend depend on import order.
An unconfigured or unknown provider name now fails with the list of registered ones. It used to fall through to the local filesystem, so a typo wrote your uploads to disk and said nothing.
App.New automatically wraps all remote provider operations
(Put, Get, Delete, Exists, List, Copy, SignedURL) with a
pkg/circuit.Breaker. The local provider and PublicURL (pure string
composition) are never wrapped. storage.ErrNotFound is not counted as
a failure — a missing object is a normal outcome.
When the breaker is open, wrapped operations return circuit.ErrOpen
immediately. The default thresholds are:
storage:
circuit_breaker:
enabled: true
failure_threshold: 5
cooldown: 30s
half_open_max_concurrent: 1
Set enabled: false to disable, or tune the thresholds for your
workload. Full details: docs/guides/STORAGE_GUIDE.md.
Background tasks (pkg/tasks)
pkg/tasks runs background jobs on Asynq + Redis. Payloads are
encoded as JSON and keyed by a task-type string; the framework handles
enqueue, retry, dead-letter and metrics.
tasks.Manager is an interface constructed by the application's task
wiring — it is not exposed as a field on App. Hold the Manager
your wiring builds and use it directly:
import (
"context"
"github.com/jcsvwinston/nucleus/pkg/tasks"
)
const TypeSendWelcomeEmail = "email:welcome"
type SendWelcomeEmail struct {
UserID int64
}
// mgr is a tasks.Manager held by your task wiring.
// Register a handler for the task type. tasks.HandlerFunc is
// func(ctx context.Context, task tasks.Task) error.
mgr.HandleFunc(TypeSendWelcomeEmail, tasks.HandlerFunc(
func(ctx context.Context, task tasks.Task) error {
var payload SendWelcomeEmail
if err := tasks.DecodeJSONPayload(task, &payload); err != nil {
return err
}
return sendWelcome(ctx, payload.UserID)
},
))
// Enqueue from a request handler (payload is JSON-encoded for you):
id, err := mgr.EnqueueJSON(TypeSendWelcomeEmail, SendWelcomeEmail{UserID: 42})
Module jobs and webhooks
Modules declare recurring background jobs and inbound webhook receivers
directly on their Module[C] definition; the framework schedules the
jobs on pkg/tasks and mounts the webhook routes at boot.
nucleus.Module[BillingConfig]{
Name: "billing",
Jobs: func(j nucleus.JobRegistry, cfg BillingConfig) {
_ = j.Register("reconcile", nucleus.JobSpec{
Every: 15 * time.Minute, // or Cron: "0 3 * * *"
Timeout: 5 * time.Minute,
Singleton: true, // skip a tick while the previous run is live
Handler: func(ctx context.Context) error {
return reconcileInvoices(ctx)
},
})
},
Webhooks: func(w nucleus.WebhookRegistry, cfg BillingConfig) {
_ = w.Register("/stripe", nucleus.WebhookSpec{
Secret: cfg.StripeWebhookSecret,
Handler: func(rw http.ResponseWriter, r *http.Request) {
// Body is verified against X-Nucleus-Signature before
// this handler runs; read it as usual.
},
})
},
}
Jobs. Each registration needs exactly one schedule: Every for a fixed
interval, or Cron for a standard 5-field expression or a descriptor such as
@hourly. Cron expressions are validated at boot and mean the same thing on
every provider.
The jobs_provider config key selects the runtime:
memory(default) — in-process. Pending jobs are lost on restart.asynq— Redis-backed and durable. Setjobs_redis_url.
jobs_concurrency caps the number of parallel workers. A broken registration
— duplicate name, invalid cron, missing handler — fails boot rather than
silently never running.
Webhooks. Each registration mounts a real route at
<webhooks_prefix>/<module-name><path>; the default prefix is /webhooks.
Incoming requests are checked in this order, all before your handler runs:
- Method — POST only by default. Anything else gets 405.
- Body size — 1 MiB by default. Larger bodies get 413.
- Signature — with a
Secretset, the request must carry an HMAC-SHA256 of the raw body in theX-Nucleus-Signatureheader, assha256=<hex>. Unsigned or mis-signed requests get 401. Senders and tests can produce the value withnucleus.SignWebhookBody.
Two more rules apply at boot:
- A webhook registered without a
Secretis still mounted, but logs a WARN — its handler must authenticate callers itself. - Registration paths must be canonical. A path that
path.Cleanwould rewrite (.or..segments, duplicate or trailing slashes) fails boot with a clear error, instead of silently mounting a route that cleaned request URLs can never reach.
When csrf_enabled is on, the webhook prefix is exempted automatically:
webhooks authenticate by signature, not by CSRF token.
Replay, declared honestly. The signature authenticates content, not freshness — a captured signed request verifies again if it is re-sent verbatim. If your handler's effect is not idempotent, deduplicate on an event ID carried in the payload.
To narrow the replay window, set TimestampTolerance on the spec. That
changes the contract for senders:
- The request must carry its send time as Unix seconds in the
X-Nucleus-Timestampheader. - That time must fall within the tolerance of the receiver's clock.
- The signature must cover
<timestamp>.<body>rather than the body alone.nucleus.SignWebhookBodyWithTimestampreturns both header values.
A timestamp that is missing, malformed, stale, future-dated, or signed over the body alone is rejected with 401 before your handler runs.
Because it changes what senders must sign, the scheme is opt-in: leaving
TimestampTolerance unset keeps the body-only behaviour. 5m is a sensible
tolerance when senders have synced clocks, and event-ID deduplication still
closes the window the tolerance leaves open.
Transactional outbox (pkg/outbox)
The naïve "enqueue inside a SQL transaction" pattern silently loses
events when the transaction commits but the queue write fails.
pkg/outbox solves this with the standard outbox pattern:
import (
"database/sql"
"github.com/jcsvwinston/nucleus/pkg/outbox"
)
// App.DB.Tx runs fn inside a transaction (tx is a *sql.Tx).
// App.Outbox is a *outbox.ManagedOutbox; EnqueueTx writes the event row
// in the SAME transaction, so the event is durable iff the commit lands.
err := a.DB.Tx(ctx, func(tx *sql.Tx) error {
if err := repo.Save(tx, article); err != nil {
return err
}
_, err := a.Outbox.EnqueueTx(ctx, tx, outbox.Entry{
Topic: "article.published",
Payload: ArticlePublished{ID: article.ID},
})
return err
})
The outbox table is part of the migration set the framework manages.
With outbox.enabled: true the framework starts a leasing dispatcher
that polls the table and delivers committed events through the bridges
declared under outbox.bridges (all keys in the
Configuration reference).
Shutdown
Stopping the outbox is graceful: the dispatcher finishes the pass it is in — including the delivery in flight — and only then exits. A pass is not abandoned halfway, so a message is never left claimed by a delivery that was cut off mid-attempt (it would have waited for its lease to expire before anyone retried it).
Waiting is bounded. Stop(ctx) escalates to cancelling the pass when the
context you pass expires, or after five seconds if it carries no deadline
— a bridge that never answers cannot hold shutdown hostage. The escalation
is logged at WARN so a slow shutdown is visible rather than silent.
Webhook bridge: delivery contract
A webhook bridge POSTs each outbox message as a JSON body to the
configured URL:
{
"id": "msg-01hzy4v7",
"topic": "orders.placed",
"payload": "eyJvcmRlcl9pZCI6NDJ9",
"status": "pending",
"attempts": 1,
"available_at": "2026-07-22T12:00:00Z",
"created_at": "2026-07-22T12:00:00Z"
}
Every delivery carries the header X-Outbox-Payload-Encoding declaring
the shape of the payload field for that message, so the receiver
never guesses:
base64— the default:payloadis a JSON string holding the base64 encoding of the raw payload bytes (the example above). Decode the string to get the payload document. This is the classic wire shape — byte-for-byte what every release up to v1.4.0 emits — so existing consumers keep working unchanged; the header is purely additive.json— opt-in per bridge withpayload_encoding: json: the payload document is embedded verbatim, e.g."payload": {"order_id": 42}, and the consumer reads it directly with no base64 round-trip. Two edge cases: a payload that is not valid JSON (possible only for rows not written by the framework's own enqueue path) falls back to the base64 string form and declaresbase64in the header for that delivery; a message with no payload puts JSONnullin the field.
outbox:
enabled: true
bridges:
- name: order-hooks
type: webhook
config:
url: "https://consumer.example.com/hooks/outbox"
pattern: "orders.*"
payload_encoding: json # opt-in; omit for the base64 default
secret: "shared-webhook-secret"
Webhook bridge: HMAC signature
With secret set, the bridge signs every delivery: the header
X-Nucleus-Signature carries sha256=<hex>, the HMAC-SHA256 of the
exact body bytes under the shared secret. It is the same header and
the same scheme module webhooks verify (previous section), so one
verifier covers both directions — and nucleus.SignWebhookBody
computes the expected value. Verify with a constant-time comparison,
never ==/!=:
import (
"crypto/hmac"
"io"
"net/http"
"github.com/jcsvwinston/nucleus/pkg/nucleus"
)
func outboxHook(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
want := nucleus.SignWebhookBody(secret, body)
got := r.Header.Get(nucleus.WebhookSignatureHeader)
if !hmac.Equal([]byte(want), []byte(got)) {
http.Error(w, "bad signature", http.StatusUnauthorized)
return
}
// The body is authentic. The X-Outbox-Payload-Encoding header is
// informational and travels UNSIGNED — the signature covers the body
// alone — so decode by the encoding you configured for this bridge
// (base64 unless you set payload_encoding: json), not by trusting the
// header. Treat a header that disagrees with your configured encoding as
// a bad request rather than as an instruction to switch decoders.
}
Without a secret the bridge delivers unsigned and logs a boot
warning: the consumer must then authenticate deliveries itself (for
example with a static header under config.headers) — and a static
header does not authenticate the body, so prefer the signature.
Scope, honestly stated: the signature authenticates the body and nothing else. There is no anti-replay protection here — no timestamp in the signed material, no nonce — so a captured delivery verifies again if replayed.
In practice that is tolerable because outbox delivery is at-least-once
anyway: consumers must already be idempotent, keyed on the message id.
Deliveries to plain http:// URLs send the body in clear. Use HTTPS outside
loopback.
Mail (pkg/mail)
Two drivers ship out of the box:
| Driver | Use |
|---|---|
noop | Tests and development — captures payloads in memory. |
smtp | Anything that speaks SMTP. |
Vendor-specific HTTP providers (SendGrid, Mailgun, AWS SES, Postmark,
Resend, …) install as nucleus-plugin-<provider> binaries on PATH
and are discovered via the capability-style external bridge
(pkg/plugins). The mail.send capability contract is documented
in the Plugin SDK reference;
a runnable reference skeleton returns with the v0.9.X reference
applications.
Circuit breaker (mail)
App.New automatically wraps mail.Sender.Send with a
pkg/circuit.Breaker. The noop driver and the Healthy SMTP HELO
probe (used by /healthz) are never wrapped — so health checks can
observe that a mail relay has recovered while Send is still
short-circuited.
When the breaker is open, Send returns circuit.ErrOpen. The default
thresholds are:
mail_circuit_breaker:
enabled: true
failure_threshold: 5
cooldown: 30s
half_open_max_concurrent: 1
Set enabled: false to disable. Config keys are documented in the
Configuration reference.