victorzhuk/skills

z-go-messaging

At-least-once messaging for Go on Kafka, RabbitMQ, or NATS — idempotent-consumer dedup, the transactional outbox, consumer-group rebalancing, and dead-letter queues.

Ver código fuente

Cómo usar

  1. Copia el comando y revisa el paquete antes de instalarlo en tu entorno de agente.
  2. Consulta las herramientas, credenciales y entornos necesarios en el documento original.
  3. Describe las entradas y el resultado deseado; contrasta la salida con las fuentes.
npx skills add https://github.com/victorzhuk/skills --skill z-go-messaging

Antes de empezar

  • La instalación no incluye suscripción al modelo, cuota API ni permisos sobre tus archivos.
  • Las estrellas e instalaciones reflejan actividad, no una prueba independiente de calidad o seguridad.
Documento original del Skill

Contenido del repositorio de origen con títulos, ejemplos, código, tablas, enlaces e imágenes preservados.

Go messaging

The moment a consumer handler touches anything outside the broker's own transactional boundary — a Postgres write, an HTTP call, an email — delivery is at-least-once, full stop. Kafka's exactly-once semantics (idempotent producer + transactions) only hold for a consume-transform-produce loop that never leaves Kafka. RabbitMQ and NATS core have no exactly-once mode at all. Design every consumer as if redelivery will happen, because it will: broker crashes, rebalances, client restarts, and network partitions all trigger reprocessing of already-handled messages.

Idempotent consumers

Two ways to make a handler safe under redelivery, in order of preference:

StrategyWhenCost
Idempotent-by-constructionRepeating the write produces the same result; an upsert that increments a counter does not qualifyNo extra dedup table
Dedup table keyed by message IDNon-idempotent counters, appends, or multi-statement effects in the same database transactionOne extra row + index per message

Dedup table pattern — the dedup INSERT and the business write share one transaction, so either both land or neither does:

External HTTP, email, or payment calls are outside this transaction. Use an outbox for durable dispatch and downstream idempotency for repeated calls.

go
tx, err := pool.Begin(ctx)
if err != nil {
    return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback(ctx)

tag, err := tx.Exec(ctx,
    `INSERT INTO processed_messages (message_id) VALUES ($1) ON CONFLICT DO NOTHING`, msg.ID)
if err != nil {
    return fmt.Errorf("mark processed: %w", err)
}
if tag.RowsAffected() == 0 {
    return nil // already applied on a prior delivery — ack and move on
}

if err := applyBalanceChange(ctx, tx, msg); err != nil {
    return fmt.Errorf("apply balance change: %w", err)
}
return tx.Commit(ctx)

Full transaction/rollback boilerplate (panic-safe defer, isolation level): see [[z-go-database]]. Only ack the message after this transaction commits — see commit-after-process below.

Transactional outbox

This is the one rule that makes the pattern work, and where it most often breaks: the outbox INSERT must run in the exact same Postgres transaction as the business write. Never publish to the broker from inside that transaction — a broker call cannot roll back with the database, so a "publish, then commit" or "commit, then publish with no fallback" order both have a window where one side succeeds and the other doesn't.

The flow:

  1. Business transaction — one commit writes the domain row(s) and an outbox row (event type, payload, headers, created_at, published_at NULL).
  2. Relay (separate process or goroutine, polling or CDC) — reads unpublished outbox rows, publishes each to the broker, then marks it published. Publish happens strictly after the business transaction has committed.
  3. Crash recovery — if the relay dies between publish and mark-published, it republishes that row on restart. The outbox makes publishing at-least-once, not exactly-once: it guarantees the message is never lost, and the idempotent-consumer pattern above is what absorbs the resulting duplicate. Neither pattern alone is enough; they close the loop together.
go
tx, err := pool.Begin(ctx)
if err != nil {
    return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback(ctx)

if _, err := tx.Exec(ctx,
    `INSERT INTO orders (id, customer_id, total) VALUES ($1, $2, $3)`,
    order.ID, order.CustomerID, order.Total); err != nil {
    return fmt.Errorf("insert order: %w", err)
}
if _, err := tx.Exec(ctx,
    `INSERT INTO outbox (id, topic, payload, created_at) VALUES ($1, $2, $3, now())`,
    order.ID, "orders.created", payload); err != nil {
    return fmt.Errorf("insert outbox row: %w", err)
}
return tx.Commit(ctx)

Relay poll, FOR UPDATE SKIP LOCKED so multiple relay instances don't double-publish the same row. The lock only holds for the life of the transaction that took it — run the select and the publish loop inside one explicit Begin/Commit, never as a bare pool.Query (an unwrapped statement is its own implicit transaction and releases the row locks the instant it finishes, before producer.Publish even runs):

go
tx, err := pool.Begin(ctx)
if err != nil {
    return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback(ctx)

rows, err := tx.Query(ctx, `
    SELECT id, topic, payload FROM outbox
    WHERE published_at IS NULL
    ORDER BY id
    FOR UPDATE SKIP LOCKED
    LIMIT 100`)
if err != nil {
    return fmt.Errorf("select outbox batch: %w", err)
}

type outboxRow struct {
    id      int64
    topic   string
    payload []byte
}
var batch []outboxRow
for rows.Next() {
    var r outboxRow
    if err := rows.Scan(&r.id, &r.topic, &r.payload); err != nil {
        rows.Close()
        return fmt.Errorf("scan outbox row: %w", err)
    }
    batch = append(batch, r)
}
rows.Close()
if err := rows.Err(); err != nil {
    return fmt.Errorf("read outbox batch: %w", err)
}

for _, r := range batch {
    if err := producer.Publish(ctx, r.topic, r.payload); err != nil {
        return fmt.Errorf("publish outbox row %d: %w", r.id, err)
    }
    if _, err := tx.Exec(ctx,
        `UPDATE outbox SET published_at = now() WHERE id = $1`, r.id); err != nil {
        return fmt.Errorf("mark outbox row %d published: %w", r.id, err)
    }
}
return tx.Commit(ctx)

Holding the transaction open across the publish loop keeps the row locks (and the connection) held for as long as the broker calls take — fine at a LIMIT of 100, shrink the batch if publish latency makes that too long a hold. A polling relay (100ms–1s interval) is the default. Reach for CDC (Debezium or logical replication tailing the outbox table) only once poll latency is a proven problem — it removes the poll interval at the cost of a CDC pipeline to run and monitor.

Consumer-group semantics

BrokerGrouping unitOrdering guaranteeRebalance trigger
KafkaConsumer group spread across partitionsPer-partition only — never across partitions, never per-topicMember join/leave, partition count change
RabbitMQCompeting consumers on one queueNone, unless a single active consumer or a consistent-hash exchange pins a key to one queueConsumer disconnect, queue failover
NATS JetStreamDurable consumer, work-queue policyPer-subject with a single ordered consumer; a shared work-queue consumer gives noneConsumer/durable restart

Commit-after-process, never before. Ack or commit only after the handler returns success. Interval-based auto-commit/auto-ack trades this away for convenience — a crash between "received" and "processed" loses the message even though the offset was already committed. Treat auto-commit as an opt-in for loss-tolerant, already-idempotent paths, not the default.

Graceful shutdown drains before it disconnects. On SIGTERM (Kubernetes sends this, then waits terminationGracePeriodSeconds before SIGKILL): stop pulling new messages first, let in-flight handlers finish under a bounded drain timeout, commit/ack their results, then close the client. A rebalance or pod kill that yanks the connection before drain either loses in-flight work or forces redelivery — which is exactly why idempotency is a prerequisite for this pattern, not an afterthought.

Poison messages

Bound retries with backoff, then dead-letter — never loop forever on a message that can't be processed:

BrokerNative supportPattern
KafkaNoneTrack attempt count in a header or side table; retry with backoff (in-process, or a delayed retry topic); after N attempts, produce to <topic>.dlq with original headers + failure reason, then commit past the offset so later valid records on that partition aren't blocked
RabbitMQNative dead-letteringSet x-dead-letter-exchange (+ optional routing key) on the queue; Nack(false, false) or exceeding x-message-ttl/a delivery-count limit routes the message to the DLX automatically
NATS JetStreamMaxDeliver on the consumer configOnce exceeded, the message stops redelivering; wire an advisory-subject handler to move it into your own DLQ if you need the payload preserved

Producer keys and ordering

BrokerWhat the key controlsNo key
Kafkahash(key) % partitions (unkeyed records go round-robin/sticky-random)No ordering guarantee across messages
RabbitMQRouting key → queue binding, not partitioningStill delivered correctly; ordering only holds with a single consumer on that queue
NATSSubject hierarchy, not a key fieldNo partitioning concept; ordering is per-subject with a single ordered consumer

Changing a Kafka topic's partition count changes hash(key) % partitions for every key on that topic — don't repartition a topic whose consumers depend on historical per-key ordering.

Do not

  • Assume Kafka's exactly-once semantics extend past the broker boundary — any external write (DB, HTTP, email) inside the handler makes it at-least-once regardless of producer/consumer transaction config.
  • Publish to the broker from inside the business database transaction — the two systems can't commit atomically together; use the outbox.
  • Skip the outbox and publish directly after commit with no fallback — a crash in that gap silently drops the message.
  • Ack or commit an offset before the handler's work is durably applied.
  • Let a poison message retry unbounded — bound it, then DLQ it.
  • Ignore the producer key when message order matters to a consumer.
  • Close a consumer/channel on shutdown without draining in-flight work first.

Verify

sh
timeout 15m go test -parallel 4 -p 2 -race -timeout 10m ./internal/consumer/... ./internal/outbox/...
  • Idempotency: replay the same message ID twice through the handler in a test; assert the business effect applied exactly once.
  • Outbox: kill the relay (or fail the publish call) between insert and publish in a test; assert the row is still published on the next poll and the business write was never lost.
  • Poison path: force N handler failures then assert the message lands on the DLQ/DLX and stops redelivering.
  • Shutdown: send the drain signal mid-batch in a test harness; assert in-flight messages finish and commit before the client closes.

Reference

  • references/brokers.md — client choice per broker (franz-go vs segmentio/kafka-go, amqp091-go, nats.go/JetStream) and minimal consume-loop shapes.

See also

Use [[z-cloudevents]] for event envelopes, identity, and protocol mappings.

Context cancellation inside a consumer loop: [[z-go-context]]. Consumer lag, DLQ counters, and per-message tracing: [[z-go-observability]]. Repository/transaction wiring beneath the outbox: [[z-go-database]]. Worker-pool shape for parallel handler execution: [[z-go-concurrency]].

del mismo repositorio

Más Skills

Todos los Skills
victorzhuk
Comunidad

z-accessibility

Audit and improve web accessibility following WCAG 2.2 guidelines. Use when asked to "improve accessibility", "a11y audit", "WCAG compliance", "screen reader support", "keyboard navigation", or "make accessible".

instalaciones
1
GitHub Stars
0
Actualizado
22 oct
victorzhuk
Comunidad

z-algo-core

DS/algorithm selection judgment for production code, not competitive tricks — hash map for membership/dedup, slice+sort beats a tree until proven, heap for top-k, two-pointer/sliding-window for scans, BFS/DFS/topo-sort for dependency shapes. Triggers on Big-O, hash map vs tree. Does not cover profiling/tuning; see [[z-go-performance]].

instalaciones
1
GitHub Stars
0
Actualizado
22 oct
victorzhuk
Comunidad

z-bdd

BDD acceptance contracts for humans and agents across UI, API, and CLI products. Use for Gherkin, Given-When-Then, E2E acceptance, or scenario evidence. Define repeatable scenarios with explicit assertions and required outcomes. Test selection belongs to [[z-testing-strategy]]. Go harness wiring belongs to [[z-go-bdd]].

instalaciones
1
GitHub Stars
0
Actualizado
22 oct
victorzhuk
Comunidad

z-codegraph

Use an existing CodeGraph integration for call graphs, impact analysis, affected tests, or route discovery. Discover installed tools and index freshness; follow host LSP precedence and verify current source. Do not initialize indexes without user authorization.

instalaciones
1
GitHub Stars
0
Actualizado
22 oct