queue

package
v0.1.23 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 1, 2026 License: Apache-2.0 Imports: 11 Imported by: 0

Documentation

Overview

Package queue is a durable, per-destination delivery queue.

Without it, a destination that is down for two minutes loses everything sent during those two minutes: retries happen in memory, and when they run out the message is recorded as failed and gone. With it, the message is on disk and keeps its place until the destination comes back.

Two design decisions shape everything else.

**Order is preserved per destination, and that means a queued destination stops taking the fast path.** HL7 is a stream of events about the same patients: an A01 admits, an A03 discharges. If a failed A01 goes to the queue while the next A03 is delivered directly, the receiving system sees a discharge for a patient it never admitted. So once anything is queued for a destination, everything for that destination queues behind it until the queue drains. That costs throughput during an outage and it is not optional, because the alternative is silent clinical nonsense. It is also the specific thing that goes wrong in other engines when somebody turns on concurrent queue threads to make a backlog drain faster.

**A queued message is an accepted message.** The sender is told AA, because the bytes are on disk and fsynced before the acknowledgement goes out. Saying AE would make a correctly functioning store-and-forward queue look like a fault and invite the sender to resend what we already hold.

Index

Constants

View Source
const (
	DefaultBackoff    = 5 * time.Second
	DefaultMaxBackoff = time.Minute
	DefaultTimeout    = 30 * time.Second
)

Defaults for anything left unset. The backoff ceiling is a minute rather than something larger because a receiver that comes back after an outage should be found quickly; waiting an hour to notice would turn a two-minute blip into an hour-long backlog.

View Source
const DefaultIdle = 2 * time.Second

DefaultIdle is the poll interval when the queue is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type DepthRow

type DepthRow struct {
	Channel     string    `json:"channel"`
	Destination string    `json:"destination"`
	Pending     int       `json:"pending"`
	Failed      int       `json:"failed"`
	Oldest      time.Time `json:"oldest,omitzero"`
	// OldestSeconds is the age of the oldest pending item. This is the number to
	// alert on: depth alone cannot distinguish a busy queue that is draining from
	// a small one that has been stuck since Tuesday.
	OldestSeconds float64 `json:"oldestSeconds"`
	// MaxAttempts is the highest attempt count among pending items, which is what
	// says "this is being retried and not progressing".
	MaxAttempts int `json:"maxAttempts"`
}

DepthRow is the per-destination summary the dashboard shows.

type Item

type Item struct {
	ID          int64     `json:"id"`
	Channel     string    `json:"channel"`
	Destination string    `json:"destination"`
	ControlID   string    `json:"controlId"`
	MessageType string    `json:"messageType"`
	Raw         []byte    `json:"-"`
	Size        int       `json:"size"`
	State       State     `json:"state"`
	Attempts    int       `json:"attempts"`
	EnqueuedAt  time.Time `json:"enqueuedAt"`
	NextAttempt time.Time `json:"nextAttempt"`
	LastAttempt time.Time `json:"lastAttempt,omitzero"`
	LastError   string    `json:"lastError,omitempty"`
	// Reason is why it entered the queue, kept separate from LastError so the
	// original cause survives a series of different later failures.
	Reason string `json:"reason,omitempty"`
}

Item is one queued delivery.

func (Item) Age

func (i Item) Age(now time.Time) time.Duration

Age reports how long the item has been waiting. This is the number that tells an operator whether a backlog is draining or stuck.

type Observer

type Observer interface {
	QueueDelivered(channel, destination string, attempts int, waited time.Duration)
	QueueRetried(channel, destination string, attempts int, cause string)
	QueueGaveUp(channel, destination string, attempts int, cause string)
}

Observer is told what the worker did, so metrics and alerts can be driven without the queue importing either.

type Policy

type Policy struct {
	// MaxAttempts is how many times the queue tries before giving up. Zero means
	// keep trying, which is a legitimate choice for a feed that must not lose
	// anything, but it has to be chosen rather than inherited.
	MaxAttempts int

	// Backoff is the first delay, doubling up to MaxBackoff.
	Backoff    time.Duration
	MaxBackoff time.Duration

	// Timeout bounds one attempt.
	Timeout time.Duration
}

Policy bounds how hard a queued message is retried.

type Query

type Query struct {
	Channel     string
	Destination string
	State       State
	// Limit defaults to 100 and is capped at 1000, because this feeds a page and
	// an unbounded query against a large backlog would be a denial of service on
	// ourselves.
	Limit  int
	Offset int
}

Query selects items for the interface.

type Sender

type Sender interface {
	Send(ctx context.Context, raw []byte) error
	Describe() string
}

Sender delivers one message. It is the same contract the engine uses, so a destination's real sender drains the queue rather than a reimplementation of it — otherwise the retry path could succeed where the live path fails, or worse, the other way round.

type State

type State string

State is where a queued message is in its life.

const (
	// Pending is waiting for its next attempt.
	Pending State = "pending"
	// Delivered means it eventually got there. Kept briefly so somebody watching
	// a backlog drain can see it happening.
	Delivered State = "delivered"
	// Failed means attempts were exhausted, or an operator gave up on it. It stays
	// until removed, because a message that left the queue without arriving is
	// the one somebody needs to look at.
	Failed State = "failed"
	// Skipped means an operator deliberately abandoned it. Distinct from failed:
	// one is the system giving up and the other is a person deciding, and
	// conflating them loses who is accountable.
	Skipped State = "skipped"
)

type Store

type Store struct {
	// contains filtered or unexported fields
}

Store persists the queue.

func NewStore

func NewStore(db, read *sql.DB) (*Store, error)

NewStore prepares the schema.

Pass the write handle and, separately, a read handle. Queue depth is polled by the dashboard, and that poll must not sit in front of a delivery.

func (*Store) Blocked

func (s *Store) Blocked(ctx context.Context, channel, destination string) (bool, error)

Blocked reports whether anything is waiting for a destination, regardless of whether its next attempt is due.

This is what makes ordering work. A destination with a pending item must not take the direct path, or a later message would overtake an earlier one. Note it deliberately ignores next_attempt: an item backing off is still ahead in the queue.

func (*Store) Depth

func (s *Store) Depth(ctx context.Context) ([]DepthRow, error)

Depth summarises the queue per destination.

func (*Store) Drain

func (s *Store) Drain(ctx context.Context, channel, destination, who string) (int64, error)

Drain skips every pending item for a destination. Used when a receiver has been down long enough that the backlog is no longer clinically useful — a week of stale admissions replayed at once can do more harm than never sending them.

func (*Store) Enqueue

func (s *Store) Enqueue(ctx context.Context, item Item) (int64, error)

Enqueue adds a message, returning its id.

The write is committed before this returns, so a caller may acknowledge the sender once it has. That ordering is the entire promise of the queue and it must not be relaxed for throughput.

func (*Store) Erase added in v0.1.18

func (s *Store) Erase(ctx context.Context, ids []int64) (int64, error)

Erase deletes items whatever their state, for a data protection erasure: a pending delivery of an erased person's message is not sent. A worker that already took one finds it gone when it reports back, which changes nothing.

func (*Store) GaveUp

func (s *Store) GaveUp(ctx context.Context, id int64, attempts int, cause string) error

GaveUp marks an item failed after exhausting its attempts.

func (*Store) List

func (s *Store) List(ctx context.Context, q Query) ([]Item, int, error)

List returns matching items, newest first, and the total count.

Payloads are not included. A queue page showing a hundred messages does not need a hundred payloads, and shipping them would put clinical content into a response that only needed counts.

func (*Store) Next

func (s *Store) Next(ctx context.Context, channel, destination string, now time.Time) (*Item, time.Duration, error)

Next returns the head of a destination's queue when it is ready to be sent.

When the head exists but is still backing off, it returns a nil item and how long to wait. That distinction is the whole ordering guarantee, and getting it wrong is subtle: an earlier version selected the oldest item *whose next attempt was due*, which reads as reasonable and is badly broken. A message backing off after a failure is not due, so the query skipped it and returned a later message that had never been tried — and the later message went out first. The queue preserved order right up until the first failure, which is precisely when order starts to matter.

So the head is the head. If it is waiting, everything behind it waits too. That is the cost of ordering and it is not negotiable.

It returns one item, not a batch, because delivering in order means delivering one at a time. A batch would only help if they could be sent concurrently, which is the thing that reorders them.

func (*Store) Payload

func (s *Store) Payload(ctx context.Context, id int64) ([]byte, error)

Payload returns the stored bytes for one item, for the inspector.

func (*Store) PendingDestinations

func (s *Store) PendingDestinations(ctx context.Context) ([]DepthRow, error)

PendingDestinations lists every destination with work waiting, so a worker starting up knows what to drain without being told.

This is what makes the queue survive a restart rather than merely persist: the rows would be there either way, but nothing would look at them.

func (*Store) Purge

func (s *Store) Purge(ctx context.Context, before time.Time) (int64, error)

Purge deletes finished items older than a cutoff, and reports how many went.

func (*Store) Reader

func (s *Store) Reader() *sql.DB

Reader exposes the handle reports use, for callers in this package that build their own queries.

func (*Store) Remove

func (s *Store) Remove(ctx context.Context, id int64) (int64, error)

Remove deletes items that have finished, one by one or in bulk.

Pending items are never deleted here. Deleting a message that has not been delivered and has not been explicitly abandoned would lose it silently, so it has to be skipped first — which records that somebody chose to.

func (*Store) Retry

func (s *Store) Retry(ctx context.Context, id int64, attempts int, next time.Time, cause string) error

Retry records a failed attempt and schedules the next one.

func (*Store) RetryNow

func (s *Store) RetryNow(ctx context.Context, id int64, channel, destination string) (int64, error)

RetryNow brings forward the next attempt for one item, or for every pending item on a destination when id is zero.

It resets the attempt counter, because an operator retrying by hand has usually just fixed something, and counting the failures from before the fix against the new attempt budget would abandon the message immediately.

func (*Store) ScanPayloads added in v0.1.18

func (s *Store) ScanPayloads(ctx context.Context, fn func(id int64, channel, destination, state string, raw []byte) error) error

ScanPayloads calls fn for every item still held (pending, failed or given up), with its payload. It is for finding a data subject's queued messages, which have no identity index: the queue is short-lived, so reading it whole is acceptable.

func (*Store) Skip

func (s *Store) Skip(ctx context.Context, id int64, who string) (int64, error)

Skip abandons an item without delivering it, recording that a person decided to. The message stays in the table so the decision is auditable.

func (*Store) Succeeded

func (s *Store) Succeeded(ctx context.Context, id int64, attempts int) error

Succeeded marks an item delivered.

type Worker

type Worker struct {
	Store       *Store
	Channel     string
	Destination string
	Sender      Sender
	Policy      Policy
	Log         *slog.Logger
	Observer    Observer

	// Idle is how long to wait before looking again when there was nothing to do.
	// Short enough that a recovered destination is noticed quickly, long enough
	// that an empty queue is not a busy loop.
	Idle time.Duration
	// contains filtered or unexported fields
}

Worker drains one destination's queue, in order, one message at a time.

One worker per destination is the whole point. Running several would drain faster and reorder the messages, and an A03 arriving before its A01 is a receiving system being told about a discharge for a patient it has never heard of.

func (*Worker) Run

func (w *Worker) Run(ctx context.Context)

Run drains until the context is cancelled.

func (*Worker) Wake

func (w *Worker) Wake()

Wake asks the worker to look now rather than at the next poll. Called when something is enqueued, so the first retry after a failure is prompt.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL