Skip to content

Commit 2dd6d25

Browse files
committed
feat(queue): add Publisher.PublishAfter delay primitive
### Why? The orchestrator's build poll loop needs to space `Status` calls out without consuming `retry_count` / DLQ retry slots. `Delivery.Nack` does schedule redelivery after a delay, but it overloads `retry_count` — which the operator relies on to flag real failures. The fix is a separate "postpone this work" primitive, distinct from "this delivery failed, try again", so both signals stay meaningful. ### What? Adds `Publisher.PublishAfter(ctx, topic, msg, delayMs)` to the queue extension: a fresh message inserted into the topic, made visible to subscribers only after `delayMs` from now. Distinct from `Delivery.Nack(requeueAfterMillis)`: - `Nack` is "this delivery failed, try again" — it bumps `retry_count` and eventually trips DLQ. - `PublishAfter` is "postpone this work" — `retry_count` resets to 0, DLQ stays available for true failures. SQL backing in `extension/queue/mysql`: - New `visible_after BIGINT UNSIGNED NOT NULL DEFAULT 0` column on `queue_messages`. Default 0 means immediately visible — back-compat for any existing rows. - `messageStore.InsertDelayed(ctx, topic, messages, visibleAfterMs)` is the underlying primitive. `Insert` is now a thin wrapper that passes `visibleAfterMs = 0`. - `FetchByOffset` gains a `nowMs` parameter and an `AND visible_after <= ?` predicate so subscribers skip rows whose delivery is still deferred. - `MoveToDLQ` writes `visible_after = 0` explicitly: any delay on the original message has already been consumed by the time it failed. The first consumer of `PublishAfter` is the orchestrator's poll-driven `buildsignal` loop, which lands in the stacked PR on top of this one.
1 parent dc4b4cb commit 2dd6d25

13 files changed

Lines changed: 239 additions & 25 deletions

extension/queue/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,18 @@ Publishes messages to topics.
1313
```go
1414
type Publisher interface {
1515
Publish(ctx context.Context, topic string, message queue.Message) error
16+
PublishAfter(ctx context.Context, topic string, message queue.Message, delayMs int64) error
1617
Close() error
1718
}
1819
```
1920

21+
**`PublishAfter`** inserts a fresh message that becomes visible to subscribers only after `delayMs`. It is distinct from `Nack(requeueAfterMillis)` even though both can produce "next delivery happens at T+delay":
22+
23+
- `Nack` is "this delivery failed, try again" — it bumps `retry_count` and eventually trips DLQ.
24+
- `PublishAfter` is "postpone this work" — `retry_count` resets to 0, DLQ stays available for true failures.
25+
26+
Use `PublishAfter` for self-driven poll loops (e.g. the orchestrator's `buildsignal` consumer re-publishing itself between `Status` calls). Use `Nack` for processing failures.
27+
2028
### Subscriber
2129
Consumes messages from topics with per-subscription configuration.
2230

extension/queue/mock/publisher_mock.go

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

extension/queue/mysql/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,8 @@ extension/queue/mysql/
112112
| `queue_partition_leases` | Partition lease coordination | `(consumer_group, topic, partition_key)` |
113113
| `queue_subscriber_heartbeats` | Active subscriber tracking | `(consumer_group, topic, subscriber_name)` |
114114

115+
`queue_messages` has a `visible_after BIGINT UNSIGNED NOT NULL DEFAULT 0` column that supports `Publisher.PublishAfter`: subscribers' `FetchByOffset` skips rows where `visible_after > now`. Default 0 means immediately visible, so existing rows continue to behave as before — the column is back-compatible.
116+
115117
See `schema/` for full SQL definitions. See the [RFC](../../doc/rfc/sql-queue-rfc.md#database-schema) for field-level documentation.
116118

117119
### Store Architecture

extension/queue/mysql/message_store.go

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,15 @@ func newMessageStore(db *sql.DB, logger *zap.SugaredLogger, scope tally.Scope) m
4444
}
4545
}
4646

47-
// Insert inserts messages into the messages table.
47+
// Insert inserts messages into the messages table with no visibility delay.
48+
// Equivalent to InsertDelayed with visibleAfterMs == 0.
49+
func (s *sqlmessageStore) Insert(ctx context.Context, topic string, messages []queue.Message) error {
50+
return s.InsertDelayed(ctx, topic, messages, 0)
51+
}
52+
53+
// InsertDelayed inserts messages into the messages table, optionally deferring
54+
// delivery until visibleAfterMs (epoch milliseconds). 0 means immediately
55+
// visible; FetchByOffset skips rows where visible_after > now.
4856
//
4957
// Publishes are idempotent on the (topic, partition_key, id) unique key: a
5058
// repeated publish for the same key is silently treated as success and does
@@ -53,7 +61,7 @@ func newMessageStore(db *sql.DB, logger *zap.SugaredLogger, scope tally.Scope) m
5361
// idempotent publishes") and lets callers safely retry publishes (e.g. a
5462
// second Cancel RPC for the same request) without surfacing 1062 duplicate-key
5563
// errors.
56-
func (s *sqlmessageStore) Insert(ctx context.Context, topic string, messages []queue.Message) (retErr error) {
64+
func (s *sqlmessageStore) InsertDelayed(ctx context.Context, topic string, messages []queue.Message, visibleAfterMs int64) (retErr error) {
5765
op := metrics.Begin(s.scope, "insert", metrics.NewTag("topic", topic))
5866
defer func() { op.Complete(retErr) }()
5967

@@ -64,6 +72,7 @@ func (s *sqlmessageStore) Insert(ctx context.Context, topic string, messages []q
6472
s.logger.Debugw("inserting messages",
6573
logTopic, topic,
6674
"count", len(messages),
75+
"visible_after", visibleAfterMs,
6776
)
6877

6978
tx, err := s.db.BeginTx(ctx, nil)
@@ -75,8 +84,8 @@ func (s *sqlmessageStore) Insert(ctx context.Context, topic string, messages []q
7584
// ON DUPLICATE KEY UPDATE topic=topic is a no-op write that makes MySQL
7685
// swallow the unique-key violation without mutating the existing row.
7786
stmt, err := tx.PrepareContext(ctx, fmt.Sprintf(`
78-
INSERT INTO %s (topic, id, payload, metadata, partition_key, created_at, published_at, failed_at, failure_count, last_error, original_topic)
79-
VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0, '', '')
87+
INSERT INTO %s (topic, id, payload, metadata, partition_key, created_at, published_at, visible_after, failed_at, failure_count, last_error, original_topic)
88+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 0, '', '')
8089
ON DUPLICATE KEY UPDATE topic = topic
8190
`, MessagesTableName))
8291
if err != nil {
@@ -102,6 +111,7 @@ func (s *sqlmessageStore) Insert(ctx context.Context, topic string, messages []q
102111
msg.PartitionKey,
103112
now,
104113
msg.PublishedAt,
114+
visibleAfterMs,
105115
)
106116
if err != nil {
107117
return fmt.Errorf("insert message topic=%s message=%s partition=%s: %w", topic, msg.ID, msg.PartitionKey, err)
@@ -137,18 +147,20 @@ func (s *sqlmessageStore) Delete(ctx context.Context, topic string, partitionKey
137147
}
138148

139149
// FetchByOffset fetches messages with offset > currentOffset for a specific partition.
150+
// Rows whose visible_after > nowMs are skipped — those are deferred deliveries
151+
// (published via InsertDelayed) that should not yet be surfaced to subscribers.
140152
// Messages are fetched from the immutable log; no per-message mutation occurs.
141-
func (s *sqlmessageStore) FetchByOffset(ctx context.Context, topic string, partitionKey string, currentOffset int64, limit int) (_ []messageRow, retErr error) {
153+
func (s *sqlmessageStore) FetchByOffset(ctx context.Context, topic string, partitionKey string, currentOffset int64, nowMs int64, limit int) (_ []messageRow, retErr error) {
142154
op := metrics.Begin(s.scope, "fetch", metrics.NewTag("topic", topic))
143155
defer func() { op.Complete(retErr) }()
144156

145157
rows, err := s.db.QueryContext(ctx, fmt.Sprintf(`
146158
SELECT offset, id, payload, metadata, partition_key, published_at, failed_at, failure_count, last_error, original_topic
147159
FROM %s
148-
WHERE topic = ? AND partition_key = ? AND offset > ?
160+
WHERE topic = ? AND partition_key = ? AND offset > ? AND visible_after <= ?
149161
ORDER BY offset
150162
LIMIT ?
151-
`, MessagesTableName), topic, partitionKey, currentOffset, limit)
163+
`, MessagesTableName), topic, partitionKey, currentOffset, nowMs, limit)
152164
if err != nil {
153165
return nil, fmt.Errorf("query messages topic=%s partition=%s: %w", topic, partitionKey, err)
154166
}
@@ -254,11 +266,13 @@ func (s *sqlmessageStore) MoveToDLQ(ctx context.Context, topic string, partition
254266
return fmt.Errorf("fetch message for DLQ topic=%s partition=%s message=%s: %w", topic, partitionKey, messageID, err)
255267
}
256268

257-
// Insert into queue_messages table with DLQ topic name and DLQ-specific fields
269+
// Insert into queue_messages table with DLQ topic name and DLQ-specific fields.
270+
// DLQ messages are always immediately visible (visible_after=0); any delay on
271+
// the original message has already been consumed by the time it failed.
258272
now := time.Now().UnixMilli()
259273
_, err = tx.ExecContext(ctx, fmt.Sprintf(`
260-
INSERT INTO %s (topic, id, payload, metadata, partition_key, created_at, published_at, failed_at, failure_count, last_error, original_topic)
261-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
274+
INSERT INTO %s (topic, id, payload, metadata, partition_key, created_at, published_at, visible_after, failed_at, failure_count, last_error, original_topic)
275+
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)
262276
`, MessagesTableName), dlqTopic, messageID, payload, metadataJSON, fetchPartKey, createdAtMilli, publishedAtMilli, now, failureCount, lastError, topic)
263277

264278
if err != nil {

extension/queue/mysql/message_store_test.go

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,23 +141,68 @@ func TestMessageStore_FetchByOffset(t *testing.T) {
141141
topic := "test_topic"
142142
partitionKey := "part1"
143143
currentOffset := int64(0)
144+
nowMs := time.Now().UnixMilli()
144145
limit := 10
145146

146147
// Mock query results (no transaction, simple SELECT)
147148
rows := sqlmock.NewRows([]string{"offset", "id", "payload", "metadata", "partition_key", "published_at", "failed_at", "failure_count", "last_error", "original_topic"}).
148149
AddRow(int64(1), "msg1", []byte("payload1"), []byte("{}"), "part1", time.Now().UnixMilli(), int64(0), 0, "", "")
149150

150151
mock.ExpectQuery("SELECT (.+) FROM queue_messages").
151-
WithArgs(topic, partitionKey, currentOffset, limit).
152+
WithArgs(topic, partitionKey, currentOffset, nowMs, limit).
152153
WillReturnRows(rows)
153154

154-
results, err := store.FetchByOffset(ctx, topic, partitionKey, currentOffset, limit)
155+
results, err := store.FetchByOffset(ctx, topic, partitionKey, currentOffset, nowMs, limit)
155156
require.NoError(t, err)
156157
require.Len(t, results, 1)
157158
require.Equal(t, "msg1", results[0].ID)
158159
require.NoError(t, mock.ExpectationsWereMet())
159160
}
160161

162+
func TestMessageStore_FetchByOffset_SkipsDelayed(t *testing.T) {
163+
db, mock, store := setupmessageStoreTest(t)
164+
defer db.Close()
165+
166+
ctx := context.Background()
167+
topic := "test_topic"
168+
partitionKey := "part1"
169+
currentOffset := int64(0)
170+
nowMs := int64(1000)
171+
limit := 10
172+
173+
// The SQL filter (visible_after <= nowMs) is applied by the DB; sqlmock just
174+
// verifies the parameter binding. An empty result row simulates the case
175+
// where the only message is still deferred.
176+
mock.ExpectQuery("SELECT (.+) FROM queue_messages").
177+
WithArgs(topic, partitionKey, currentOffset, nowMs, limit).
178+
WillReturnRows(sqlmock.NewRows([]string{"offset", "id", "payload", "metadata", "partition_key", "published_at", "failed_at", "failure_count", "last_error", "original_topic"}))
179+
180+
results, err := store.FetchByOffset(ctx, topic, partitionKey, currentOffset, nowMs, limit)
181+
require.NoError(t, err)
182+
require.Empty(t, results)
183+
require.NoError(t, mock.ExpectationsWereMet())
184+
}
185+
186+
func TestMessageStore_InsertDelayed(t *testing.T) {
187+
db, mock, store := setupmessageStoreTest(t)
188+
defer db.Close()
189+
190+
ctx := context.Background()
191+
visibleAfter := time.Now().UnixMilli() + 5000
192+
msg := queue.Message{ID: "msg-delayed", Payload: []byte("p"), PartitionKey: "part1", PublishedAt: time.Now().UnixMilli()}
193+
194+
mock.ExpectBegin()
195+
mock.ExpectPrepare("INSERT INTO queue_messages")
196+
mock.ExpectExec("INSERT INTO queue_messages").
197+
WithArgs("test_topic", msg.ID, msg.Payload, []byte(nil), msg.PartitionKey, sqlmock.AnyArg(), msg.PublishedAt, visibleAfter).
198+
WillReturnResult(sqlmock.NewResult(1, 1))
199+
mock.ExpectCommit()
200+
201+
err := store.InsertDelayed(ctx, "test_topic", []queue.Message{msg}, visibleAfter)
202+
require.NoError(t, err)
203+
require.NoError(t, mock.ExpectationsWereMet())
204+
}
205+
161206
func TestMessageStore_MoveToDLQ(t *testing.T) {
162207
db, mock, store := setupmessageStoreTest(t)
163208
defer db.Close()

extension/queue/mysql/mock_stores.go

Lines changed: 18 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

extension/queue/mysql/publisher.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"context"
1919
"fmt"
2020
"sync"
21+
"time"
2122

2223
"github.com/uber-go/tally/v4"
2324
"go.uber.org/zap"
@@ -66,6 +67,36 @@ func (p *publisher) Publish(ctx context.Context, topic string, message queue.Mes
6667
return nil
6768
}
6869

70+
// PublishAfter sends a message that becomes visible to subscribers only
71+
// after delayMs from now. The message is inserted with visible_after =
72+
// now + delayMs; FetchByOffset skips it until that timestamp.
73+
// delayMs <= 0 is equivalent to Publish.
74+
func (p *publisher) PublishAfter(ctx context.Context, topic string, message queue.Message, delayMs int64) (retErr error) {
75+
op := metrics.Begin(p.scope, "publish_after", metrics.NewTag("topic", topic))
76+
defer func() { op.Complete(retErr) }()
77+
78+
p.mu.RLock()
79+
closed := p.closed
80+
p.mu.RUnlock()
81+
82+
if closed {
83+
return ErrPublisherClosed
84+
}
85+
86+
var visibleAfter int64
87+
if delayMs > 0 {
88+
visibleAfter = time.Now().UnixMilli() + delayMs
89+
}
90+
91+
if err := p.messageStore.InsertDelayed(ctx, topic, []queue.Message{message}, visibleAfter); err != nil {
92+
return fmt.Errorf("publish_after message store insert error: %w", err)
93+
}
94+
95+
p.logger.Debugw("published delayed message", logTopic, topic, logMessageID, message.ID, "delay_ms", delayMs)
96+
97+
return nil
98+
}
99+
69100
// Close gracefully shuts down the publisher
70101
func (p *publisher) Close() error {
71102
p.mu.Lock()

extension/queue/mysql/publisher_test.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,68 @@ func TestPublisher_PublishAfterClose(t *testing.T) {
160160
require.True(t, errors.Is(err, ErrPublisherClosed))
161161
}
162162

163+
func TestPublisher_PublishAfter(t *testing.T) {
164+
tests := []struct {
165+
name string
166+
delayMs int64
167+
wantVisibleArg gomock.Matcher
168+
}{
169+
{
170+
name: "positive delay binds future visible_after",
171+
delayMs: 5000,
172+
// Exact timestamp depends on wall clock; assert it's > 0.
173+
wantVisibleArg: gomock.Cond(func(v any) bool {
174+
ts, ok := v.(int64)
175+
return ok && ts > 0
176+
}),
177+
},
178+
{
179+
name: "zero delay binds visible_after=0",
180+
delayMs: 0,
181+
wantVisibleArg: gomock.Eq(int64(0)),
182+
},
183+
{
184+
name: "negative delay clamps to 0",
185+
delayMs: -100,
186+
wantVisibleArg: gomock.Eq(int64(0)),
187+
},
188+
}
189+
190+
for _, tt := range tests {
191+
t.Run(tt.name, func(t *testing.T) {
192+
ctrl := gomock.NewController(t)
193+
defer ctrl.Finish()
194+
195+
mockStore := NewMockmessageStore(ctrl)
196+
mockStore.EXPECT().
197+
InsertDelayed(gomock.Any(), "test_topic", gomock.Any(), tt.wantVisibleArg).
198+
Return(nil).
199+
Times(1)
200+
201+
pub := setupPublisherTest(t, mockStore)
202+
203+
msg := queue.NewMessage("msg-delayed", []byte("p"), "part1", nil)
204+
err := pub.PublishAfter(context.Background(), "test_topic", msg, tt.delayMs)
205+
require.NoError(t, err)
206+
})
207+
}
208+
}
209+
210+
func TestPublisher_PublishAfterClosed(t *testing.T) {
211+
ctrl := gomock.NewController(t)
212+
defer ctrl.Finish()
213+
214+
mockStore := NewMockmessageStore(ctrl)
215+
pub := setupPublisherTest(t, mockStore)
216+
217+
require.NoError(t, pub.Close())
218+
219+
msg := queue.NewMessage("msg1", []byte("p"), "part1", nil)
220+
err := pub.PublishAfter(context.Background(), "test_topic", msg, 1000)
221+
require.Error(t, err)
222+
require.True(t, errors.Is(err, ErrPublisherClosed))
223+
}
224+
163225
func TestPublisher_Close(t *testing.T) {
164226
ctrl := gomock.NewController(t)
165227
defer ctrl.Finish()

extension/queue/mysql/schema/queue_messages.sql

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@ CREATE TABLE IF NOT EXISTS queue_messages (
2525
created_at BIGINT UNSIGNED NOT NULL,
2626
published_at BIGINT UNSIGNED NOT NULL,
2727

28+
-- visible_after defers delivery: subscribers skip rows where visible_after > now.
29+
-- 0 (the default) means immediately visible. Set by Publisher.PublishAfter
30+
-- to schedule a fresh message for delivery at a future time without
31+
-- consuming a delivery_state retry slot (used e.g. by the orchestrator's
32+
-- buildstatus polling consumer to space out Status calls).
33+
visible_after BIGINT UNSIGNED NOT NULL DEFAULT 0,
34+
2835
-- DLQ-specific fields (0/"" for normal messages, populated for DLQ messages)
2936
failed_at BIGINT UNSIGNED NOT NULL,
3037
-- failure_count stores how many times the message failed on the ORIGINAL topic before moving to DLQ

0 commit comments

Comments
 (0)