Writing the row and the event in one transaction

The outbox is the smallest correct answer to the dual write problem, and the implementation is one insert and a worker.

CREATE TABLE outbox (
  id BIGINT AUTO_INCREMENT PRIMARY KEY,
  topic VARCHAR(128) NOT NULL,
  payload JSON NOT NULL,
  created_at DATETIME(6) NOT NULL,
  published_at DATETIME(6) NULL,
  INDEX idx_unpublished (published_at, id)
);

-- the relay, every second:
-- SELECT ... WHERE published_at IS NULL ORDER BY id LIMIT 100

The index on (published_at, id) is what keeps the relay query fast as the table grows, and without it the relay scans everything ever published. Pruning is a separate job and is needed within weeks. Polling every second is unglamorous and works; reading the binlog is the sophisticated version and is considerably more machinery for the same guarantee.