How-to guide

Queuey Edge — durable publishing from unreliable networks

For producers where the network is a fact of life, not a guarantee — factories, kiosks, vehicles, ships, on-prem services. One PublishAsync call durably accepts the event on the local machine; Queuey owns the delivery mechanics from there.

The contract

When PublishAsync returns, the event is committed to a local durable store (SQLite, fsync'd) under a permanent transfer identity. Queuey then handles transfer to Queuey Cloud, retries with backoff and jitter, reconnect, lost-acknowledgement resolution, idempotent resend, recovery across process and machine restarts, and backlog draining — in order per lane. Kill the network for two days: events accumulate durably and drain when it returns. A retry, however late, can never become a second logical event.

Ordering, precisely. Events reach Queuey Cloud in strict accept order within a lane — the queue, or the queue plus GroupKey — with at most one transfer in flight per lane and no ordering across lanes. Whether that order is kept through to your destination is the queue's own ordering policy (ordering: fifo keeps it; besteffort does not). An event Cloud permanently rejects is quarantined and steps aside so the lane keeps moving; queuey edge retry puts it back at the head of its lane.

Draining a backlog. Edge has no rate limiter of its own: transfers run with bounded concurrency across lanes, back off with decorrelated jitter, probe with a single event after a failure, and wait at least Cloud's Retry-After (plus a little jitter, so a fleet that went dark together does not knock again in lockstep).

Before Queuey.Edge
// ❌ What Queuey.Edge exists to delete from your codebase
if (!await queuey.IsOnline())
{
    await localDb.Save(evt);
    ScheduleRetry(...);
}

Set it up once

Install the Queuey.Edge NuGet package (dotnet add package Queuey.Edge --prerelease), make the node's signing key with your queuey login, and register it. Edge signs with the same QUEUEY_SIGNING_KEY_ID and QUEUEY_SIGNING_SECRET as Queuey.Client, read by the same rules. A key on a machine you do not control should reach nothing but its queue, so make it with --queue:

Package version
Signing with the queue's key requires Queuey.Edge ≥ 0.1.0-preview.9 (newer than 0.1.0-preview.8), and the queuey CLI of the same version for keys mint --write. 0.1.0-preview.8 and older publish with an API key only.
bash
queuey keys mint --queue sensor-readings --write .env   # or --write user-secrets in a .NET project
Program.cs
// Program.cs — any .NET host (worker service, ASP.NET, console)
builder.Services.AddQueueyEdge(o =>
{
    o.UseEnvironmentVariables();   // QUEUEY_SIGNING_KEY_ID + QUEUEY_SIGNING_SECRET (and QUEUEY_TENANT)
    // or, with user-secrets: o.UseSettings(builder.Configuration);
    // Optional: o.Storage.Path = "/var/lib/myapp/queuey/spool.db";
});

Edge signs each transfer when it sends it, so an event that waited in the spool through an outage goes out with a fresh timestamp and nonce. The secret stays in memory; the spool never holds it. The timestamp is this machine's clock, and the ingress refuses one more than five minutes off (timestamp_out_of_range), so keep the clock synchronised (NTP): with a clock that is off, Edge holds the events, says the clock is the cause, and drains once it is right. A workspace that takes signed requests only accepts Edge as it accepts the SDK. A publish-only API key (QUEUEY_API_KEY) still works for a workspace that takes API keys. As with Queuey.Client, .env and user secrets are read only in Development, and dotnet run without a launch profile runs as Production: set DOTNET_ENVIRONMENT=Development (or ASPNETCORE_ENVIRONMENT) while you develop.

Edge sends to https://ingress.queuey.ai. Against a local or self-hosted Queuey, give it that Queuey's ingress with QUEUEY_INGRESS_BASE, which UseEnvironmentVariables() and the daemon read, or set IngressBaseAddress in code:

bash
# the environment, or edge.env for the daemon; UseEnvironmentVariables() reads it
QUEUEY_INGRESS_BASE=http://localhost:5084

# the daemon also takes it as a flag
queuey edge run --spool /var/lib/queuey/spool.db --ingress-base http://localhost:5084
Program.cs
builder.Services.AddQueueyEdge(o =>
{
    o.UseEnvironmentVariables();
    o.IngressBaseAddress = new Uri("http://localhost:5084");   // or leave it to QUEUEY_INGRESS_BASE
});
Where a key never goes
Not in a command's arguments (every user on the machine can read argv), not as a literal in Program.cs or any other code, and never through /tmp.

Then publish — that's the whole app

SensorService.cs
// The whole delivery story in application code:
public sealed class SensorService(IQueueyPublisher queuey)
{
    public async Task OnReading(Reading reading) =>
        await queuey.PublishAsync("sensor-readings", reading, new PublishOptions
        {
            EventType = "temperature.updated",
            GroupKey = reading.DeviceId,        // ordering lane
            OccurredAtUtc = reading.ReadAtUtc,  // honest history when a backlog drains
        });
}
Honest history
Pass OccurredAtUtc and a backlog drained on Wednesday still reads as Monday in the console — Queuey stores the occurrence time separately from the receive time (X-Queuey-Occurred-At).

What can actually go wrong at the call

PublishAsync throws only for conditions that exist before Queuey takes responsibility: missing configuration, a payload that cannot be serialized or exceeds your local cap, a full local store (QueueySpoolFullException — the honest backpressure of lossless retention), or a corrupt store awaiting explicit operator recovery. Network state, Queuey Cloud availability and HTTP errors never surface at the call site.

Storage rules
Local durable disk only (never a network share; in containers, a persistent volume), and exclude spool.db* from antivirus and file-copy backups. Default 512 MB spool ≈ a year of offline autonomy at 1 event/minute × 1 KB. The limit counts live events: a spool that filled up during an outage accepts again as soon as its backlog has transferred.

No .NET app: the Edge daemon

On a Linux box or IoT gateway, queuey edge run is the whole of Edge as a systemd service, and anything on the machine publishes durably to it. It reads the signing key from its environment file, never from flags. Make the file on your own machine and stream it straight into place, owned by root and readable by nobody else, then delete your copy:

bash
queuey keys mint --queue sensor-readings --write edge.env   # the secret is never shown
ssh device 'sudo install -d -m 700 -o root -g root /etc/queuey'
ssh device 'sudo install -m 600 -o root -g root /dev/stdin /etc/queuey/edge.env' < edge.env && rm edge.env
systemd
# /etc/systemd/system/queuey-edge.service (excerpt)
[Service]
ExecStart=/opt/queuey/queuey edge run --spool /var/lib/queuey/spool.db --listen 7311
EnvironmentFile=/etc/queuey/edge.env
User=queuey
StateDirectory=queuey

# /etc/queuey/edge.env (root:root, 0600)
QUEUEY_TENANT=ten_...
QUEUEY_SIGNING_KEY_ID=hsk_...
QUEUEY_SIGNING_SECRET=...
# QUEUEY_EDGE_HEALTH_API_KEY=qak_...   # only for --report-health
# QUEUEY_INGRESS_BASE=https://ingress.example.internal   # only when the ingress is not Queuey's own

--report-health (or Health.ReportToCloud in an app) shows the node under Edge nodes in the console. Queuey's check-in takes an API key today, not a signature, so a node that signs checks in with QUEUEY_EDGE_HEALTH_API_KEY, a publish-only key (queuey keys mint --type api-key) that nothing but the check-in uses: in edge.env for the daemon, in the environment or user secrets for an app. Without it the node says so once and sends no reports; its events flow as before. When Queuey takes a signed check-in, the health key goes away. The full runbook is in docs/edge-operations.md.

Queuey exposes health — you monitor it

Edge publishes an OpenTelemetry meter and an in-process snapshot for whatever monitoring you already run (Azure Monitor, Datadog, Prometheus, SCADA). If you wire exactly one alert, use oldest_age_seconds: it grows when anything has been stopping transfer, and Edge resumes by itself when the cause clears.

Health signals
Meter: Queuey.Edge (OpenTelemetry)

  queuey.edge.spool.pending               events accepted locally, not yet transferred
  queuey.edge.spool.oldest_age_seconds    ← THE signal worth alerting on
  queuey.edge.spool.quarantined           permanently rejected, awaiting operator retry/discard
  queuey.edge.cloud.last_contact_seconds  seconds since last successful transfer
  queuey.edge.state                       0 Healthy · 1 Backlogged · 2 RequiresAction
                                          3 StorageFull · 4 StorageFaulted
  queuey.edge.transfer.accepted{replayed} / transfer.failed{class,reason}

Operator verbs

The queuey CLI operates a spool file directly, alongside a running host. Discard reaches only quarantined events, and data loss is always an explicit, acknowledged decision — never Queuey's default.

CLI
queuey edge status  --spool <path> [--json]
queuey edge retry   --spool <path> (--id N | --all)    # after fixing a quarantine cause
queuey edge discard --spool <path> --id N              # explicit, logged operator decision
queuey edge recover --spool <path>                     # salvage a faulted spool
queuey edge reset   --spool <path> --accept-data-loss  # start clean; old file preserved
Next steps