Radish is a lightweight, type-safe, persistent background task queue for Go. Tasks are durably stored in a relational database (PostgreSQL or SQLite) so that they survive process restarts, are processed exactly-once with at-least-once delivery semantics, and can be retried with configurable backoff. Radish is designed to be embedded directly into your Go application - there is no separate broker process to deploy or manage.
- Persistent - tasks are stored in your database and survive crashes.
- Type-safe - workers and tasks are strongly typed via Go generics.
- Embedded - runs in-process with your application; no separate broker.
- Retryable - configurable backoff (zero, constant, linear, exponential) with optional jitter, per-task and per-worker retry overrides.
- Schedulable - enqueue tasks for immediate execution or schedule them for later.
- Concurrent - configurable pool of executors poll for and run tasks in parallel.
- Pluggable storage - PostgreSQL for production, SQLite for testing or single-node deployments, mock for unit tests.
Install Radish:
go get go.rtnl.ai/radishA complete working example with a single task type and worker:
package main
import (
"context"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"time"
"go.rtnl.ai/radish"
)
// 1. Define a task. The struct is JSON-serialized into the queue.
type GreetTask struct {
Name string `json:"name"`
}
func (t *GreetTask) Kind() string { return "greet" }
// 2. Define a worker. Embed WorkerDefaults to inherit default Retry/Timeout.
type GreetWorker struct {
radish.WorkerDefaults[*GreetTask]
}
func (w *GreetWorker) Do(ctx context.Context, task *radish.TaskInfo[*GreetTask]) error {
fmt.Printf("hello, %s!\n", task.Task.Name)
return nil
}
func main() {
// 3. Configure Radish (env vars also work; see Configuration below).
tasks, err := radish.New(&radish.Config{
DatabaseURL: "postgres://radish@localhost:5432/radish?sslmode=disable",
NumWorkers: 4,
TaskTimeout: 30 * time.Second,
})
if err != nil {
log.Fatal(err)
}
// 4. Register your worker(s).
if err := radish.Register(tasks, new(GreetWorker)); err != nil {
log.Fatal(err)
}
// 5. Start the executor pool.
if err := tasks.Run(); err != nil {
log.Fatal(err)
}
defer tasks.Shutdown()
// 6. Enqueue tasks from anywhere in your application.
if _, err := tasks.Enqueue(context.Background(), &GreetTask{Name: "world"}); err != nil {
log.Fatal(err)
}
// Block until shutdown.
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
<-quit
}That's it. Run the program, and the executor pool will pick up the greet task
and execute it. If the worker returns an error, Radish will retry the task
according to the configured backoff policy.
- Concepts
- Installation
- Configuration
- Designing Tasks
- Designing Workers
- Embedding Radish
- Enqueueing and Scheduling Tasks
- Retries and Backoff
- Brokers and Storage
- Operations
- Developer Guide
A few terms used throughout the documentation:
- Task - a Go struct (implementing
radish.Task) that describes a unit of work. It is JSON-serialized and persisted to the database. The taskKind()routes the payload to the correct worker. - Worker - a Go type (implementing
radish.Worker[T]) that processes tasks of a specific typeT. Each taskKindis handled by exactly one worker. - TaskInfo - the runtime view a worker sees: the deserialized task
Tplus metadata (id, attempts, errors, timestamps, etc). - Broker - the persistent storage backend that holds tasks. PostgreSQL, SQLite, and an in-memory mock are provided.
- Executor - a goroutine that polls the broker for ready tasks and dispatches
them to workers. The executor pool size is configured by
NumWorkers. - Radish - the top-level controller: holds configuration, the worker registry, and the executor pool.
go get go.rtnl.ai/radishRadish requires Go 1.26 or later and a PostgreSQL database for production use. SQLite is supported for development and single-node deployments. The schema is created automatically the first time Radish connects.
NOTE: other brokers such as redis and queues like Kafka and Pulsar may be available in the future.
radish.Config controls all of Radish's behavior. It can be constructed
explicitly or loaded from the environment using
confire.
| Field | Env Var | Default | Description |
|---|---|---|---|
DatabaseURL |
DATABASE_URL |
DSN to the database (e.g. postgres://..., sqlite3:///path/to/db.sqlite). |
|
ManagedDB |
RADISH_MANAGED_DB |
false |
If true, supply your own *sql.DB via Conn instead of letting Radish connect. |
NumWorkers |
RADISH_NUM_WORKERS |
8 |
Number of executor goroutines polling for tasks. |
TaskRetries |
RADISH_TASK_RETRIES |
3 |
Maximum default retry attempts per task. |
TaskTimeout |
RADISH_TASK_TIMEOUT |
60s |
Default and maximum time allowed per task; worker-specific timeouts longer than this are capped at this value. |
CleanupTimeout |
RADISH_CLEANUP_TIMEOUT |
5s |
Time allowed to clean up a task after worker execution, independent of TaskTimeout. |
PollInterval |
RADISH_POLL_INTERVAL |
5s |
How often each executor polls for new work. |
PollJitter |
RADISH_POLL_JITTER |
125ms |
Standard deviation of jitter applied to the poll interval to avoid thundering herds. |
Retention |
RADISH_RETENTION |
24h |
How long completed/failed tasks are retained when calling Vacuum. |
VacuumInterval |
RADISH_VACUUM_INTERVAL |
3h |
How often the Vacuum background task is executed to clean up the database. |
Backoff |
RADISH_BACKOFF_* |
(linear) | Backoff policy; see Retries and Backoff. |
Conn |
(not env-loadable) | nil |
An existing *sql.DB to use instead of opening a new one. Required when ManagedDB=true. |
OnError |
(not env-loadable) | nil |
Optional runtime error handler; overrides the default rlog.Fatal behavior and receives a detached context and task metadata when available. |
OnError runs on the executor goroutine. Handlers should return promptly and
must not synchronously call Shutdown. When it is nil, runtime executor errors
use the default rlog.Fatal behavior.
tasks, err := radish.New(nil) // nil triggers env-based config loadingWhen nil is passed to radish.New, the config is loaded from environment
variables using the RADISH_ prefix (with DATABASE_URL as the one exception).
Set them in your shell, .env, or process manager:
export DATABASE_URL=postgres://radish@localhost:5432/radish?sslmode=disable
export RADISH_NUM_WORKERS=8
export RADISH_TASK_RETRIES=3
export RADISH_TASK_TIMEOUT=120s
export RADISH_CLEANUP_TIMEOUT=5s
export RADISH_POLL_INTERVAL=15s
export RADISH_POLL_JITTER=1250ms
export RADISH_RETENTION=72h
export RADISH_BACKOFF_POLICY=exponential
export RADISH_BACKOFF_DELAY=16s
export RADISH_BACKOFF_FACTOR=2.0
export RADISH_BACKOFF_JITTER=true
export RADISH_BACKOFF_SIGMA=1500mscfg := &radish.Config{
DatabaseURL: "postgres://radish@localhost:5432/radish?sslmode=disable",
NumWorkers: 8,
TaskRetries: 5,
TaskTimeout: 2 * time.Minute,
CleanupTimeout: 5 * time.Second,
PollInterval: 10 * time.Second,
PollJitter: 500 * time.Millisecond,
Retention: 72 * time.Hour,
Backoff: backoff.Config{
Policy: backoff.PolicyExponential,
Delay: 10 * time.Second,
Factor: 2.0,
Jitter: true,
Sigma: 750 * time.Millisecond,
},
}
tasks, err := radish.New(cfg)If your application already manages a *sql.DB, you can let Radish use it
instead of opening its own connection by setting ManagedDB: true and providing
Conn:
db, _ := sql.Open("postgres", dsn)
tasks, err := radish.New(&radish.Config{
ManagedDB: true,
Conn: db,
})A task is any Go struct that implements radish.Task:
type Task interface {
Kind() string
}Some guidance for designing tasks:
The Kind() is the routing key that maps a serialized payload to a worker.
Kinds should be lowercase, alphanumeric, with no spaces or special characters
(dashes are fine). For example: send-email, index-document, report.daily.
Important: Kind values must be static, not dynamic. The worker registry instantiates a zero-valued task and calls
Kind()to learn which kind a worker is responsible for. IfKind()reads fields from the struct, it will return the wrong value at registration time.
// GOOD - constant, returned for any zero value of the type.
func (t *SendEmail) Kind() string { return "send-email" }
// BAD - kind is derived from struct fields and won't match at registration.
func (t *SendEmail) Kind() string { return "email-" + t.Provider }After tasks have been deployed and persisted, you cannot simply rename a kind
without orphaning queued tasks. To rename safely, implement
radish.TaskWithAliases so the worker continues to handle previous kinds:
type SortTask struct {
Numbers []int `json:"numbers"`
}
func (t *SortTask) Kind() string { return "sort" }
func (t *SortTask) KindAliases() []string {
return []string{"sort-numbers", "sort-integers"}
}The worker registered for SortTask will receive any task with kind sort,
sort-numbers, or sort-integers.
Tasks are serialized with encoding/json, so:
- Exported fields are marshaled; unexported fields are not.
json:"..."struct tags are honored.- Avoid putting non-serializable values (channels, funcs,
*sql.DB, etc.) in the task struct - only put what the worker needs to recreate context, and let the worker fetch the rest from your application's services.
A complete example:
type SendEmailTask struct {
To string `json:"to"`
Subject string `json:"subject"`
Template string `json:"template"`
Data map[string]any `json:"data,omitempty"`
}
func (t *SendEmailTask) Kind() string { return "send-email" }A worker implements radish.Worker[T] for a specific task type T:
type Worker[T Task] interface {
// Determines whether the failed task should be retried, and if so, how
// long to wait. Return nil to fall back to the default retry policy.
Retry(*TaskInfo[T]) *Retry
// Per-task timeout. Return 0 to use the configured TaskTimeout. Values
// greater than the configured TaskTimeout are clamped down.
Timeout(*TaskInfo[T]) time.Duration
// Performs the work. Returns nil for success, error to mark failure.
Do(context.Context, *TaskInfo[T]) error
}Most workers only need to implement Do. Embed radish.WorkerDefaults[T] to get
sensible no-op implementations of Retry and Timeout:
type SendEmailWorker struct {
radish.WorkerDefaults[*SendEmailTask]
smtp *smtp.Client
}
func (w *SendEmailWorker) Do(ctx context.Context, task *radish.TaskInfo[*SendEmailTask]) error {
return w.smtp.Send(ctx, task.Task.To, task.Task.Subject, task.Task.Template, task.Task.Data)
}For trivial workers, radish.WorkFunc wraps a function as a worker:
worker := radish.WorkFunc(func(ctx context.Context, task *radish.TaskInfo[*GreetTask]) error {
fmt.Println("hello,", task.Task.Name)
return nil
})
radish.Register(tasks, worker)Override Retry to react to specific errors or to the task's history:
func (w *SendEmailWorker) Retry(info *radish.TaskInfo[*SendEmailTask]) *radish.Retry {
// Retry up to 10 times for this worker.
if info.Attempts >= 10 {
return &radish.Retry{Retry: false}
}
return &radish.Retry{Retry: true, Delay: time.Duration(info.Attempts) * 30 * time.Second}
}Returning nil (the default) means "use the global retry policy from Config.Backoff
and Config.TaskRetries". A returned Retry.Delay < 0 also falls back to the
default backoff delay.
Override Timeout to give certain tasks more or less time to run:
func (w *RenderReportWorker) Timeout(info *radish.TaskInfo[*RenderReportTask]) time.Duration {
if info.Task.Large {
return 10 * time.Minute
}
return 30 * time.Second
}If the returned duration exceeds the global TaskTimeout, it is clamped to
TaskTimeout (which is also the broker visibility lease - exceeding it would
let another executor double-process the task).
Workers' Do methods are called from multiple goroutines concurrently (one per
executor). Workers must be safe for concurrent use - guard mutable state with
mutexes or design workers as stateless processors that pull dependencies from
injected services.
The Radish lifecycle has three phases:
- Construct with
radish.New. - Register workers (must happen before
Run). - Run the executor pool, then
Shutdowncleanly when finished.
tasks, err := radish.New(cfg)
if err != nil { return err }
if err := radish.Register(tasks, new(SendEmailWorker)); err != nil { return err }
if err := radish.Register(tasks, new(IndexDocumentWorker)); err != nil { return err }
if err := tasks.Run(); err != nil { return err }
defer tasks.Shutdown()
// ... your application runs ...Useful methods to know:
radish.Register(tasks, worker)- typed registration. ReturnsErrRunningif called afterRun.radish.MustRegister(tasks, worker)- same, but panics on error.tasks.Run()- startsNumWorkersexecutor goroutines. ReturnsErrRunningif already running.tasks.Shutdown()- signals all executors to stop, waits for in-flight tasks to finish (subject to their timeouts), and closes the broker.tasks.IsRunning()- reports whether the executor pool is active.tasks.Enqueue(ctx, task, opts...)/tasks.Schedule(ctx, task, at, opts...)- add work to the queue (see Enqueueing and Scheduling Tasks).tasks.List(ctx, filter)- query tasks in the broker (see Listing tasks).tasks.Info(ctx, id),tasks.Cancel(ctx, id),tasks.Vacuum(ctx)- inspect, cancel, and clean up tasks.
Shutdown is graceful: it waits for in-flight tasks to either complete or
hit their timeout before returning. Tasks still in the queue remain there and
will be picked up by the next process that runs.
Once Radish is running, your application enqueues tasks through it:
// Run as soon as an executor is available.
id, err := tasks.Enqueue(ctx, &SendEmailTask{To: "ada@example.com", Subject: "hi"})
// Run no earlier than a specific time.
id, err := tasks.Schedule(ctx, &ReportTask{Date: today}, time.Now().Add(1*time.Hour))Both methods return the broker-assigned int64 task id, which can be used to
inspect or cancel the task later.
Both Enqueue and Schedule accept a variadic list of radish.Option values
that control how the broker treats the task relative to other tasks of the same
kind. Options are applied in order and passed through to the broker.
// Enqueue only if no other task of this kind is already outstanding.
id, err := tasks.Enqueue(ctx, &SyncTask{}, radish.OnlyOne())
// Schedule, replacing any outstanding task of this kind.
id, err := tasks.Schedule(ctx, &SyncTask{}, runAt, radish.OnlyOneReplace())Both options deduplicate on the task Kind() (and any KindAliases()), so they
are useful for idempotent or coalescing work - for example, "sync this account
at most once at a time" or "keep only the latest scheduled report". If neither
option is supplied, duplicate tasks of the same kind are always allowed.
Ensures that at most one task of a given kind exists at a time. When enqueuing or
scheduling with OnlyOne(), if there is already a task of the same kind (or kind
alias) that is pending, scheduled, running, or in retry, the new task is
not created and an error is returned. Use this when a duplicate should be
rejected and the existing task left untouched.
if _, err := tasks.Enqueue(ctx, &SyncTask{AccountID: id}, radish.OnlyOne()); err != nil {
// An outstanding SyncTask already exists; nothing was enqueued.
}Also ensures a single task of a given kind, but instead of rejecting the new
task it replaces the outstanding one. When enqueuing or scheduling with
OnlyOneReplace(), any existing pending or scheduled task of the same kind
(or kind alias) is cancelled and superseded by the new task, which is then
created normally. Use this when the newest request should win - for example,
re-scheduling a report so only the most recent parameters take effect.
Note:
OnlyOneReplace()does not cancel a task that is currentlyrunning, because an in-progress task cannot be cancelled mid-flight. This creates an edge case where two tasks of the same kind can briefly coexist: if the running task fails and is moved toretry, both the retry task and the new replacement task will exist. To avoid this, pairOnlyOneReplace()with a retry policy of a single attempt (no retries) for that task's worker.
OnlyOne() and OnlyOneReplace() are mutually exclusive in intent - choose the
one that matches whether an existing task should block the new one (OnlyOne) or
be superseded by it (OnlyOneReplace). If both are supplied, then the OnlyOne
semantics take precedence.
meta, err := tasks.Info(ctx, id)
fmt.Println(meta.Status, meta.Attempts, meta.Errors)meta is a *models.TaskMeta containing the kind, status, payload, attempt
count, accumulated errors, and timestamps.
List queries the broker for many tasks at once, optionally narrowed by a
filter. It returns a *cursor.Cursor that streams results from the database
rather than materializing them all in memory:
import "go.rtnl.ai/radish/broker/cursor"
// List every task (nil filter matches all).
c, err := tasks.List(ctx, nil)
if err != nil { /* ... */ }
defer c.Close() // MUST close to release the underlying transaction.
fmt.Println("total:", c.Count())
for c.Next() {
task, err := c.Task()
if err != nil { /* ... */ }
fmt.Println(task.ID, task.Kind, task.Status)
}
if err := c.Err(); err != nil { /* ... */ }Important: always
Close()the cursor when you are done with it. The cursor holds an open read-only transaction until closed.defer c.Close()immediately after a successfulListis the safest pattern.
The *cursor.Cursor offers a few ways to consume results:
Count()- the total number of tasks matched by the filter (available before iterating).Next()/Task()- stream one task at a time (memory-efficient for large result sets).List()- materialize all matched tasks into a[]*models.TaskMetaslice at once (convenient for small result sets):
c, err := tasks.List(ctx, cursor.Where().Kinds("send-email").Awaiting())
if err != nil { /* ... */ }
defer c.Close()
all, err := c.List()
if err != nil { /* ... */ }
fmt.Printf("%d send-email tasks awaiting execution\n", len(all))Build a filter with cursor.Where() and chain conditions. Each condition
returns the filter, so calls compose fluently. Conditions are combined with
logical AND.
| Method | Effect |
|---|---|
Kinds(kinds ...any) |
Match only the given kinds. Accepts kind strings, or Task values (which contribute their Kind() and any KindAliases()). Unknown types are ignored. |
States(states ...status.Status) |
Match only the given task statuses. |
Completed() |
Shorthand for States(succeeded, failed, cancelled) (terminal tasks). |
Awaiting() |
Shorthand for States(pending, retry, scheduled) (tasks not yet finished). |
Before(t time.Time) |
Match tasks whose visible_at is before t. |
After(t time.Time) |
Match tasks whose visible_at is at or after t. |
Note:
BeforeandAfterfilter on thevisible_atcolumn (when a task becomes eligible to run), not oncreated.
// Pending or scheduled "report" tasks that should have run by now.
filter := cursor.Where().
Kinds("report").
States(status.Pending, status.Scheduled).
Before(time.Now())
c, err := tasks.List(ctx, filter)Because Kinds accepts Task values, you can filter by a task type and pick up
all of its aliases automatically:
// Matches "sort", "sort-numbers", and "sort-integers" from the aliases example.
c, err := tasks.List(ctx, cursor.Where().Kinds(&SortTask{}))if err := tasks.Cancel(ctx, id); err != nil { /* ... */ }Cancellation marks the task as cancelled in the broker so it will not be picked up by an executor. Cancelling a task that is already running has no effect on the in-flight execution.
Successfully completed and permanently failed tasks are kept in the database for
auditing. Periodically call Vacuum to reclaim space:
if err := tasks.Vacuum(ctx, 72*time.Hour); err != nil { /* ... */ }This deletes tasks in a terminal state (succeeded, failed, cancelled) that
finished more than retention ago. A common pattern is to run a daily Radish
task that vacuums the queue.
When a worker's Do returns an error, Radish:
- Adds the error to the task's
Errorshistory (with attempt number and timestamp). - Asks the worker for a
Retrydecision viaWorker.Retry(info). - If the worker returns
nil, it consults the configured retry policy:- if
Attempts < TaskRetries, the task is retried; - otherwise it is marked
failed.
- if
- The retry delay comes from the worker's
Retry.Delayif positive; otherwise from the configuredBackoffpolicy.
Configure the backoff via radish.Config.Backoff (or RADISH_BACKOFF_* env
vars):
| Policy | Delay formula | Required fields |
|---|---|---|
zero |
0 (retry immediately) |
(none) |
constant |
Delay |
Delay |
linear |
Delay * Attempts |
Delay |
exponential |
Delay * Factor^Attempts |
Delay, Factor |
Setting Jitter: true wraps any policy in a normal distribution centered on
the computed delay with standard deviation Sigma, which helps avoid coordinated
thundering herds across executors.
backoff.Config{
Policy: backoff.PolicyExponential,
Delay: 10 * time.Second,
Factor: 2.0,
Jitter: true,
Sigma: 750 * time.Millisecond,
}Radish ships with three broker implementations selected automatically from the DSN scheme:
- PostgreSQL (
postgres://...) - the recommended production backend. Uses row-level locking andSKIP LOCKEDfor safe multi-process dequeuing. - SQLite (
sqlite3://...) - good for development, tests, and single-node deployments. - Mock (
mock://...) - in-memory, for unit tests of code that uses Radish.
The schema (radish_tasks table and radish_status enum) is created
automatically the first time the broker connects, guarded by an advisory lock so
multiple processes can safely race.
A task moves through these statuses (go.rtnl.ai/radish/status):
pending- waiting in the queue, available for dequeue.scheduled- waiting forVisibleAtto elapse.running- leased by an executor and being worked.retry- failed and waiting for the retry delay to elapse.succeeded- terminal: worker returnednil.failed- terminal: worker exhausted retries or returned a non-retryable error.cancelled- terminal: cancelled viaCancel(ctx, id).
Radish logs through go.rtnl.ai/x/rlog,
which wraps the standard slog package. Configure your application's slog
handler to control output format and level - Radish's logs will follow.
You can run multiple Radish processes against the same database; the dequeue
query uses FOR UPDATE SKIP LOCKED (PostgreSQL) to ensure each task is leased
to exactly one executor at a time. This is the recommended way to scale
horizontally.
Each Radish process must register the same set of workers (or at least know about the kinds it might dequeue). If an executor dequeues a task with an unknown kind, it logs an error and leaves the task in the queue for another process to handle.
The schema is initialized on first connect. There is currently no separate migration tool - if you need to alter the schema, do so via your application's own migration framework against the same database.
This section is for contributors working on Radish itself.
radish/
├── radish.go # Top-level controller: New, Run, Shutdown, Enqueue, etc.
├── worker.go # Worker[T] interface, WorkerDefaults, Workers registry.
├── task.go # Task / TaskWithAliases interfaces and TaskInfo[T].
├── wrapper.go # Generic ↔ untyped worker bridge (Factory pattern).
├── config.go # radish.Config and env loading.
├── backoff/ # Retry-delay policies.
├── jitter/ # Normally-distributed jitter ticker.
├── status/ # Task status enum + JSON/SQL marshaling.
├── models/ # TaskMeta and AttemptError persistence types.
├── broker/
│ ├── broker.go # Broker interface + DSN-based Connect dispatch.
│ ├── postgres/ # PostgreSQL broker implementation.
│ ├── sqlite/ # SQLite broker implementation.
│ ├── mock/ # In-memory broker for testing.
│ ├── tests/ # Cross-broker conformance suite.
│ └── errors/ # Shared broker errors (ErrNotFound, etc).
├── internal/worker/ # Untyped worker.Worker / Factory used by the executor.
└── cmd/turnip/ # Integration testing harness (see below).
# All tests, with race detection.
go test -race ./...
# Unit tests only (skip the postgres-backed broker tests).
go test -short ./...CI runs against PostgreSQL 18 with DATABASE_URL configured to a test database.
To run the full suite locally, start a Postgres container and export the same
variable:
docker run --rm -d -p 5432:5432 \
-e POSTGRES_USER=radish \
-e POSTGRES_PASSWORD=turnip42 \
-e POSTGRES_DB=radish_test \
postgres:18
export DATABASE_URL=postgres://radish:turnip42@localhost:5432/radish_test?sslmode=disable
go test -race ./...go install honnef.co/go/tools/cmd/staticcheck@latest
staticcheck ./...cmd/turnip is a synthetic load generator and integration harness that:
- registers a generic
Basicworker that sleeps for a configurable duration and fails with a configurable probability; - enqueues / schedules tasks at a configurable rate using a JSON simulator configuration.
Two convenience scripts live in fixtures/:
fixtures/simulate.shexports a sample environment and runsturnip.fixtures/simulators.jsondescribes the workload simulators.
Quick run (Radish only, no synthetic load):
cd fixtures
./simulate.shRun with simulated load:
cd fixtures
go run ../cmd/turnip/ -path simulators.json -log turnip.log- Create a new package under
broker/<name>/exposing a struct that implements thebroker.Brokerinterface (seebroker/broker.go). - Add a
Connect(uri *dsn.DSN)constructor. - Register the new provider in
broker.Connect(inbroker/broker.go). - Implement the cross-broker conformance tests in
broker/tests/against your new broker.
- Open a PR using the template in
.github/pull_request_template.md. - Add or update tests for any change to behavior.
- Run
go test -race ./...andstaticcheck ./...before pushing. - Update the documentation in this README if you change public API or add a new configuration knob.
- Keep PRs focused and small; prefer multiple reviewable PRs over one large one.
Radish follows Semantic Versioning. The current version
constants live in version.go and are exposed via radish.Version(). Build
metadata (git sha, build date) can be injected via -ldflags at release time.
See LICENSE.txt.