Tay
Tay is an embedded, durable, single-node background-job engine for Elixir. It stores an append-only job history locally, runs supervised Elixir workers, and can dispatch stable task keys to local Python workers over a Unix socket. It does not require a database or broker. This is a public preview with a constrained, target-validated operating profile.
Tay can also run as a standalone production OCI image for non-Elixir applications. This is an additional distribution mode around the same Engine; the Hex dependency remains the normal Elixir installation. See the standalone runtime guide for its volumes, permissions, configuration, health check, restart behavior, and Compose example.
When an operational web UI is required, the dashboard-enabled image combines one Tay Engine, the same executor socket, and the optional LiveView dashboard. It replaces the headless image rather than running as a sidecar. See the dashboard container guide.
Start locally
Use Elixir 1.20 and Erlang/OTP 29 with a C11 compiler (cc) available when
building. Add {:tay, "~> 0.9.0"} to your application's Mix dependencies, or
{:tay, path: "../tay"} for this checkout. Run mix deps.get and mix compile.
For a development run, choose a dedicated absolute directory and initialize it once. Initialization refuses existing history and is never a repair command:
export TAY_DATA_DIR="$PWD/var/tay"
mix tay.storage.init --data-dir "$TAY_DATA_DIR" --durability write
Define a worker with a stable key:
defmodule MyApp.HelloWorker do
use Tay.Worker, key: "hello.v1", queue: :default
@impl true
def perform(%Tay.Job{args: %{"name" => name}}) do
IO.puts("Hello, #{name}!")
:ok
end
end
Add Tay to your application's supervisor. The :write mode below is for
development only and has no power-loss durability guarantee:
children = [
Tay.child_spec(
data_dir: System.fetch_env!("TAY_DATA_DIR"),
durability: :write,
workers: %{"hello.v1" => MyApp.HelloWorker},
queues: [default: 2]
)
]
Supervisor.start_link(children, strategy: :one_for_one)
Explicit initialization remains the default. An embedding application may
instead pass initialize: :if_missing to Tay.child_spec/1. That option only
initializes a genuinely missing storage root before the normal full recovery
path; it never repairs, replaces, truncates, or reinitializes existing storage.
Once the Engine is ready, submit and inspect a job:
%{state: :ready} = Tay.status()
{:ok, intent} = MyApp.HelloWorker.new(%{"name" => "Ada"})
{:ok, job} = Tay.insert(intent)
{:ok, current} = Tay.get_job(job.id)
IO.inspect(current.state)
List and summarize jobs through the bounded public inspection API:
{:ok, %{jobs: jobs, next_cursor: cursor}} =
Tay.jobs(states: [:retryable, :discarded], queues: [:default], limit: 50)
{:ok, counts} = Tay.stats()
{:ok, queues} = Tay.queues()
The separately published optional tay_dashboard package provides an official
Phoenix LiveView UI for these APIs. It lives in this repository under the
dashboard/ project,
but Phoenix, LiveView, and Plug are not dependencies of the core tay package.
See its
README
for installation, router mounting, and access-control guidance.
Keep the original intent until an insertion outcome is known. A lost reply may follow a durable write; reconcile by job ID or resubmit the same intent, never a newly generated ID. External worker effects may run again after a crash, so make them idempotent or reconcile them in the application.
Local Python executors
The Engine starts a local Unix-domain socket automatically. The separate Python
SDK discovers the same path; neither side needs a socket setting for normal
local use. Install the SDK with python -m pip install tay-client (or
python -m pip install ./clients/python from this checkout), then:
from tay import Tay
tay = Tay(capacity=4)
@tay.task(name="billing.capture.v1")
def capture(invoice_id: str) -> dict:
return {"invoice_id": invoice_id}
async def submit() -> None:
await tay.start()
job = await capture.enqueue("inv-42", submission_id="capture:inv-42")
print(await job.status())
Python schedules
The Python client accepts standard five-field Unix cron expressions. Schedules
use UTC by default; pass a fixed numeric offset when needed. The API accepts
catch_up="latest" (default) or catch_up="all", and defaults to
overlap="skip".
# At 08:00 on weekdays in UTC+02.
await tay.schedule("billing.capture.v1", cron="0 8 * * 1-5", timezone="+02")
# Start in ten minutes, then repeat every fifteen minutes.
await tay.every("billing.capture.v1", minutes=15, delay=600)
Use start_at=<UTC milliseconds> for an absolute first run instead of
delay; those options cannot be combined. The current listener schedule
registry belongs to the live execution generation. Durable schedule recovery,
restart catch-up, and enforced overlap policies are not yet implemented.
The socket is local only; there is no TCP listener or multi-host worker protocol. See the protocol contract for discovery, security, request types, and result retention.
Standalone container
The release workflow publishes a self-contained Linux image for amd64 and
arm64 as ghcr.io/andriisydorenko1904/tay:0.9.6. It includes the Erlang VM
and Tay runtime, so the host needs Docker (or another OCI runtime), not Elixir
or Erlang. Pull it directly with:
docker pull ghcr.io/andriisydorenko1904/tay:0.9.6
A typical non-Elixir deployment runs that image beside a language worker and
shares only the /run/tay socket volume. Durable state belongs on
/var/lib/tay. The included example starts both services:
docker compose -f examples/standalone/docker-compose.yml up --build
The container is single-node, single-writer, non-root, and compatible with a read-only root filesystem. It does not execute user job code. Deleting its data volume deletes Tay's durable state; deleting its socket volume does not.
Standalone container with Dashboard
The dashboard distribution contains Tay core and a small Phoenix host in one release. Start the repository example with:
docker compose -f examples/dashboard/docker-compose.yml up --build
Open http://localhost:4000/tay. The example binds only to host loopback and
leaves optional Basic authentication disabled. The image is
ghcr.io/andriisydorenko1904/tay-dashboard:0.9.6. It owns the Store, so never
run it beside the headless tay image on the same data volume.
See the complete dashboard container guide for configuration, optional Basic authentication, reverse-proxy guidance, persistence, and upgrade rules.
License
The Tay engine is source-available under the Elastic License 2.0. Internal use, including use inside an ordinary commercial SaaS product, is free. The license does not permit offering Tay itself, or a substantial set of its functionality, to third parties as a hosted or managed service. Commercial terms for that use are available separately; see commercial licensing.
The separately distributed Python client is licensed under MIT.
Operate safely
Production :sync requires Linux, an explicitly validated supported local
filesystem, and validated_filesystem: true. macOS supports explicit
development :write only. Tay evaluates obsolete history automatically through
bounded-retention, stop-the-world Store-v2 compaction. It has no automatic tail
repair, live backup, online compaction, or exactly-once external effects. A corrupt or unsupported
history refuses writable startup and preserves the evidence. Do not delete
individual log files to recover capacity.
Start with operations for initialization, inspection, cold backup/restore, and incident response. The storage contract describes persistent formats and recovery; compatibility covers supported platforms, upgrade boundaries, and finite release limits. The standalone guide covers the official Docker/OCI runtime.