You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
work queue (claim / lease / retry / poison-retire),
job status record — GET /v1/index/{id} reads status, gamesIndexed, errorMessage back out of it, through IndexRequestService,
idempotency registry — createOrAdopt returns the existing request so a duplicate submit attaches to the run in flight and the caller gets the same id back,
progress channel — updateStatusOwned writes gamesIndexed as the run proceeds,
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
serviceHopper {
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.operationSendMessage {
input := {
@requiredqueue: QueueName
@requiredbody: MessageBody// opaque to the servicededupeKey: StringdelaySeconds: Integer
}
output := { @requiredmessageId: String, @requiredcreated: 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.operationReceiveMessages {
input := {
@requiredqueue: QueueNamemaxMessages: Integer// 1..10, default 1visibilityTimeoutSeconds: Integer// default per-queuewaitTimeSeconds: Integer// 0..20, long poll
}
output := { @requiredmessages: DeliveryList }
}
structureDelivery {
@requiredmessageId: String
@requiredreceiptHandle: String
@requiredbody: MessageBody
@requiredfencingToken: Long// monotonic per message
@requiredreceiveCount: Integer
@requiredenqueuedAt: 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:
CREATETABLEqueue_messages (
id UUID PRIMARY KEY,
queue VARCHAR(64) NOT NULL,
body BYTEANOT NULL,
dedupe_key VARCHAR(600), -- partial unique index over live rows
visible_at TIMESTAMPNOT NULL, -- delay + visibility timeout
receipt UUID, -- current delivery, NULL when visible
fencing_token BIGINTNOT NULL DEFAULT 0,
receive_count INTNOT NULL DEFAULT 0,
enqueued_at TIMESTAMPNOT 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
Service + model + PostgresQueueStore + InMemoryQueueStore + the shared contract suite. No consumers yet.
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.
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.
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_requestsis worked throughIndexingRequestStore, and it is already a real queue — the operations map almost one-for-one onto SQS:IndexingRequestStorecreateOrAdopt(...)— atomic dedupe on a content key, returns whether you created itSendMessage+ FIFOMessageDeduplicationIdclaimNext(ownerId, lease, now)— oldest live unheld rowReceiveMessagewith a visibility timeoutrenewLease(id, ownerId, lease, now)ChangeMessageVisibilityhandBack(id, ownerId, now)ChangeMessageVisibility(0)updateStatusOwned(...)DeleteMessagereclaimStale(staleAfter, now)+MAX_ATTEMPTS = 3maxReceiveCount→ DLQIndexWorkerLifecycle's 5 sIDLE_POLL+ in-process nudgeWaitTimeSeconds)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:
GET /v1/index/{id}readsstatus,gamesIndexed,errorMessageback out of it, throughIndexRequestService,createOrAdoptreturns the existing request so a duplicate submit attaches to the run in flight and the caller gets the same id back,updateStatusOwnedwritesgamesIndexedas the run proceeds,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_requestslosesowner_id,lease_expires_at, andattempts, 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 isUPDATE ... 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
SendMessageidempotent on the job id and have a reconciler re-send for jobs sitting inPENDINGwith 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.
claimNextis 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.submitHybridruns 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>(alongsideprom_proxy), C++ on the existing stack: smithy-cpp for the API,//domains/platform/libs/pgfor storage,//domains/platform/libs/aurafor the serving chain,linux_amd64_oci_binaryfor the image. Name suggestions: hopper,sluice,chute— bikeshed freely.Protocol:
alloy#simpleRestJson, the same bindingportraituses, 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
plus
ExtendVisibility(queue, receiptHandle, seconds)(the heartbeat, returning the unchanged token),DeleteMessage(queue, receiptHandle)(ack),ReleaseMessage(queue, receiptHandle)(nack → immediately visible), andGetQueueStats(queue)→{visible, inFlight, oldestVisibleAgeSeconds}for dashboards and alerting. Stale receipt handles get a typedReceiptExpirederror 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
One contract test suite runs against every adapter — the Beyoncé-rule pattern
golf_hubalready 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, andInMemoryQueueStoreis cheap enough that it can land in the same PR as the Postgres one.Postgres schema is essentially what
indexing_requestsalready carries, minus the domain:ReceiveMessagesisSELECT ... FOR UPDATE SKIP LOCKEDovervisible_at <= now()ordered byenqueued_at— the standard Postgres-as-queue pattern, and a better fit than today's claim becauseSKIP LOCKEDlets concurrent receivers avoid each other without lock conflicts. Long polling ridesLISTEN/NOTIFYon//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
/v1/analyze. Plus@httpBearerAuthin the model — smithy-cpp'sClientConfighasbearer_tokenas a per-request callback, so this costs a config line on the client.maxMessages≤ 10,waitTimeSeconds≤ 20, visibility timeout ≤ 1 h, and a per-queue depth ceiling that makesSendMessagereturn a typedQueueFullinstead of growing without limit. Most of these are Smithy@rangetraits, i.e. validated by generated code rather than by hand.maxReceiveCountper queue → dead-letter queue, replacing today'sMAX_ATTEMPTS = 3retire. A DLQ is just another queue, so draining it needs no new API.RetentionPolicy.Migration
PostgresQueueStore+InMemoryQueueStore+ the shared contract suite. No consumers yet.IndexRequestServicesends the message; the job row keeps status.owner_id,lease_expires_at,attempts) fromindexing_requestsonce the Java worker is gone. Cannot happen before the fencing token is in place and the jobs table honors it.Deviations from SQS, on purpose
claimNextwithout over-promising for future adaptersPurgeQueuein the APIOpen questions
golf_hubandr3drhave no queue today. If the answer is "nobody yet", v1 should stay deliberately small rather than growing knobs for hypothetical users.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.