gotasks

package module
v0.8.0 Latest Latest
Warning

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

Go to latest
Published: Sep 20, 2026 License: MIT Imports: 12 Imported by: 0

README

gotasks

CI

Persistent, atomic, asynchronous task processing for Go — backed by a pluggable store (MongoDB first), with typed payloads, retries with backoff, scheduling, and lease-based fencing so no two workers ever run the same task.

go get github.com/ameenkh/gotasks

Why

  • Atomic claiming — one findOneAndUpdate per claim; N workers across N machines never take the same task.
  • Lease fencing — every claim gets a fresh lease token; a worker whose lease was reclaimed can no longer complete/fail the task.
  • Railway workers — workers keep claiming while tasks keep coming, and only sleep the poll interval when the queue is empty.
  • Change-stream wakeup — on replica sets, idle managers are woken by Mongo's change stream in milliseconds instead of polling (automatic; falls back to polling on standalone servers).
  • Typed API — payloads and handlers are generic; no map[string]interface{}.
  • Inspectable — tasks are plain BSON documents; open them in mongosh or Compass, payloads included.

Quick start

store, err := mongostore.New(ctx, "mongodb://localhost:27017",
    mongostore.WithDatabase("myapp"),
)

m, err := gotasks.New(store,
    // Queues are explicit, like Kafka topics — no default queue exists.
    // TTL = task lifetime measured from run_at (0 = keep forever).
    gotasks.WithQueues(gotasks.QueuePolicy{Name: "emails", TTL: 7 * 24 * time.Hour}),
    gotasks.WithWorkers(8),
    gotasks.WithLeaseTime(time.Minute),
)

type EmailPayload struct {
    To      string `json:"to"`
    Subject string `json:"subject"`
}

gotasks.RegisterHandler(m, "email",
    func(ctx context.Context, task *gotasks.Task, p EmailPayload) (any, error) {
        return sendEmail(ctx, p) // returned value is stored as the task result
    })

// From anywhere (workers optional in the enqueuing process); addressing is
// positional (queue, then type), knobs go in an optional TaskPolicy:
id, err := gotasks.Enqueue(ctx, m, "emails", "email", EmailPayload{To: "a@b.c", Subject: "hi"})

// Scheduled / delayed / at-most-once / idempotent / custom lifetime:
gotasks.Enqueue(ctx, m, "emails", "email", p, gotasks.TaskPolicy{RunAt: tomorrow})
gotasks.Enqueue(ctx, m, "emails", "email", p, gotasks.TaskPolicy{Delay: 10 * time.Minute})
gotasks.Enqueue(ctx, m, "emails", "provision", p, gotasks.TaskPolicy{MaxAttempts: 1})

// At most one active task per key; a conflict returns the existing id
// and an error matching gotasks.ErrDuplicateTask:
id, err = gotasks.Enqueue(ctx, m, "emails", "sync", p, gotasks.TaskPolicy{UniqueKey: "tenant-42"})

// Dead-letter management:
m.Requeue(ctx, id)             // reset one dead task (attempts back to 0)
m.RequeueDead(ctx, "email")    // requeue all dead email tasks ("" = all)

// Blocking run (Ctrl-C to drain and exit), or Start()/Stop(ctx):
m.Run(ctx)

See examples/ for runnable scenarios covering every option: scheduling, retries + dead-letter + requeue, unique keys, heartbeat, at-most-once, batch fan-out, and retention.

Task lifecycle

pending ──claim (atomic, +1 attempt, new lease)──▶ running ──ok──▶ done
   ▲                                                  │
   ├────── retry: run_at = now + backoff ──────error──┤
   │            (while attempts < max_attempts)       └──▶ dead
   └───────────── Requeue / RequeueDead ◀─────────────────────┘
  • A claim is an attempt: attempts is incremented by the claim itself, so work lost to a crashed worker still counts.
  • An opt-in heartbeat (WithHeartbeat(), or WithHeartbeatInterval(d)) extends the running task's lease every LeaseTime/3, so handlers may run longer than LeaseTime. If the heartbeat finds the lease was lost, the handler's context is cancelled. Off by default: without it, make sure LeaseTime exceeds your longest handler run (or call Manager.ExtendLease from inside the handler).
  • A running task whose lease (leased_until) expired is reclaimed by the next claim — but only while attempts < max_attempts. Setting max_attempts: 1 therefore gives at-most-once execution: never retried on error, never re-run after a worker dies.
  • A reaper periodically marks lease-expired tasks with no attempts left as dead (error: "lease expired"), so nothing lingers as a zombie.
  • dead is the dead-letter state: inspect, then Requeue(id) or RequeueDead(type) to run again with a fresh attempts budget (the errors array is kept as history), or let retention prune them.
  • Unique keys (TaskPolicy.UniqueKey) are held while a task is pending/running (including across retries) and released at done/dead.
  • Every failed attempt is appended to the task's errors array ({at, attempt, worker, message}).

Queues and ordering

Testing your code (no MongoDB needed)

stores/memstore is an in-memory Store with the engine's real semantics (atomic claims, lease fencing, unique keys, requeue, reaping, the registry) — gotasks' own manager test suite runs on it. Unit-test your handlers and enqueue flows in milliseconds, deterministically, no Docker:

st := memstore.New()
m, _ := gotasks.New(st, gotasks.WithQueues(gotasks.QueuePolicy{Name: "emails"}))
gotasks.RegisterHandler(m, "send", mySendHandler)
gotasks.Enqueue(ctx, m, "emails", "send", payload)
m.Start()
// ... poll st.Task(id) for assertions

Not a production store: nothing persists, single process only.

Hooks & middleware

Attach your observability stack without gotasks knowing it exists. Middleware wraps handler execution (tracing spans, timing, log enrichment — first registered is outermost); hooks are observe-only lifecycle callbacks (they run on worker goroutines: keep them fast; a panicking hook is recovered and logged, never affecting the task):

gotasks.New(store,
    gotasks.WithMiddleware(func(next gotasks.HandlerFunc) gotasks.HandlerFunc {
        return func(ctx context.Context, t *gotasks.Task, payload json.RawMessage) (any, error) {
            ctx, span := tracer.Start(ctx, "task."+t.Type)
            defer span.End()
            return next(ctx, t, payload)
        }
    }),
    gotasks.WithHooks(gotasks.Hooks{
        OnDead:     func(t *gotasks.Task, err error) { alerting.Page("task dead: " + t.Type) },
        OnComplete: func(t *gotasks.Task, d time.Duration) { latency.Observe(d.Seconds()) },
        // OnEnqueue, OnClaim, OnFail(err, willRetry) ...
    }),
)

(Reaper-detected zombie deaths don't fire OnDead — no worker context exists there; watch the dead gauge in metrics for those.)

Dashboard

import "github.com/ameenkh/gotasks/dashboard"

// Behind YOUR auth — the dashboard can requeue and delete tasks:
mux.Handle("/gotasks/", http.StripPrefix("/gotasks", dashboard.Handler(store)))

An embeddable, zero-dependency web dashboard (single embedded page, no JavaScript build chain, no external services — it reads the same Mongo collections everything else uses): per-queue overview with live counts and oldest-due age, a task browser with filters, payload/result/error inspection, requeue and delete, per-queue "requeue dead", and a throughput chart fed by the metrics collection. Run go run ./examples/dashboard for a live demo with a workload generator.

Metrics (Mongo-native, opt-in)

gotasks.WithJanitor(gotasks.JanitorConfig{
    // ReapInterval: 60 * time.Second, // stale-zombie sweep (default)
    Metrics: &gotasks.MetricsConfig{Interval: time.Minute, Retention: 7 * 24 * time.Hour},
})

The janitor (the reaper's grown-up form) writes two kinds of documents to the {namespace}_metrics collection — no Prometheus, no agents, the Mongo cluster is the single source of truth:

  • gauges — one doc per queue per window, cluster-wide (managers race; the first insert wins): status counts plus oldest_due_age_ms, the queue-health number (how long has the oldest DUE task been waiting — the same signal as SQS's ApproximateAgeOfOldestMessage).
  • counters — one doc per manager instance per queue per window with exact throughput (enqueued, claimed, done, failed, dead), counted in-process; readers sum across managers. Manager identity is dot-separated: {WithManagerName}.{random} per instance, workers lease as {managerID}.worker.{n}.

Metrics prune themselves via their own TTL index (Retention, default 7d) — deliberately independent of any queue's task TTL: task TTL answers "how long does the work record matter" (per queue), metrics retention answers "how far back should the dashboard remember" (one ops decision), and the two often point in opposite directions (short-lived tasks still deserve weeks of throughput history). Off by default.

Declared queues are recorded in a queues registry (a sibling <tasks>_queues collection): the first declaration registers a queue's policy, an identical redeclaration is a no-op, and a manager declaring different settings for a registered queue fails fast with ErrQueueConflict — policy drift across producer/consumer deployments is a startup error, never silent behavior. Deliberate changes go through Store.SetQueuePolicy; Store.Queues(ctx) lists the registry.

Queues use explicit subscription, like every queue system: tasks carry a queue label (gotasks.WithQueue("emails") at enqueue; "default" otherwise), and a manager consumes exactly the queues it declares — gotasks.WithQueues("emails"), default ["default"]. Nothing is consumed implicitly: a named queue needs a manager naming it (and WithQueues replaces the set, so include "default" if the manager serves it too). Run one manager per queue — sharing a process and a Mongo client is fine — to give each queue its own worker pool. Because every claim names its queues, claims are always index-targeted: measured ~97x fewer documents examined per claim versus label-only filtering at a 99:1 queue skew.

Claims are unsorted by default: the store takes whichever runnable task is cheapest — faster and less contended. Scheduling is unaffected (run_at is enforced by the claim filter, never the ordering). Opt into oldest-first claiming with gotasks.WithFIFO() when task staleness must stay bounded under overload; note that with parallel workers, completion order is never guaranteed in any mode.

Pipeline mode (big pipelines)

By default (single mode) each worker claims, handles, and acknowledges its tasks independently — simple, immediate visibility of every state change. For high-throughput pipelines, switch on pipeline mode:

m, err := gotasks.New(store,
    gotasks.WithWorkers(16),
    gotasks.WithPipelineMode(gotasks.PipelineConfig{}), // all defaults
    // or tuned:
    // gotasks.WithPipelineMode(gotasks.PipelineConfig{
    //     ClaimBatch:       64,              // tasks per fetch (default 32)
    //     QueueLease:       2 * time.Minute, // channel-wait budget (default LeaseTime)
    //     FinalizeBatch:    128,             // outcomes per bulk ack (default 2x ClaimBatch)
    //     FinalizeInterval: 300 * time.Millisecond,
    //     NoFinalize:       false,           // true = immediate acks, batched claims only
    // }),
)

Mega fan-outs are first-class on the enqueue side too: EnqueueMany writes in chunks (default 1000, mongostore.WithEnqueueChunkSize), returns ids aligned with the input, and reports partial failures per index via *gotasks.PartialEnqueueError — a 100k-task broadcast neither builds one giant wire message nor fails opaquely.

One fetcher per process claims batches under a queue lease and feeds a bounded channel; workers revalidate ownership with a single point-write (the start-of-work handshake) which starts the normal task lease. This amortizes the contended claim query across the batch and moves head-of-queue racing from per-worker to per-process. A task that waits in the channel longer than the queue lease is dropped locally, unrun, and reclaimed — exactly-once execution per attempt is preserved (see the scenario tests).

Pipeline mode also enables batched finalization by default: finished tasks' Complete/Fail writes are buffered and flushed as one bulk write (on size, interval, lease-deadline, or shutdown drain — whichever comes first). Safety policy: at-most-once tasks and outcomes whose remaining lease is inside the safety margin bypass the buffer and write immediately, so a buffered outcome can never outlive its lease while the process is alive. A crash loses at most one flush interval of finished-but-unrecorded outcomes — reclaimed and re-run under the standard at-least-once contract. Opt out with NoFinalize: true.

Change-stream wakeup

On a replica set (any Atlas cluster; a single-node --replSet works too), the manager automatically tails the tasks collection's change stream and wakes the moment work appears — median enqueue→pickup latency is ~10ms in the scenario tests, versus half the poll interval otherwise. Ownership is untouched: the stream only wakes claimers; atomic claims still decide who runs what. Future run_ats (scheduled tasks, retry backoffs) are tracked by a min-timer fed from the same events, and a long safety-net poll (WithFallbackPoll, default 30s) covers stream gaps. On standalone servers this silently falls back to plain polling; WithoutChangeStream() forces polling explicitly.

Chaos-tested

The crash-recovery claims are not just reasoned about: the chaos test (GOTASKS_CHAOS=1 go test -run TestChaos ., also a CI step) SIGKILLs real worker processes every 1–3 seconds under a continuous task stream, then audits the database against an execution ledger written by the handlers themselves: nothing lost (done+dead equals enqueued), at-most-once is absolute (MaxAttempts: 1 tasks execute at most once across every kill), executions never exceed attempts, and nothing completes without executing. Three profiles run in CI: realistic (library defaults — 60s lease/reap, single mode, a kill every 5–10s: 0.1% duplicate executions of retryable tasks), aggressive (pipeline mode, 2s leases, a kill every ~0.7s: 1.2% duplicates), and failover (workers killed AND MongoDB itself restarted twice mid-storm: zero lost, zero at-most-once violations — driver reconnects, change-stream resume, and majority-acked claims surviving the outage). Duplicates are the documented at-least-once contract; the measured rate scales with the crash rate, and at-most-once violations are zero in all profiles.

Durability: the store pins writeConcern: majority and readConcern: majority on its collections regardless of deployment defaults — the at-most-once guarantee must not depend on an operator's cluster settings (an acked-then-rolled-back claim under w:1 would erase the lease token and legitimize a duplicate run). Known remaining boundary: chaos runs against a single-node replica set; multi-node election/rollback scenarios are covered by the majority-concern construction but not yet by a test.

Benchmarks

go test -bench . -benchtime 2000x -run xxx . (needs local MongoDB). Reference numbers from a dev laptop with MongoDB 7 in Docker Desktop (macOS's container I/O inflates per-op latency several-fold — treat these as relative, not absolute):

Benchmark Result
EnqueueSingle ~4ms/task
EnqueueMany (batches of 100) ~12,800 tasks/sec
EnqueueMany (batches of 10,000, chunked) ~36,000 tasks/sec
Throughput, single mode, 4 workers ~370 tasks/sec
Throughput, single mode, 16 workers ~600 tasks/sec
Throughput, pipeline 16×8 workers, NoFinalize ~560 tasks/sec
Throughput, pipeline 16×8 workers (defaults) ~900 tasks/sec
Throughput, pipeline 64×16 workers (defaults) ~1,355 tasks/sec

Reading the numbers: batched claiming removed claiming as the bottleneck, which made the per-task finalize write the next one; pipeline mode's batched finalization (compare the NoFinalize row) removes that too. What remains is per-op latency of the remaining point-writes — far better on production Linux/Atlas than on this laptop setup.

Task lifetime (TTL)

Every task in a TTL'd queue gets expires_at = run_at + QueuePolicy.TTL, stamped once at creation — the SQS retention model, measured from the moment the task becomes eligible, so scheduled tasks don't burn lifetime while waiting to come due. When it passes, MongoDB purges the task in whatever state it is in: done, dead, pending under a backlog, mid-retry, even while a handler runs (that handler's finalize is then fenced off and logged). Budget it as queue wait + retries + how long the result should stay visible — an unconsumed-but-due task purges too; bounded queue growth is the feature.

TTL is queue-scoped by design (0 = the queue's tasks never expire): tasks needing a different lifetime belong in a different queue — the queue is the policy boundary. Requeue does NOT reset the deadline — a revived task keeps its original lifetime, which doubles as dead-letter retention. QueuePolicy also carries MaxAttempts (TaskPolicy → queue → manager-default inheritance).

Requirements

  • Go 1.23+
  • MongoDB 4.2+

Status / roadmap

v0.2 (current): core queue, Mongo store, worker pool, retries/backoff, scheduling, reaper, heartbeat, dead-letter + requeue, unique tasks, per-type retention. See PLAN.md for the full roadmap (batch claim, named queues, priorities, change-stream wakeup, Postgres store, metrics...).

License

MIT

Documentation

Overview

Package gotasks is a persistent, atomic, asynchronous task queue for Go. Tasks live in a pluggable store (MongoDB first); a pool of workers claims them atomically (one claim = one attempt), runs a typed handler per task type, and retries failures with backoff. Scheduling is done via run_at; every claim takes a lease (leased_until + lease_token) so a task whose worker died is reclaimed once the lease expires — never run twice at once.

Index

Constants

View Source
const HeartbeatAuto time.Duration = -1

HeartbeatAuto selects the automatic heartbeat cadence: LeaseTime/3.

Variables

View Source
var (
	// ErrNoTask is returned by Store.Claim when no runnable task exists.
	ErrNoTask = errors.New("gotasks: no task available")
	// ErrLeaseLost is returned by Complete/Fail/ExtendLease when the task is
	// no longer held under the given lease token (it was reclaimed by another
	// worker after the lease expired, or already finalized).
	ErrLeaseLost = errors.New("gotasks: task lease lost or task already finalized")
	// ErrNotFound is returned when a task id does not exist (or is malformed
	// for the store's id scheme), or is not in the state the call requires
	// (e.g. Requeue on a task that is not dead).
	ErrNotFound = errors.New("gotasks: task not found")
	// ErrDuplicateTask is matched (via errors.Is) by the *DuplicateTaskError
	// returned when enqueueing with a unique key that already has an active
	// (pending/running) task.
	ErrDuplicateTask = errors.New("gotasks: duplicate unique key")
	// ErrQueueConflict is returned when a manager declares a queue whose
	// policy differs from what the queues registry already records — policy
	// drift across deployments is a startup error, never silent behavior.
	// Deliberate changes go through Store.SetQueuePolicy.
	ErrQueueConflict = errors.New("gotasks: queue policy conflicts with the registry")
)

Functions

func Enqueue

func Enqueue[T any](ctx context.Context, m *Manager, queue, taskType string, payload T, policy ...TaskPolicy) (string, error)

Enqueue inserts one task into queue (which must be declared on this manager) with a typed payload — any value that marshals to a JSON object; use struct{}{} for no payload. The optional TaskPolicy carries scheduling, attempts, unique-key and TTL knobs. Returns the task id. With TaskPolicy.UniqueKey, a conflict returns the EXISTING active task's id along with an error matching ErrDuplicateTask — treat it as idempotent success when that suits the caller.

func EnqueueMany

func EnqueueMany[T any](ctx context.Context, m *Manager, queue, taskType string, payloads []T, policy ...TaskPolicy) ([]string, error)

EnqueueMany inserts many tasks of one type into queue in chunked store round trips. All share the same TaskPolicy.

func RegisterHandler

func RegisterHandler[T any](m *Manager, taskType string, fn func(ctx context.Context, task *Task, payload T) (any, error), opts ...HandlerOption) error

RegisterHandler wires fn to run tasks of taskType, decoding the payload into T. A non-nil returned value is stored as the task's result (wrapped as {"value": ...} when it isn't a JSON object). A returned error (or panic) consumes an attempt and triggers retry with backoff, or failed when attempts are exhausted. Register everything before Start.

Types

type Backoff

type Backoff interface {
	Next(attempt int) time.Duration
}

Backoff decides how long to wait before retry attempt+1, given that `attempt` attempts have already happened (attempt >= 1 on first failure).

func ExponentialBackoff

func ExponentialBackoff(base, max time.Duration) Backoff

ExponentialBackoff waits base*2^(attempt-1) capped at max, with up to 25% random jitter added to spread thundering herds. This is the default (base=30s, max=1h).

func FixedBackoff

func FixedBackoff(d time.Duration) Backoff

FixedBackoff waits the same duration after every failure.

func LinearBackoff

func LinearBackoff(base time.Duration) Backoff

LinearBackoff waits base*attempt (30s, 60s, 90s, ... for base=30s).

type BackoffFunc

type BackoffFunc func(attempt int) time.Duration

BackoffFunc adapts a function to the Backoff interface.

func (BackoffFunc) Next

func (f BackoffFunc) Next(attempt int) time.Duration

type ClaimOptions

type ClaimOptions struct {
	// WorkerID is recorded on the task as leased_by.
	WorkerID string
	// Types restricts claims to these task types; empty = all types.
	Types []string
	// Queues restricts claims to these queues. Empty = all queues, a
	// store-level convenience for tests/tools: the manager always supplies
	// its declared queue names, and the claim index requires the clause.
	Queues []string
	// FIFO requests oldest-first claim order ((run_at, id) ascending).
	// False (the default) lets the store pick whichever runnable task is
	// cheapest to take — faster and less contended, but task staleness is
	// unbounded under sustained overload.
	FIFO bool
	// Lease is how long the claim holds the task before it becomes
	// reclaimable.
	Lease time.Duration
}

ClaimOptions parameterizes Claim and ClaimBatch.

type Config

type Config struct {
	// Workers is the number of concurrent worker goroutines. Default 4.
	Workers int
	// PollInterval is how long a worker sleeps after finding no task
	// (railway loop: while tasks keep coming, workers never sleep). Default 2s.
	PollInterval time.Duration
	// LeaseTime is how long a claim holds a task before it becomes
	// reclaimable by other workers. Must exceed your longest handler run
	// unless the heartbeat is enabled (WithHeartbeat). Default 60s.
	LeaseTime time.Duration
	// DefaultMaxAttempts for enqueued tasks (override per queue via
	// QueuePolicy.MaxAttempts or per task via TaskPolicy.MaxAttempts).
	// 1 = at-most-once. Default 3.
	DefaultMaxAttempts int
	// DefaultTimeout bounds each handler run via context (per-type override
	// with WithHandlerTimeout). 0 disables. Default 60s.
	DefaultTimeout time.Duration
	// Backoff computes retry delays. Default ExponentialBackoff(30s, 1h).
	Backoff Backoff
	// Janitor configures the maintenance goroutine's duties (reaping,
	// metrics, future work). The zero value is a working default: reap
	// every 60s, metrics off. See WithJanitor.
	Janitor JanitorConfig
	// HeartbeatInterval controls automatic lease extension while a handler
	// runs, letting handlers outlive LeaseTime without being reclaimed.
	// 0 (default) disables the heartbeat — handlers must then finish within
	// LeaseTime, or call Manager.ExtendLease themselves. HeartbeatAuto
	// derives LeaseTime/3; a positive value is used as-is. When the
	// heartbeat discovers the lease was lost (the task was reclaimed), the
	// handler's context is cancelled.
	HeartbeatInterval time.Duration
	// Pipeline enables pipeline mode when non-nil (set via
	// WithPipelineMode). nil (default) = single mode: each worker claims,
	// handles, and writes its result independently — no fetcher, no
	// channel, no buffering, immediate visibility of every state change.
	// See PipelineConfig for what pipeline mode changes.
	Pipeline *PipelineConfig
	// DisableChangeStream forces polling even when the store supports
	// change-stream wakeup. Default false: the manager tries the stream at
	// Start and falls back to polling silently if unavailable.
	DisableChangeStream bool
	// FallbackPoll is the safety-net poll cadence used while a change
	// stream is active (it catches anything a stream gap missed). Default
	// 30s. Ignored when no stream is active — PollInterval applies then.
	FallbackPoll time.Duration
	// FIFO makes claims take the oldest runnable task first ((run_at, id)
	// ascending). Off by default: unsorted claiming is faster and spreads
	// contention, but doesn't bound how stale a task can get under
	// sustained overload. Scheduling (run_at) is enforced by the claim
	// filter in both modes — FIFO only orders already-due tasks.
	FIFO bool
	// Queues is the explicit set of queues this manager works with — like
	// every queue system (Kafka topics, SQS queues), there is no default
	// queue and nothing is consumed implicitly. Declare each queue with
	// WithQueue; at least one is required. Enqueueing to an undeclared
	// queue is an error (catches typos at the call site), and Start
	// consumes every declared queue (a produce-only manager simply never
	// calls Start; an app producing to A while consuming B uses two
	// managers).
	Queues []QueuePolicy
	// ManagerName is a developer-chosen, non-unique label for this manager
	// instance (default "manager"). The framework derives a unique,
	// dot-separated ManagerID = "{name}.{random}" per instance; workers
	// lease as "{managerID}.worker.{n}" (fetcher: "{managerID}.fetcher"),
	// and metrics counters are attributed to the ManagerID.
	ManagerName string
	// Hooks are observe-only lifecycle callbacks (see Hooks). They run on
	// worker goroutines: keep them fast and non-blocking. A panicking hook
	// is recovered and logged; hooks never affect a task's outcome.
	Hooks Hooks
	// Middleware wraps handler execution (outermost first): tracing spans,
	// timing, log enrichment. Applied identically to every handler at
	// registration time.
	Middleware []Middleware
	// Logger receives worker/reaper diagnostics. Default slog.Default().
	Logger *slog.Logger
}

Config holds manager settings. Zero values are replaced by defaults in New.

type DuplicateTaskError

type DuplicateTaskError struct {
	Key        string
	ExistingID string
}

DuplicateTaskError reports a unique-key conflict on enqueue. errors.Is(err, ErrDuplicateTask) matches it; ExistingID is the id of the active task holding the key ("" if it finished between insert and lookup).

func (*DuplicateTaskError) Error

func (e *DuplicateTaskError) Error() string

func (*DuplicateTaskError) Is

func (e *DuplicateTaskError) Is(target error) bool

type EnqueueFailure added in v0.4.0

type EnqueueFailure struct {
	Index int // position in the input batch
	Err   error
}

EnqueueFailure is one failed entry of a batch enqueue.

type HandlerFunc added in v0.6.0

type HandlerFunc func(ctx context.Context, t *Task, payload json.RawMessage) (any, error)

HandlerFunc is the raw executable form of a handler that Middleware wraps: the payload is still JSON, the result is whatever the (typed) handler returned.

type HandlerOption

type HandlerOption func(*handlerOptions)

HandlerOption configures a handler registration.

func WithHandlerTimeout

func WithHandlerTimeout(d time.Duration) HandlerOption

WithHandlerTimeout bounds every run of this handler via context deadline.

type Hooks added in v0.6.0

type Hooks struct {
	// OnEnqueue fires once per successfully inserted task.
	OnEnqueue func(t *Task)
	// OnClaim fires when this manager takes ownership of a task.
	OnClaim func(t *Task)
	// OnComplete fires when a handler succeeds; d is the handler duration.
	OnComplete func(t *Task, d time.Duration)
	// OnFail fires on every failed attempt; willRetry=false means the
	// task is going to the dead-letter state (OnDead fires too).
	OnFail func(t *Task, err error, willRetry bool)
	// OnDead fires when a worker's terminal failure sends a task to the
	// dead-letter state. (Reaper-detected zombie deaths don't fire it —
	// no worker context exists there; watch the dead gauge for those.)
	OnDead func(t *Task, err error)
}

Hooks are observe-only lifecycle callbacks. Every field is optional. They observe, they never decide: returning is their only effect, and a panic inside a hook is recovered and logged without touching the task.

type JanitorConfig added in v0.6.0

type JanitorConfig struct {
	// ReapInterval is how often stale+exhausted tasks are swept to dead.
	// 0 = default 60s; disable with NoReap.
	ReapInterval time.Duration
	// NoReap disables the reap duty (zombies then stay visible as running
	// docs with expired leases).
	NoReap bool
	// Metrics enables the metrics duty when non-nil. nil (default) = off:
	// smaller projects pay nothing.
	Metrics *MetricsConfig
}

JanitorConfig groups the janitor's duties. The janitor itself is a deadline scheduler — every duty keeps its own clock, and the goroutine sleeps until the earliest next deadline (no fixed tick, no drift on the wall-aligned metrics windows).

type Manager

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

Manager owns the enqueue API and the worker pool. Create with New, wire handlers with RegisterHandler, then Start (or Run). Enqueue works from any process, including ones that never start workers.

func New

func New(store Store, opts ...Option) (*Manager, error)

New creates a Manager on top of a Store.

func (*Manager) Close

func (m *Manager) Close(ctx context.Context) error

Close stops the pool (if running) and closes the store.

func (*Manager) ExtendLease

func (m *Manager) ExtendLease(ctx context.Context, t *Task) error

ExtendLease pushes the task's lease forward by the manager's LeaseTime. Rarely needed directly — enable the heartbeat (WithHeartbeat) to do this automatically for long-running handlers.

func (*Manager) Requeue

func (m *Manager) Requeue(ctx context.Context, id string) error

Requeue resets one dead task to pending (attempts back to 0, runnable now); its errors array is kept as history. Returns ErrNotFound if the id doesn't exist or the task isn't dead.

func (*Manager) RequeueDead

func (m *Manager) RequeueDead(ctx context.Context, taskType string) (int64, error)

RequeueDead requeues every dead task of taskType ("" = all types) with the same resets as Requeue, returning how many were requeued.

func (*Manager) Run

func (m *Manager) Run(ctx context.Context) error

Run is a convenience blocking loop: Start, wait for ctx cancellation, then drain with a 30s grace period.

func (*Manager) Start

func (m *Manager) Start() error

Start launches the worker pool and reaper. Non-blocking; pair with Stop.

func (*Manager) Stop

func (m *Manager) Stop(ctx context.Context) error

Stop drains the pool: workers stop claiming immediately and in-flight handlers get to finish. If ctx expires first, in-flight handler contexts are cancelled (each records a failed attempt) and ctx.Err() is returned.

type MetricsConfig added in v0.6.0

type MetricsConfig struct {
	// Interval is the snapshot window size (default 60s, min 1s).
	Interval time.Duration
	// Retention is how long metrics documents are kept (TTL index on the
	// metrics collection; default 7 days).
	Retention time.Duration
}

MetricsConfig configures Mongo-native metrics (see JanitorConfig). Zero values take defaults.

type Middleware added in v0.6.0

type Middleware func(next HandlerFunc) HandlerFunc

Middleware wraps a HandlerFunc. The first middleware passed to WithMiddleware is the outermost.

type Option

type Option func(*Config)

Option configures the Manager.

func WithBackoff

func WithBackoff(b Backoff) Option

func WithDefaultMaxAttempts

func WithDefaultMaxAttempts(n int) Option

func WithDefaultTimeout

func WithDefaultTimeout(d time.Duration) Option

func WithFIFO

func WithFIFO() Option

WithFIFO makes claims take the oldest runnable task first (see Config.FIFO). Off by default.

func WithFallbackPoll

func WithFallbackPoll(d time.Duration) Option

WithFallbackPoll sets the safety-net poll cadence used while a change stream is active (see Config.FallbackPoll).

func WithHeartbeat

func WithHeartbeat() Option

WithHeartbeat enables automatic lease extension at the auto cadence (LeaseTime/3). Off by default.

func WithHeartbeatInterval

func WithHeartbeatInterval(d time.Duration) Option

WithHeartbeatInterval enables automatic lease extension at an explicit cadence (or HeartbeatAuto for LeaseTime/3; 0 keeps it disabled).

func WithHooks added in v0.6.0

func WithHooks(h Hooks) Option

WithHooks sets the lifecycle hooks (see Hooks). Last call wins whole.

func WithJanitor added in v0.6.0

func WithJanitor(jc JanitorConfig) Option

WithJanitor configures the maintenance duties (last call wins whole). Metrics duty: per-window gauge snapshots per queue (status counts + oldest due-task age; deduplicated cluster-wide, first manager wins each window) and this manager's exact per-queue throughput counters.

func WithLeaseTime

func WithLeaseTime(d time.Duration) Option

func WithLogger

func WithLogger(l *slog.Logger) Option

func WithManagerName added in v0.6.0

func WithManagerName(name string) Option

WithManagerName sets the developer-chosen label for this manager instance (see Config.ManagerName).

func WithMiddleware added in v0.6.0

func WithMiddleware(mw ...Middleware) Option

WithMiddleware appends handler middleware; the first registered is the outermost.

func WithPipelineMode added in v0.4.0

func WithPipelineMode(pc PipelineConfig) Option

WithPipelineMode switches the manager from single mode to pipeline mode (see PipelineConfig). Single mode — the default — keeps every worker claiming, handling, and acknowledging independently with immediate visibility; pipeline mode trades bounded acknowledgment latency for amortized round trips and is meant for high-throughput pipelines.

func WithPollInterval

func WithPollInterval(d time.Duration) Option

func WithQueues

func WithQueues(qs ...QueuePolicy) Option

WithQueues declares the manager's queues (appendable across calls).

func WithWorkers

func WithWorkers(n int) Option

func WithoutChangeStream

func WithoutChangeStream() Option

WithoutChangeStream forces polling even when the store supports change-stream wakeup (see Config.DisableChangeStream).

type Outcome

type Outcome struct {
	Task     *Task
	Result   json.RawMessage // success only; nil = no result
	Failure  *TaskError      // non-nil = this attempt failed
	RetryAt  time.Time       // failure, non-terminal: the next run_at
	Terminal bool            // failure: attempts exhausted -> dead
}

Outcome is one finished attempt, carried between a worker and (batched) finalization. Failure nil = success.

type PartialEnqueueError added in v0.4.0

type PartialEnqueueError struct {
	Failures []EnqueueFailure
}

PartialEnqueueError reports a partially successful batch enqueue: entries not listed in Failures were inserted (chunked writes are not atomic). The ids returned alongside this error stay aligned with the input, with "" at each failed index.

func (*PartialEnqueueError) Error added in v0.4.0

func (e *PartialEnqueueError) Error() string

type PipelineConfig added in v0.4.0

type PipelineConfig struct {
	// ClaimBatch is how many tasks the fetcher claims per round trip
	// (1..100). Default 32.
	ClaimBatch int
	// QueueLease budgets how long a task may wait in the fetcher's channel
	// before becoming reclaimable by other processes. Default: LeaseTime.
	QueueLease time.Duration
	// FinalizeBatch is how many outcomes a finalize flush carries at most
	// (2..1000). Default: 2x ClaimBatch.
	FinalizeBatch int
	// FinalizeInterval is the longest a finished task may wait in the
	// finalize buffer before its outcome is written (default 300ms). A
	// latency bound, not a polling period: the flusher sleeps while the
	// buffer is empty, and on busy queues the size trigger fires first.
	FinalizeInterval time.Duration
	// NoFinalize keeps batched claiming but writes every outcome
	// immediately (single-mode acknowledgment semantics).
	NoFinalize bool
}

PipelineConfig configures pipeline mode: the high-throughput architecture where one fetcher claims tasks in batches under a QUEUE lease and feeds workers through a bounded channel (workers revalidate ownership with one point-write that starts the task lease), and finished tasks are acknowledged in batched bulk writes.

Zero values take defaults, so WithPipelineMode(PipelineConfig{}) is a complete, sensible setup.

Finalize safety policy: outcomes are NEVER buffered when risky — at-most-once tasks (max_attempts=1) and tasks whose remaining lease is under the safety margin (max(4x FinalizeInterval, 2s)) are written immediately, so a buffered outcome cannot outlive its lease while the process is alive. The trade-off: a crash loses up to FinalizeInterval of finished-but-unflushed outcomes — those tasks are reclaimed and re-run (the normal at-least-once contract, slightly widened).

type QueueCounters added in v0.6.0

type QueueCounters struct {
	Enqueued int64
	Claimed  int64
	Done     int64
	Failed   int64 // failed attempts that will retry
	Dead     int64 // terminal failures
}

QueueCounters is one manager instance's exact per-queue event counts for one metrics window. Done/Failed/Dead are counted when the worker decides the outcome (batched finalization may land the write moments later).

type QueuePolicy added in v0.6.0

type QueuePolicy struct {
	Name string
	// TTL is the queue's total-lifetime default (see above). 0 = no expiry.
	TTL time.Duration
	// MaxAttempts is the queue's default attempts budget. 0 inherits the
	// manager's DefaultMaxAttempts. TaskPolicy.MaxAttempts overrides both.
	MaxAttempts int
}

QueuePolicy declares one queue and its policies. TTL is the queue's task lifetime, measured from each task's run_at (its eligible-to-run time): expires_at = run_at + TTL is stamped at creation, and MongoDB purges the document when it passes — whether or not the task ran (the SQS retention model; budget TTL for queue wait + retries + how long you want the result visible; scheduled tasks don't burn lifetime while waiting to become due). 0 = no expiry. TTL is queue-scoped by design: tasks needing a different lifetime belong in a different queue.

type Status

type Status string

Status is the lifecycle state of a task.

const (
	StatusPending Status = "pending" // waiting for a worker (run_at may be in the future)
	StatusRunning Status = "running" // claimed under a live lease
	StatusDone    Status = "done"    // handler succeeded
	// StatusDead is the dead-letter state: attempts exhausted (or lease
	// expired with none left). Dead tasks sit inspectable until requeued
	// (Manager.Requeue / RequeueDead) or pruned by retention.
	StatusDead Status = "dead"
)

type Store

type Store interface {
	// Enqueue inserts tasks (batch; callers pass a single-element slice for
	// one task) and returns their assigned ids aligned with the input.
	// Large batches may be written in chunks; on partial failure the ids
	// slice carries "" at failed indexes and the error is a
	// *PartialEnqueueError (single-task unique-key conflicts keep returning
	// *DuplicateTaskError).
	Enqueue(ctx context.Context, tasks []*Task) ([]string, error)

	// Claim atomically takes one runnable task and returns it with
	// Status=running, Attempts incremented, and a fresh LeaseToken/LeasedUntil.
	// Runnable means: (pending AND run_at <= now) OR
	// (running AND leased_until < now AND attempts < max_attempts).
	// Returns ErrNoTask when nothing matches.
	Claim(ctx context.Context, opts ClaimOptions) (*Task, error)

	// ClaimBatch atomically takes up to k runnable tasks (same runnable
	// definition and ordering as Claim) under a single lease token, in a
	// constant number of round trips. Concurrent callers may split the
	// candidates: a short (even empty) result with a nil error means others
	// won some or all of them — more work may still exist, so callers should
	// retry promptly. ErrNoTask means no runnable candidates existed at all.
	ClaimBatch(ctx context.Context, opts ClaimOptions, k int) ([]*Task, error)

	// Complete marks t done, stores the handler result (nil = none), and
	// releases t's unique key if it has one.
	Complete(ctx context.Context, t *Task, result json.RawMessage) error

	// Fail records taskErr on t's errors array. If terminal is false the
	// task is rescheduled: status back to pending with run_at = retryAt
	// (unique key kept — the task is still active). If terminal is true the
	// task becomes dead and its unique key is released.
	Fail(ctx context.Context, t *Task, taskErr TaskError, retryAt time.Time, terminal bool) error

	// ExtendLease pushes t's leased_until to now+lease and returns the new
	// deadline. Used by the manager's heartbeat for long-running handlers.
	ExtendLease(ctx context.Context, t *Task, lease time.Duration) (time.Time, error)

	// FinalizeBatch applies many Complete/Fail outcomes in one round trip.
	// Each entry is fenced individually, exactly like Complete/Fail; an
	// entry whose lease was lost is skipped, not an error. Returns how many
	// entries were skipped that way (for observability).
	FinalizeBatch(ctx context.Context, outcomes []Outcome) (lost int64, err error)

	// Requeue resets one dead task to pending: attempts back to 0, runnable
	// now, retention expiry cleared. The errors array is kept as history.
	// Returns ErrNotFound if the id doesn't exist or the task isn't dead.
	Requeue(ctx context.Context, id string) error

	// RequeueDead requeues every dead task (of taskType, or all types when
	// taskType is ""), with the same resets as Requeue. Returns the count.
	RequeueDead(ctx context.Context, taskType string) (int64, error)

	// RegisterQueues records the declared queue policies in the queues
	// registry. A queue not yet registered is inserted; an identical
	// redeclaration is a no-op; a DIFFERENT policy for a registered queue
	// fails with an error matching ErrQueueConflict (drift guard).
	RegisterQueues(ctx context.Context, queues []QueuePolicy) error

	// Queues lists every registered queue policy (tools/UI/introspection).
	Queues(ctx context.Context) ([]QueuePolicy, error)

	// SetQueuePolicy deliberately overwrites (or creates) one registered
	// queue policy — the explicit ops action for changing a policy;
	// managers still running the old declaration will then fail their
	// next registration check.
	SetQueuePolicy(ctx context.Context, q QueuePolicy) error

	// EnsureMetrics prepares metrics storage (indexes incl. the retention
	// TTL). Called once by the janitor when metrics are enabled; a changed
	// retention updates the TTL in place.
	EnsureMetrics(ctx context.Context, retention time.Duration) error

	// SnapshotMetrics computes and stores one cluster-wide gauges document
	// per queue for the window starting at windowStart (status counts +
	// oldest DUE-task age). Managers race per window; the first insert
	// wins and the rest are silently skipped.
	SnapshotMetrics(ctx context.Context, queues []string, windowStart time.Time, window time.Duration) error

	// RecordCounters stores one document per queue with this manager
	// instance's exact throughput counters for the window (no dedup —
	// every manager writes its own; readers sum across managers).
	RecordCounters(ctx context.Context, managerID string, windowStart time.Time, window time.Duration, counters map[string]QueueCounters) error

	// ReapExpired marks running tasks whose lease expired AND whose attempts
	// are exhausted as dead (with a "lease expired" error entry), so dropped
	// tasks are visible instead of zombie "running" docs. Tasks with
	// attempts remaining are NOT touched — Claim reclaims those naturally.
	// Returns the number of tasks reaped.
	ReapExpired(ctx context.Context) (int64, error)

	// Close releases the store's resources.
	Close(ctx context.Context) error
}

Store is the persistence backend. Implementations must make Claim atomic: under concurrent claims, each runnable task is handed to exactly one caller.

Fencing contract: Claim generates a fresh lease token per claim. Complete/Fail/ExtendLease must only match a task that is still running under the token carried by the passed *Task, and return ErrLeaseLost otherwise. Matching must NOT require the lease to still be unexpired — finishing after expiry but before anyone reclaimed the task is a success, not a conflict.

Unique-key contract: at most one task with a given UniqueKey may be active (pending or running) at a time; a conflicting enqueue returns *DuplicateTaskError. The key is released when the task reaches done/dead.

type Task

type Task struct {
	ID          string          `json:"id"`
	Queue       string          `json:"queue"` // logical queue name (explicit; no default queue exists)
	Type        string          `json:"type"`
	UniqueKey   string          `json:"unique_key,omitempty"` // dedup key: at most one pending/running task per key
	Payload     json.RawMessage `json:"payload,omitempty"`
	Status      Status          `json:"status"`
	Attempts    int             `json:"attempts"`             // claims so far (a claim IS an attempt)
	MaxAttempts int             `json:"max_attempts"`         // 1 = at-most-once: never retried, stale never reclaimed
	RunAt       time.Time       `json:"run_at"`               // eligible-to-run time (scheduling / retry backoff)
	ExpiresAt   *time.Time      `json:"expires_at,omitempty"` // total-lifetime purge deadline, stamped at creation; nil = never
	LeasedBy    string          `json:"leased_by,omitempty"`
	LeasedUntil time.Time       `json:"leased_until,omitempty"`
	LeaseToken  string          `json:"-"` // fencing token; regenerated on every claim
	Errors      []TaskError     `json:"errors,omitempty"`
	Result      json.RawMessage `json:"result,omitempty"`
	CreatedAt   time.Time       `json:"created_at"`
	UpdatedAt   time.Time       `json:"updated_at"`
}

Task is one unit of work. Payload and Result are JSON; use the typed Enqueue / RegisterHandler API instead of touching them directly.

type TaskError

type TaskError struct {
	At      time.Time `bson:"at" json:"at"`
	Attempt int       `bson:"attempt" json:"attempt"`
	Worker  string    `bson:"worker" json:"worker"`
	Message string    `bson:"message" json:"message"`
}

TaskError is one failed attempt, kept on the task in order.

type TaskPolicy added in v0.6.0

type TaskPolicy struct {
	// RunAt schedules the task for an absolute time (wins over Delay).
	RunAt time.Time
	// Delay schedules the task for now+Delay.
	Delay time.Duration
	// MaxAttempts overrides the manager's DefaultMaxAttempts; 1 =
	// at-most-once.
	MaxAttempts int
	// UniqueKey makes the enqueue idempotent: at most one active
	// (pending/running) task per key; conflicts return the existing id
	// with an error matching ErrDuplicateTask. Single-task enqueues only.
	UniqueKey string
}

TaskPolicy carries the optional per-task knobs for Enqueue/EnqueueMany. The zero value means "all defaults": run now, inherited max attempts (queue's, else manager's), no unique key.

type Watcher

type Watcher interface {
	WatchRunnable(ctx context.Context, types, queues []string) (<-chan struct{}, error)
}

Watcher is an optional Store capability: pushing "work may be available" wakeup signals so managers don't rely on polling while idle. The manager type-asserts for it at Start; stores without it (or whose deployment can't support it) are simply polled.

Contract: the returned channel is a coalesced, level-triggered nudge — capacity 1, dropped when full; a receive means "claim now, something may be runnable". False positives are fine (one empty claim); implementations should be conservative the other way. The implementation is responsible for reconnection (nudging after any blind window) and for signaling when known future run_at times come due. The channel closes when ctx ends. An error return means watching is unavailable (e.g. no replica set) and the caller should fall back to polling.

Directories

Path Synopsis
Package dashboard serves the gotasks dashboard: an embeddable http.Handler exposing a JSON API and a single-page frontend over the tasks, queues and metrics collections.
Package dashboard serves the gotasks dashboard: an embeddable http.Handler exposing a JSON API and a single-page frontend over the tasks, queues and metrics collections.
examples
atmostonce command
At-most-once: TaskPolicy{MaxAttempts: 1} is for non-idempotent, credit-spending work (provisioning, charging a card).
At-most-once: TaskPolicy{MaxAttempts: 1} is for non-idempotent, credit-spending work (provisioning, charging a card).
batch command
Batch fan-out and throughput: EnqueueMany inserts a whole batch in one store round-trip (this is how broadcast-style workloads fan one message out to thousands of recipients), and WithPipelineMode enables pipeline consumption: one fetcher claims up to 16 tasks per ~3 round trips and feeds the workers through a bounded channel, instead of each worker paying a contended claim query per task.
Batch fan-out and throughput: EnqueueMany inserts a whole batch in one store round-trip (this is how broadcast-style workloads fan one message out to thousands of recipients), and WithPipelineMode enables pipeline consumption: one fetcher claims up to 16 tasks per ~3 round trips and feeds the workers through a bounded channel, instead of each worker paying a contended claim query per task.
dashboard command
The gotasks dashboard, live: mounts dashboard.Handler on :8080 and runs a small workload generator (steady emails, occasional failures, scheduled reports) so there is something to look at.
The gotasks dashboard, live: mounts dashboard.Handler on :8080 and runs a small workload generator (steady emails, occasional failures, scheduled reports) so there is something to look at.
heartbeat command
Heartbeat: with WithHeartbeat, a worker extends its task's lease every LeaseTime/3 while the handler runs, so handlers may run far longer than LeaseTime without another worker reclaiming the task.
Heartbeat: with WithHeartbeat, a worker extends its task's lease every LeaseTime/3 while the handler runs, so handlers may run far longer than LeaseTime without another worker reclaiming the task.
queues command
Named queues: tasks carry a queue label (WithQueue at enqueue; "default" otherwise), and each manager can be pointed at specific queues with WithQueues.
Named queues: tasks carry a queue label (WithQueue at enqueue; "default" otherwise), and each manager can be pointed at specific queues with WithQueues.
retention command
Task lifetime (TTL): expires_at = run_at + QueuePolicy.TTL, stamped at creation; MongoDB purges the document when it passes — done, dead, or never consumed (the SQS retention model).
Task lifetime (TTL): expires_at = run_at + QueuePolicy.TTL, stamped at creation; MongoDB purges the document when it passes — done, dead, or never consumed (the SQS retention model).
retries command
Retries, backoff, dead-letter, and requeue: a flaky handler fails a configurable number of times per task.
Retries, backoff, dead-letter, and requeue: a flaky handler fails a configurable number of times per task.
scheduled command
Scheduled tasks: TaskPolicy.Delay and TaskPolicy.RunAt defer when a task becomes runnable.
Scheduled tasks: TaskPolicy.Delay and TaskPolicy.RunAt defer when a task becomes runnable.
simple command
A minimal end-to-end example: enqueue a few email tasks (one scheduled), run a worker pool against local MongoDB, stop cleanly on Ctrl-C.
A minimal end-to-end example: enqueue a few email tasks (one scheduled), run a worker pool against local MongoDB, stop cleanly on Ctrl-C.
unique command
Unique keys: TaskPolicy.UniqueKey guarantees at most one active (pending or running) task per key.
Unique keys: TaskPolicy.UniqueKey guarantees at most one active (pending or running) task per key.
internal
chaosworker command
chaosworker is the chaos test's fixture: a real gotasks worker process meant to be SIGKILLed mid-flight.
chaosworker is the chaos test's fixture: a real gotasks worker process meant to be SIGKILLed mid-flight.
stores
memstore
Package memstore is an in-memory gotasks.Store for unit tests and local development: run real managers, handlers and enqueue flows with no MongoDB, no Docker, in milliseconds, deterministically.
Package memstore is an in-memory gotasks.Store for unit tests and local development: run real managers, handlers and enqueue flows with no MongoDB, no Docker, in milliseconds, deterministically.
mongostore
Package mongostore implements the gotasks Store on MongoDB (>= 4.2).
Package mongostore implements the gotasks Store on MongoDB (>= 4.2).

Jump to

Keyboard shortcuts

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