Singletons and cron jobs
An entity gives you one owner per id — every message for
"counter-123" lands on a single live instance. A singleton gives you one owner
across the whole cluster: a single named effect that runs on exactly one runner at a
time, no matter how many runners are up.
The mechanism is the same sharding you already use for entities. A singleton’s name
(plus an optional shard group) hashes to a shard; whichever runner currently owns that
shard runs the effect, and everyone else stays idle. When the owning runner leaves, the
shard moves and another runner resumes the singleton. You get a fleet-wide background
worker that fails over automatically without writing any leader-election code.
Reach for a singleton when you want exactly one of something for the entire deployment: a poller, a queue consumer, a scheduler, a metrics aggregator, a maintenance loop.
A cluster singleton
Section titled “A cluster singleton”Singleton.make(name, run, options) returns a Layer that registers run with
sharding under name. You merge it into your cluster layer next to your entity layers.
import { NodeClusterSocket, NodeRuntime } from "@effect/platform-node"import { Effect, Layer, Schedule } from "effect"import { Singleton } from "effect/unstable/cluster"import type { SqlClient } from "effect/unstable/sql"
// A background poller that should run on exactly one runner in the cluster.// `run` is a long-lived effect: it loops forever until interrupted.const PollInbox = Singleton.make( "poll-inbox", Effect.gen(function*() { yield* Effect.log("starting inbox poller")
const pollOnce = Effect.gen(function*() { // ... fetch new items, process them ... yield* Effect.log("polled inbox") }).pipe( // Handle expected failures HERE. Failures that escape `run` are surfaced // as defects by sharding, which tears the singleton down. Effect.catch((error) => Effect.logWarning("poll failed, will retry", error)) )
// A simple polling loop. It is interruptible at every `sleep`, so when // ownership moves the fiber stops cleanly between iterations. yield* pollOnce.pipe(Effect.repeat(Schedule.spaced("10 seconds"))) }))
// Wiring: a singleton layer needs `Sharding`, which the cluster transport// provides. Merge it alongside your entity layers, then provide the cluster.declare const SqlClientLayer: Layer.Layer<SqlClient.SqlClient>
const ClusterLayer = NodeClusterSocket.layer().pipe( Layer.provide(SqlClientLayer))
const AppLayer = Layer.mergeAll( PollInbox // ...CounterLayer, OtherEntityLayer, etc.).pipe(Layer.provide(ClusterLayer))
// `Layer.launch` keeps the runner alive so it can hold shards and run work.Layer.launch(AppLayer).pipe(NodeRuntime.runMain)How ownership works
Section titled “How ownership works”-
Placement. The singleton’s
nameand shard group hash to aShardId, exactly like an entity id does. The singleton is “located” on that shard. See Sharding & runners for how shards map to runners. -
Activation. When the local runner acquires the shard, sharding forks
run. Whilerunis alive, no other runner runs this singleton. -
Failover. When the owning runner leaves (crash, deploy, rebalance), its shards move to surviving runners. The new owner starts a fresh copy of
run; the old fiber is interrupted. -
Teardown. Closing the layer scope removes the registration and interrupts the fiber if it is running.
Reference
Section titled “Reference”Singleton.make(name, run, options)
Section titled “Singleton.make(name, run, options)”The only public export of the Singleton module. Registers run as a cluster-wide
singleton under name and returns a Layer.
name: string— the singleton’s identity; participates in shard placement and must be unique within its shard group.run: Effect<void, E, R>— the effect to run on the owning runner. Typically a loop that runs until interrupted.options.shardGroup?: string— pin the singleton to a specific shard group so its ownership is coordinated with that group’s work (defaults to the standard group).
The returned layer’s type is Layer<never, never, Sharding | Exclude<R, Scope>>: it
requires Sharding (supplied by the cluster transport) plus whatever services run
needs, except Scope (the registration provides its own).
import { Effect, Layer } from "effect"import { Singleton } from "effect/unstable/cluster"
const ReconcileBalances = Singleton.make( "reconcile-balances", Effect.log("reconciling..."), { shardGroup: "billing" })// ReconcileBalances: Layer<never, never, Sharding>// => one runner in the "billing" shard group runs the effect
// Merge several singletons together like any other layers.const Workers = Layer.mergeAll(ReconcileBalances /*, ...other singletons */)Singleton.make is a thin wrapper over the lower-level
Sharding.registerSingleton(name, run, options), which does the same thing as an
effect inside an existing scope rather than as a layer. Use the layer form unless you
are already composing inside a Sharding-aware effect; see
Sharding & runners.
SingletonAddress
Section titled “SingletonAddress”The runtime address assigned to a registered singleton. It is a Schema.Class pairing
the singleton name with the ShardId chosen from that name and shard group. You
encounter it in sharding registration events, metrics, and singleton-ownership
diagnostics — you rarely construct it yourself.
import { ShardId, SingletonAddress } from "effect/unstable/cluster"
const address = new SingletonAddress.SingletonAddress({ name: "poll-inbox", shardId: ShardId.make("default", 7)})
address.name // => "poll-inbox"address.shardId // => ShardId for group "default", id 7// Equality and hashing are by (name, shardId), so the same singleton in a// different shard group is a different address.Scheduled jobs across the cluster (ClusterCron)
Section titled “Scheduled jobs across the cluster (ClusterCron)”ClusterCron runs a cron schedule as a clustered job: the effect
fires on its schedule, on exactly one runner, with failover.
It is built from two cluster pieces you have already seen:
- A singleton performs the initial scheduling step — computing the first run time when the cluster starts.
- Each run is delivered as a persisted entity message at its scheduled time. After a run exits, the handler schedules the next occurrence the same way.
Because the schedule lives in persisted messages rather than in memory, the job
survives restarts and migrates with the shard. That durability is why ClusterCron
needs a real message storage backend (e.g. the SQL-backed
layer); the no-op in-memory store will not retain scheduled runs across a crash.
import { NodeClusterSocket, NodeRuntime } from "@effect/platform-node"import { Cron, Effect, Layer } from "effect"import { ClusterCron } from "effect/unstable/cluster"import type { SqlClient } from "effect/unstable/sql"
// 1. Build the schedule. Either parse a cron expression...const dailyAt4am = Cron.parseUnsafe("0 0 4 * * *") // sec min hour day month weekday// ...or construct one from explicit fields with Cron.make.
// 2. The work to run on each tick. Keep it idempotent (see gotchas).const cleanup = Effect.gen(function*() { yield* Effect.log("running daily cleanup") // ... delete expired rows, rotate files, etc ...})
// 3. Register the clustered cron job as a layer.const DailyCleanup = ClusterCron.make({ name: "daily-cleanup", cron: dailyAt4am, execute: cleanup, // skip a scheduled run if it is being delivered more than 2 hours late skipIfOlderThan: "2 hours"})
// 4. Wire it into the cluster. ClusterCron needs persisted message storage,// which the SQL-backed NodeClusterSocket transport provides.declare const SqlClientLayer: Layer.Layer<SqlClient.SqlClient>
const ClusterLayer = NodeClusterSocket.layer().pipe( Layer.provide(SqlClientLayer))
const AppLayer = Layer.mergeAll( DailyCleanup // ...entity and singleton layers...).pipe(Layer.provide(ClusterLayer))
Layer.launch(AppLayer).pipe(NodeRuntime.runMain)ClusterCron.make(options)
Section titled “ClusterCron.make(options)”The only public export of the ClusterCron module. Returns a
Layer<never, never, Sharding | Exclude<R, Scope>> — like a singleton, it requires
Sharding plus the services execute needs (minus Scope).
| Option | Type | Default | Meaning |
|---|---|---|---|
name | string | — | Unique job name; backs the internal singleton and entity (ClusterCron/<name>). |
cron | Cron.Cron | — | The schedule, from Cron.make or Cron.parse/parseUnsafe. |
execute | Effect<void, E, R> | — | The work to run on each tick. |
shardGroup | string | "default" | Shard group to run the job on. |
calculateNextRunFromPrevious | boolean | false | How the next run time is computed (see below). |
skipIfOlderThan | Duration.Input | "1 day" | Skip a scheduled run delivered later than this. |
import { Cron, Effect } from "effect"import { ClusterCron } from "effect/unstable/cluster"
const job = ClusterCron.make({ name: "hourly-report", cron: Cron.parseUnsafe("0 0 * * * *"), // top of every hour execute: Effect.log("generating report")})// job: Layer<never, never, Sharding>// => one runner generates the report each hour, with failovercalculateNextRunFromPrevious — catch up vs. preserve cadence. After a run
finishes, the next occurrence must be computed:
false(default): compute the next run from now (when the handler exited). If a run was delayed, the schedule effectively catches up from the current time and you never get a burst of backlogged runs.true: compute the next run from the previous scheduled time, preserving the original cadence even when a run was late. Use this when the schedule’s rhythm matters more than catching up (e.g. “every hour on the hour, no drift”).
skipIfOlderThan — drop stale runs. A long outage can leave a scheduled message
sitting in storage. When it finally delivers, if its scheduled time is older than
skipIfOlderThan, execute is skipped (the job still reschedules the next run).
The default of "1 day" means runs more than a day late are dropped. Align this with
your job’s semantics: a nightly cleanup that missed yesterday probably should skip
straight to tonight; a billing run might need a larger window or none of this behavior.
import { Cron, Effect } from "effect"import { ClusterCron } from "effect/unstable/cluster"
// Preserve the exact cadence and only skip runs more than 6 hours stale.const billing = ClusterCron.make({ name: "nightly-billing", cron: Cron.parseUnsafe("0 0 2 * * *"), // 02:00 every day execute: Effect.log("billing run"), calculateNextRunFromPrevious: true, skipIfOlderThan: "6 hours"})Next steps
Section titled “Next steps”- Sharding & runners — how names hash to shards, how shards are placed on runners, and what shard groups mean for singleton placement.
- Message storage — the persistence layer backing
ClusterCron’s scheduled runs and persisted entity messages. - Cron — building
Cron.Cronschedules withCron.makeandCron.parse, whichClusterCronconsumes.