Skip to content

A queue service with an SQS-shaped API, Postgres-backed to start, storage swappable later #1391

Description

@aaylward

Follows #1389, which moves the one_d4 index worker to C++ and keeps the Postgres table as the queue. That is the right call for #1389 — the table is the queue since #1279, and rewriting the worker is enough work on its own. This issue is the next question: put a small SQS-shaped API in front of the queueing behavior so the physical storage stops being load-bearing, and keep Postgres underneath for now (and maybe forever).

The goal is a seam, not a new database. Postgres stays the implementation; what changes is that nothing outside the queue service knows that.

What we have today

indexing_requests is worked through IndexingRequestStore, and it is already a real queue — the operations map almost one-for-one onto SQS:

IndexingRequestStore SQS
createOrAdopt(...) — atomic dedupe on a content key, returns whether you created it SendMessage + FIFO MessageDeduplicationId
claimNext(ownerId, lease, now) — oldest live unheld row ReceiveMessage with a visibility timeout
renewLease(id, ownerId, lease, now) ChangeMessageVisibility
handBack(id, ownerId, now) ChangeMessageVisibility(0)
terminal updateStatusOwned(...) DeleteMessage
reclaimStale(staleAfter, now) + MAX_ATTEMPTS = 3 visibility expiry + maxReceiveCount → DLQ
IndexWorkerLifecycle's 5 s IDLE_POLL + in-process nudge long polling (WaitTimeSeconds)

So the vocabulary already exists. The work is not inventing queue semantics — it is separating them from everything else the table does.

The design challenge

1. The table is not only a queue. It plays five roles at once:

  1. work queue (claim / lease / retry / poison-retire),
  2. job status recordGET /v1/index/{id} reads status, gamesIndexed, errorMessage back out of it, through IndexRequestService,
  3. idempotency registrycreateOrAdopt returns the existing request so a duplicate submit attaches to the run in flight and the caller gets the same id back,
  4. progress channelupdateStatusOwned writes gamesIndexed as the run proceeds,
  5. retention target (deleteOlderThan).

SQS gives you (1) and a weak version of (3). It gives you nothing of (2), (4), or (5) — a message is an opaque body that is deleted on success, so there is nowhere to read "how did job X end?" afterwards. Any design that says "replace the table with a queue API" has to answer where roles 2–5 live, and the answer must not be "the queue service also stores chess indexing status", because that is the domain leaking into the generic thing and it would defeat the swap.

The split this forces: the queue carries message identity, delivery, and retry; one_d4 keeps a jobs table for status/progress/dedupe-of-user-intent. The message body becomes little more than a job id. Roughly: indexing_requests loses owner_id, lease_expires_at, and attempts, and keeps everything else.

2. Fencing stops being free. Today every write in a run is fenced by holdsLease(id, ownerId, now) against the same Postgres the write goes to (#1278). Split the queue out and the lease lives in one service while the writes go to another database — the classic distributed-lock hole: a worker paused past its visibility timeout wakes up and writes, while a second worker is already running the same message. SQS cannot help here: receipt handles are opaque and unordered. So this design deliberately deviates: every delivery carries a monotonically increasing fencing token, the job row stores the highest token it has seen, and a fenced write is UPDATE ... WHERE fencing_token <= :token. Without this the API is not safe to adopt, and it is the single most important thing SQS's shape does not give us.

3. Enqueue becomes a dual write. Today creating the request is enqueueing it — one row, one transaction, no window. With a queue service, submit must write a job row (one_d4's DB) and send a message (queue service), and either half can fail. Options: a transactional outbox in one_d4, or — simpler and probably enough here — make SendMessage idempotent on the job id and have a reconciler re-send for jobs sitting in PENDING with no live delivery. Worth deciding explicitly rather than discovering.

4. At-least-once meets a non-idempotent write. Lease expiry already means a message can run twice, so this is not new — but it is worth writing down that the occurrences flush (delete + re-insert, ConcurrentFlushTest) is the part that doubles under concurrent runs, and the fencing token from (2) is what protects it.

5. Our dedupe window is not SQS's. FIFO dedupe is a fixed 5-minute window on a caller-supplied id. Ours is "at most one live job per content key, for however long that job runs" — enforced by a partial unique index on non-terminal rows, and hours long for a big backfill. That is a property of the jobs table, not the queue, and it should stay there. The queue's own dedupe (if any) is a much weaker safety net.

6. Ordering and fairness. claimNext is oldest-first (ORDER BY created_at). SQS standard queues promise neither ordering nor fairness. Since a future adapter might not offer oldest-first either, the contract should say what it actually guarantees — I'd propose "approximately oldest-first, best effort, no ordering guarantee" so a Redis or in-memory adapter isn't born non-conforming.

7. The inline path exists. IndexRequestService.submitHybrid runs single-month requests to completion on the calling thread, never touching the queue. Any redesign has to keep that path (it is a latency feature, not an accident) or explicitly retire it.

Initial design

Shape

A small standalone service, domains/platform/apis/<name> (alongside prom_proxy), C++ on the existing stack: smithy-cpp for the API, //domains/platform/libs/pg for storage, //domains/platform/libs/aura for the serving chain, linux_amd64_oci_binary for the image. Name suggestions: hopper, sluice, chute — bikeshed freely.

Protocol: alloy#simpleRestJson, the same binding portrait uses, so the C++ worker in #1389 gets a generated client (see #1390) and the model is the contract. Long polling is just a slow response; no streaming needed in v1. If push delivery ever beats polling, smithy-cpp's event-stream support is there, but polling is the right v1.

Operations

service Hopper {
    version: "2026-08-17"
    operations: [SendMessage, ReceiveMessages, ExtendVisibility,
                 DeleteMessage, ReleaseMessage, GetQueueStats]
}

/// Enqueue. `dedupeKey` collapses re-sends onto the live message, and the
/// response says whether this call created it — the property IndexRequestService
/// needs to decide whether to dispatch.
operation SendMessage {
    input := {
        @required queue: QueueName
        @required body: MessageBody        // opaque to the service
        dedupeKey: String
        delaySeconds: Integer
    }
    output := { @required messageId: String, @required created: Boolean }
    errors: [QueueFull, QueueNotFound]
}

/// Long-polling receive. Each delivery carries a receipt handle and a fencing
/// token strictly greater than any previous delivery of the same message.
operation ReceiveMessages {
    input := {
        @required queue: QueueName
        maxMessages: Integer               // 1..10, default 1
        visibilityTimeoutSeconds: Integer  // default per-queue
        waitTimeSeconds: Integer           // 0..20, long poll
    }
    output := { @required messages: DeliveryList }
}

structure Delivery {
    @required messageId: String
    @required receiptHandle: String
    @required body: MessageBody
    @required fencingToken: Long      // monotonic per message
    @required receiveCount: Integer
    @required enqueuedAt: Timestamp   // @timestampFormat("epoch-seconds")
}

plus ExtendVisibility(queue, receiptHandle, seconds) (the heartbeat, returning the unchanged token), DeleteMessage(queue, receiptHandle) (ack), ReleaseMessage(queue, receiptHandle) (nack → immediately visible), and GetQueueStats(queue){visible, inFlight, oldestVisibleAgeSeconds} for dashboards and alerting. Stale receipt handles get a typed ReceiptExpired error rather than a silent no-op — a heartbeat that quietly fails is how you get two live runs.

Deliberately not in v1: PurgeQueue (destructive, admin-gated at best), queue creation over the API (queues are configuration, declared at deploy), and message attributes/filters.

Storage seam

QueueStore (pure virtual)          <- the seam
├── PostgresQueueStore             <- v1, //domains/platform/libs/pg
├── InMemoryQueueStore             <- tests, and the proof the seam is real
└── ...                            <- Redis / SQS / NATS, later

One contract test suite runs against every adapter — the Beyoncé-rule pattern golf_hub already uses for its smithy contract tests. The design is not proven by argument; it is proven when a second adapter passes the same suite unmodified. That is the acceptance criterion for this issue, and InMemoryQueueStore is cheap enough that it can land in the same PR as the Postgres one.

Postgres schema is essentially what indexing_requests already carries, minus the domain:

CREATE TABLE queue_messages (
  id             UUID PRIMARY KEY,
  queue          VARCHAR(64)  NOT NULL,
  body           BYTEA        NOT NULL,
  dedupe_key     VARCHAR(600),          -- partial unique index over live rows
  visible_at     TIMESTAMP    NOT NULL, -- delay + visibility timeout
  receipt        UUID,                  -- current delivery, NULL when visible
  fencing_token  BIGINT       NOT NULL DEFAULT 0,
  receive_count  INT          NOT NULL DEFAULT 0,
  enqueued_at    TIMESTAMP    NOT NULL
);

ReceiveMessages is SELECT ... FOR UPDATE SKIP LOCKED over visible_at <= now() ordered by enqueued_at — the standard Postgres-as-queue pattern, and a better fit than today's claim because SKIP LOCKED lets concurrent receivers avoid each other without lock conflicts. Long polling rides LISTEN/NOTIFY on //domains/platform/libs/pg:listener, which already exists with reconnect-and-re-LISTEN healing and was built for exactly this fan-out — a notify is a wake-up, every wake re-reads state, so a dropped notification costs latency and not work. That is precisely the guarantee a long-poll needs.

Safety

  • Not publicly routed. Internal network only, like /v1/analyze. Plus @httpBearerAuth in the model — smithy-cpp's ClientConfig has bearer_token as a per-request callback, so this costs a config line on the client.
  • Bounded everything: message body ≤ 64 KB, maxMessages ≤ 10, waitTimeSeconds ≤ 20, visibility timeout ≤ 1 h, and a per-queue depth ceiling that makes SendMessage return a typed QueueFull instead of growing without limit. Most of these are Smithy @range traits, i.e. validated by generated code rather than by hand.
  • Poison messages: maxReceiveCount per queue → dead-letter queue, replacing today's MAX_ATTEMPTS = 3 retire. A DLQ is just another queue, so draining it needs no new API.
  • Retention: per-queue TTL sweeper, same shape as the existing RetentionPolicy.
  • Rate limiting through the aura middleware chain, already there.
  • The API is a fixed set of operations over opaque bodies — no queue creation, no arbitrary predicates, nothing that turns it into a general database proxy.

Migration

  1. Service + model + PostgresQueueStore + InMemoryQueueStore + the shared contract suite. No consumers yet.
  2. The C++ index worker from one_d4: rewrite the index worker in C++ on Disservin/chess-library #1389 consumes it from day one — new code, no migration, and it is the natural first client. IndexRequestService sends the message; the job row keeps status.
  3. Retire the queue columns (owner_id, lease_expires_at, attempts) from indexing_requests once the Java worker is gone. Cannot happen before the fencing token is in place and the jobs table honors it.
  4. A third adapter when there is a reason. If none ever appears, Postgres stays and the seam still paid for itself in testability.

Deviations from SQS, on purpose

SQS Here Why
opaque unordered receipt handles receipt handle + monotonic fencing token fenced writes across a service boundary; the whole safety story
FIFO dedupe, fixed 5-minute window dedupe on the jobs table, for the life of the job ours is a business key, not a retry artifact
no ordering, no fairness "approximately oldest-first, best effort" matches claimNext without over-promising for future adapters
256 KB bodies 64 KB bodies are a job id and a few parameters; smaller ceiling, fewer surprises
PurgeQueue in the API not in v1 destructive and rarely what you meant

Open questions

  • Does the fencing token belong in the queue API at all, or is it a jobs-table concern? I think the queue must issue it — only the queue knows the delivery order — but it does put a domain-flavored field in a generic API, and I would like that challenged.
  • Who else wants this? golf_hub and r3dr have no queue today. If the answer is "nobody yet", v1 should stay deliberately small rather than growing knobs for hypothetical users.
  • Does submitHybrid's inline path survive? It bypasses the queue entirely for single-month requests. Keeping it means two code paths forever; retiring it means a latency regression on the most common request shape.
  • One service or a library? Everything above works as a C++ library linked into each consumer, with no network hop and no new container — the seam would still be real. A service buys language-independence (the Java one_d4 API also enqueues) and one place to swap storage; a library buys simplicity and no dual-write. The Java submit path is probably what decides it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions