Outbox (watch and ack)
A producer inserts events. A consumer reads them with watch, a where filter and ack. After a restart it continues from its stored position.
When to use
You need a reliable queue next to your data, without Kafka or Redis. Delivery is at least once, so the consumer must be safe to run twice on the same event.
Collection
Declared in examples/ndb.yml:
outbox:
write: engine
id: event_id
time: created_at
indexes: [topic]
durability: strict
receipts:
id: event_idProduce
Each insert is one event. The topic is indexed.
topics := []string{"invoice.paid", "user.signup", "invoice.paid", "user.signup", "invoice.paid"}
for i, topic := range topics {
id := fmt.Sprintf("evt-%d", i+1)
if _, err := outbox.Insert(ctx, Event{EventID: id, Topic: topic, CreatedAt: time.Now().UTC()}); err != nil {
return err
}
fmt.Printf("produce %s %s\n", id, topic)
}topics = %w[invoice.paid user.signup invoice.paid user.signup invoice.paid]
topics.each_with_index do |topic, i|
id = "evt-#{i + 1}"
ndb.insert("outbox", [{ event_id: id, topic: topic, created_at: Time.now.utc.iso8601(3) }])
puts "produce #{id} #{topic}"
endconst topics = ['invoice.paid', 'user.signup', 'invoice.paid', 'user.signup', 'invoice.paid']
for (const [i, topic] of topics.entries()) {
const id = `evt-${i + 1}`
await outbox.insert({ event_id: id, topic, created_at: new Date() })
console.log(`produce ${id} ${topic}`)
}i=0
for topic in invoice.paid user.signup invoice.paid user.signup invoice.paid; do
i=$((i + 1))
ndb POST /v1/c/outbox/insert "{\"insert\":[{\"event_id\":\"evt-$i\",\"topic\":\"$topic\",\"created_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}]}" >/dev/null
echo "produce evt-$i $topic"
doneThe work, safe to repeat
The receipt id is the event id. A second delivery fails with duplicate_id, so the work is not done twice.
// charge is the work of the consumer. The receipt id is the event id, so a second
// delivery of the same event fails with duplicate_id and the work is not done twice.
func charge(ctx context.Context, receipts *ndb.Coll[Receipt], eventID string) error {
_, err := receipts.Insert(ctx, Receipt{EventID: eventID, DoneAt: time.Now().UTC()})
return err
}# The work of the consumer. The receipt id is the event id, so a second delivery of
# the same event fails with duplicate_id and the work is not done twice.
def charge(ndb, event_id)
ndb.insert("receipts", [{ event_id: event_id, done_at: Time.now.utc.iso8601(3) }])
true
rescue Ndb::UniqueViolation
false
end// The work of the consumer. The receipt id is the event id, so a second delivery of
// the same event fails with duplicate_id and the work is not done twice.
async function charge(eventId: string): Promise<boolean> {
try {
await receipts.insert({ event_id: eventId, done_at: new Date() })
return true
} catch (err) {
if (err instanceof DuplicateIdError) {
return false
}
throw err
}
}# The work of the consumer. The receipt id is the event id, so a second delivery of
# the same event fails with duplicate_id (409) and the work is not done twice.
charge() {
[[ "$(ndb_status POST /v1/c/receipts/insert "{\"insert\":[{\"event_id\":\"$1\",\"done_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}]}")" == "200" ]]
}Consume, and crash before the ack
where leaves out other topics, but the stored position still moves over them. The program acks after the work. For evt-3 it "crashes" after the work and before the ack.
paid := ndb.WatchWhere(ndb.Eq("topic", "invoice.paid"))
fmt.Println("consumer billing, run 1 (where topic = invoice.paid):")
for ev, err := range outbox.Watch(ctx, "billing", paid, ndb.Limit(2)) {
if err != nil {
return err
}
if err := charge(ctx, receipts, ev.ID); err != nil {
return err
}
if ev.ID == "evt-3" {
fmt.Println(" evt-3 charged, crash before ack")
break
}
if err := outbox.Ack(ctx, ev); err != nil {
return err
}
fmt.Printf(" %s charged, acked\n", ev.ID)
}paid = { topic: "invoice.paid" }
puts "consumer billing, run 1 (where topic = invoice.paid):"
ndb.watch("outbox", "billing", where: paid, limit: 2) do |ev|
charge(ndb, ev["id"])
if ev["id"] == "evt-3"
puts " evt-3 charged, crash before ack"
break
end
ndb.ack("outbox", "billing", ev["pos"])
puts " #{ev['id']} charged, acked"
endconst paid = { topic: 'invoice.paid' }
console.log('consumer billing, run 1 (where topic = invoice.paid):')
for await (const ev of outbox.watch('billing', { where: paid, limit: 2 })) {
await charge(ev.id)
if (ev.id === 'evt-3') {
console.log(' evt-3 charged, crash before ack')
break
}
await outbox.ack(ev)
console.log(` ${ev.id} charged, acked`)
}watch='{"consumer":"billing","limit":2,"where":{"topic":"invoice.paid"}}'
echo "consumer billing, run 1 (where topic = invoice.paid):"
while read -r pos id; do
charge "$id" || true
if [[ "$id" == "evt-3" ]]; then
echo " evt-3 charged, crash before ack"
break
fi
ack "$pos"
echo " $id charged, acked"
done < <(ndb POST /v1/c/outbox/watch "$watch" | jq -r 'select(.pos) | "\(.pos) \(.id)"')Restart
The same consumer name resumes after the last ack. evt-3 comes again, the receipt shows it was done, and the consumer acks it.
fmt.Println("consumer billing, run 2 (restart, resumes after the last ack):")
for ev, err := range outbox.Watch(ctx, "billing", paid, ndb.Limit(2)) {
if err != nil {
return err
}
err := charge(ctx, receipts, ev.ID)
switch {
case errors.Is(err, ndb.ErrDuplicateID):
fmt.Printf(" %s delivered again, receipt exists, skipped, acked\n", ev.ID)
case err != nil:
return err
default:
fmt.Printf(" %s charged, acked\n", ev.ID)
}
if err := outbox.Ack(ctx, ev); err != nil {
return err
}
}puts "consumer billing, run 2 (restart, resumes after the last ack):"
ndb.watch("outbox", "billing", where: paid, limit: 2) do |ev|
if charge(ndb, ev["id"])
puts " #{ev['id']} charged, acked"
else
puts " #{ev['id']} delivered again, receipt exists, skipped, acked"
end
ndb.ack("outbox", "billing", ev["pos"])
endconsole.log('consumer billing, run 2 (restart, resumes after the last ack):')
for await (const ev of outbox.watch('billing', { where: paid, limit: 2 })) {
if (await charge(ev.id)) {
console.log(` ${ev.id} charged, acked`)
} else {
console.log(` ${ev.id} delivered again, receipt exists, skipped, acked`)
}
await outbox.ack(ev)
}echo "consumer billing, run 2 (restart, resumes after the last ack):"
while read -r pos id; do
if charge "$id"; then
echo " $id charged, acked"
else
echo " $id delivered again, receipt exists, skipped, acked"
fi
ack "$pos"
done < <(ndb POST /v1/c/outbox/watch "$watch" | jq -r 'select(.pos) | "\(.pos) \(.id)"')Output
The four programs print the same text.
produce evt-1 invoice.paid
produce evt-2 user.signup
produce evt-3 invoice.paid
produce evt-4 user.signup
produce evt-5 invoice.paid
consumer billing, run 1 (where topic = invoice.paid):
evt-1 charged, acked
evt-3 charged, crash before ack
consumer billing, run 2 (restart, resumes after the last ack):
evt-3 delivered again, receipt exists, skipped, acked
evt-5 charged, acked
receipts: 3 (one per invoice, none twice)Notes
- The consumer name (
billing) is the stored cursor. Keep it stable. limit: 2ends the stream after two events, so the example finishes. A real consumer leaves it out and loops.- After
watch_lagged, callwatchagain: it resumes after the last ack.history_truncatedmeanswal_retentionremoved the position. See watch and ack. - The Ruby model layer (
ndb-rails) has nowatch. Use the client, as this example does. - Every SDK supports every step of this example.
Run it
cd examples
make run-outbox LANG=go # go, ruby, ts or curl
make run-outbox # every languageSource: examples/outbox. The programs read NDB_URL and NDB_TOKEN, and they can run again and again.