Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2,607 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Eta

Eta is a small OCaml 5 effect/runtime library. It exposes a typed ('a, 'err) Effect.t for describing pure values, failures, concurrency, resource scopes, and observability, plus backend-neutral interpreters.

Eta is shaped by TypeScript Effect and Scala ZIO, but it is not a compatibility layer for their full API surface. Deliberate differences are documented in ZIO / Effect Boundaries. The recommended application style is documented in Eta API Style.

Core principle: applications own state; Eta owns effect description and interpretation. There is no global environment, layer graph, service locator, or state container. Pass dependencies as ordinary OCaml values.

Optional capabilities live in separate eta_<feature> opam packages. The root eta package contains only the core effect model and interpreter contract. Add eta_eio for an Eio backend, eta_http for HTTP, eta_sql for SQLite, eta_otel for OpenTelemetry, and so on. See docs/packages.md for the full map.

Quick start

Install dependencies and build with Nix (recommended):

nix develop -c dune build @install

Or with opam:

opam install . --deps-only --with-test
dune build @install

A minimal executable uses (libraries eta eta_eio eio_main):

(executable
 (name hello)
 (libraries eta eta_eio eio_main))
open Eta

let program () =
  let open Syntax in
  (let* n =
     Effect.sync_result (fun () -> Ok (1 + 1))
   in
   if n < 3 then Effect.fail `Too_small else Effect.pure n)
  |> Effect.fold ~ok:Fun.id ~error:(fun `Too_small -> 3)

let () =
  Eio_main.run @@ fun stdenv ->
  Eio.Switch.run @@ fun sw ->
  let rt = Eta_eio.Runtime.create ~sw ~clock:(Eio.Stdenv.clock stdenv) () in
  match Eta.Runtime.run rt (program ()) with
  | Exit.Ok n -> Format.printf "%d@." n
  | Exit.Error _ -> assert false

Eta.Runtime is the backend-neutral interpreter; Eta_eio.Runtime.create is the Eio-backed constructor. The root eta package does not include a runtime backend.

Features

Core effect model and runtime boundaries:

Module Purpose
Effect Abstract description for pure values, typed failure, sync leaves, mapping/sequencing primitives, error recovery, timeout, race, repeat, retry, uninterruptible regions, scopes.
Syntax Binding operators for Effect.t: let*, let+, let@, and*, and and+.
Supervisor Scope-bound nursery for child effects with observable failures, typed await, and cancellation.
Cause Slim failure tree: typed failure, unchecked exception, interruption, and parallel failures.
Exit Runtime boundary result: success or failure cause.
Runtime Backend-neutral interpreter for Effect.t, built from a runtime module.
Duration Millisecond-precision durations.
Schedule Pure recurrence descriptions for repeat and retry.
Eta_cache.Refreshable Optional refreshable cached values with explicit or lexical scheduled refresh.
Capabilities Small object-type traits for runtime services and explicit dependencies.
Trace_context W3C traceparent/tracestate/baggage extract and inject helpers for distributed tracing.

Concurrency, observability, and data primitives in the root package:

Module Purpose
Tracer In-memory and noop tracer implementations for tests and disabled tracing.
Logger In-memory and noop logger implementations.
Meter In-memory and noop meter implementations.
Sampler Trace sampling policies: always-on, always-off, ratio, parent-based.
Log_level Severity levels for log records.
Random Deterministic random helpers over Capabilities.random.
Promise Backend-neutral one-shot result with cancellation-safe broadcast wait.
Queue Same-domain unbounded FIFO with close/error fences.
Channel Same-domain bounded channel with backpressure.
Pubsub Same-domain scoped broadcast hub with explicit overflow policy.
Pool Same-domain bounded resource pool with lifecycle and health checks.
Semaphore Cancellation-safe counting semaphore.
Mutable_ref Named shared mutable cell backed by Atomic.t.

Sensitive-value redaction lives in the optional eta_redacted package, not in eta core.

API surface footguns

  • Effect.sync exceptions are unchecked defects (Cause.Die), not typed failures. Catch expected errors by returning result from the synchronous leaf, then use Effect.sync_result f; for an option leaf, use Effect.sync_option ~if_none (None is typed failure, ordinary raises stay defects).
  • Effect.bind_error handles typed failures only; it does not catch defects, interruption, or finalizer failures. Use Effect.catch_some when only some typed failures should recover and non-matches must preserve the original cause.
  • Effect.discard drops a success value without recovering any cause.
  • Effect.ignore_errors is only for best-effort effects. It discards the success value and suppresses typed failures, but defects, interruption, and finalizer failures still surface.
  • Effect.to_result turns the typed failure channel into an ordinary OCaml result value inside the workflow. It does not catch defects, interruption, or finalizer failures.
  • Effect.par, all, race, and map_par run child effects as fibers on the current runtime substrate, not on CPU worker domains. Use eta_par for CPU parallelism.
  • Effect.uninterruptible defers cancellation; it does not turn interruption into a typed failure.
  • Queue, Channel, Pubsub, and Pool are same-runtime primitives. Do not pass them across eta_par domain boundaries.
  • Supervisor.scoped children cannot escape their nursery; handles are rank-2 scoped to the body.
  • Runtime.run_exn raises Failure for typed failures and interruption. Use Runtime.run when you need to inspect the cause.

When the compiler or the runtime pushes back, see Eta type errors, translated for the common messages, what they mean, and the canonical fixes.

Native Parallelism

The optional eta_par package contains Eta's native worker-domain scheduler. It is deliberately outside the root eta package so the core effect model can be interpreted by other runtimes.

let pool = Eta_par.Island.Pool.create ~domains:2 ()

let program =
  Eta_par.Island.run ~name:"square" ~pool (fun n -> n * n) 7

Finite island batches use Eta_par.Island.map, map_result, or all_settled. Island pools are explicit native resources; the root Eta.Runtime does not carry an ambient island pool or a per-run island override.

For structured CPU parallelism over arrays or recursive fork/join algorithms, use Eta_par.run, Eta_par.join, Eta_par.par_map, and Eta_par.Iter. See Concurrency primitives in Eta for the decision flow between Effect.sync, islands, fork-join parallelism, and blocking work.

Native Blocking Calls

The optional eta_blocking package contains bounded OS-thread worker pools for synchronous native calls such as database engines, syscalls, and blocking C libraries. Runtime packages provide the worker substrate; for example eta_eio installs an Eio run_in_systhread runner by default.

Use Eta_blocking.run when the callback returns an ordinary value, and Eta_blocking.run_result when the callback returns expected typed failures as result.

PPX Helpers

The optional ppx_eta package provides small syntax helpers. They expand to ordinary Eta.Effect, Eta_observability, and object code; they do not infer services, build dependency graphs, or add runtime semantics.

let load_user id =
  [%eta.fn
    (Eta_observability.named "db.query" (Effect.sync (fun () -> Db.user id)))]

It expands to:

Eta_observability.fn __POS__ __FUNCTION__
  (Eta_observability.named "db.query" (Effect.sync (fun () -> Db.user id)))

Leaf effects use ordinary OCaml captures:

let current_user auth =
  [%eta.sync "auth.current_user" (Auth.current_user auth)]

This expands to Eta_observability.fn __POS__ __FUNCTION__ (Eta_observability.named ... (Effect.sync ...)), with a zero-argument callback.

Result-returning leaves use the same shape with [%eta.result]:

let user = [%eta.result "db.find" (Db.find db id)]

It expands to Eta_observability.fn __POS__ __FUNCTION__ (Eta_observability.named "db.find" (Effect.sync_result (fun () -> Db.find db id))). The string is the span name; Ok succeeds, Error is typed failure, and raises become Cause.Die. Use Effect.from_result for already-computed results; keep hand-written named/fn when you need ~error_pp, dynamic names, or other kwargs.

Use it by adding ppx_eta to your test or executable preprocessors:

(preprocess
 (pps ppx_eta))

The rewritten target must also list eta_observability in its libraries, because generated fn and named calls use that optional package.

Derived typed-error printers

[@@deriving eta_error] generates an ordinary Format printer for a public, explicit-tag closed polymorphic-variant alias:

type err =
  [ `Not_found of string
  | `Db of int
  | `Unavailable ]
[@@deriving eta_error]

let save =
  Eta_observability.named ~error_pp:pp_err "db.save" (Effect.fail (`Db 7))

Put the deriving annotation on the declaration in both the .ml and .mli. The structure generator defines pp_<type-name> as a plain typed match:

let pp_err : Format.formatter -> err -> unit = fun fmt -> function
  | `Not_found id -> Format.fprintf fmt "not_found:%s" id
  | `Db code -> Format.fprintf fmt "db:%d" code
  | `Unavailable -> Format.pp_print_string fmt "unavailable"

The signature generator exposes the matching public value. For the err declaration above, the generated interface item is:

val pp_err : Format.formatter -> err -> unit

Constructor text is lowercase with underscores preserved: Not_found becomes not_found. These strings are stable telemetry, not display-only prose; renaming a constructor changes span statuses and may require dashboard changes.

Version 1 supports nullary tags and one payload of type string, int, int64, float, or bool. Name a printer for any other single payload with [@eta.render f]:

type err =
  [ `Decode of payload [@eta.render Payload.pp] ]
[@@deriving eta_error]

The attribute must name a Format.formatter -> payload -> unit printer. Unsupported payloads without this attribute fail during PPX expansion; the error identifies the constructor and tells you to use a built-in payload or add [@eta.render f]. Nominal variants, private aliases, open or restricted rows, inherited rows, and multi-value/tuple payloads are outside version 1 and are rejected at PPX time rather than rendered as placeholders.

Derivation does not install ambient policy. Pass the generated function through ?error_pp on Eta_observability.named / Eta_observability.fn, or explicitly scope it with Eta_observability.with_error_pp. As with every Eta error printer, it must be total: a raising printer becomes a defect through the ordinary runtime capture path.

For Eta SQL (eta_sql), the separate ppx_eta_sql package provides optional table declaration sugar. Add it to the SQL consumer's preprocessors:

(preprocess
 (pps ppx_eta_sql))
[%%eta.sql.table
type users = {
  id : int [@primary_key];
  name : string [@not_null];
  active : bool [@not_null];
}]

It expands to the ordinary Sql.Table.Make module shape, typed columns, a users_row record, Users.all, and Users.schema. The input declaration is consumed by the PPX; application code refers to users_row for all-column result records. Partial projections still use the ordinary tuple-returning builder helpers.

Both PPXs are deliberately syntactic. They do not provide Layer, Context, Tag, implicit service lookup, inferred dependency construction, or argument conversion.

Resource Scopes

Use Effect.with_resource for body-bounded resource lifetimes. Finalizers run on success, typed failure, unchecked defect, and cancellation.

let with_db =
  let acquire = Eta_observability.named "db.open" (Effect.sync (fun () -> Db.open_)) in
  let release handle =
    Eta_observability.named "db.close" (Effect.sync (fun () -> Db.close handle))
  in
  Effect.with_resource ~acquire ~release

Use let@ from Eta.Syntax to keep callback-shaped lifecycle code flat:

let load_user id =
  let open Eta.Syntax in
  let@ db = with_db in
  Effect.sync_result (fun () -> Db.load_user db id)

Use Effect.acquire_release directly when a resource should live until an enclosing runtime or Effect.with_scope boundary rather than just one callback body.

For one-shot cleanup around a single effect, use Effect.finally:

let write_then_flush writer bytes =
  Writer.write writer bytes
  |> Effect.finally (Effect.sync (fun () -> Writer.flush writer))

The cleanup runs after success, typed failure, unchecked defect, or cancellation. If both the body and cleanup fail, Eta reports the cleanup failure as suppressed under the body failure, using the same cause shape as acquire_release.

Services

Eta does not ship Layer.t, Tag, Context, or Effect.provide. Build service graphs with ordinary OCaml functions and keep resource lifetime inside Effect.with_scope.

See Services Without Layer for the project convention and failure modes. Why no env channel: object-row environments are not portable across OxCaml domain boundaries, and every Layer/provide/env-row component was measured and found worse than ordinary OCaml (evidence: docs/services.md and .scratch/research/envless-verdict-2026-07-26.md).

Supervised Concurrency

Use Supervisor.scoped when a parent needs handles for child effects without letting those handles escape their owning scope.

The { run = ... } body is intentional: it gives OCaml a rank-2 scope token, so the type checker rejects returning a child handle after the nursery closes.

let supervised =
  Supervisor.scoped {
    run =
      fun sup ->
        let open Supervisor.Scope in
        let* child = start sup (lift (Effect.pure 42)) in
        await child
  }

Child failures are recorded on the supervisor and do not fail the parent unless you await the child or explicitly check a failure threshold.

let observed =
  Supervisor.scoped {
    run =
      fun sup ->
        let open Supervisor.Scope in
        let* _child = start sup (fail `Refresh_failed) in
        let* () = yield in
        failures sup
  }

Supervisor.scoped is the public way to start child work. Runtime-owned background work stays internal to modules that own that lifecycle. For cached values that refresh while a workflow runs, Eta_cache.Refreshable.with_auto owns the refresh loop lexically and records refresh failures through Eta_cache.Refreshable.failures.

For background work that exists only while a body runs, prefer Effect.with_background:

let with_stream_reader flow use =
  Effect.with_background
    ~name:"stream.reader"
    (Effect.sync (fun () -> read_loop flow))
    (fun () -> use flow)

This is the structured shape for daemon-like application work without using a runtime-owned daemon: accept loops scoped to a server lifetime, stream readers scoped to a handle, heartbeat/ticker loops scoped to a session, and resource readers scoped by acquire_release. The background child is cancelled when the body returns or fails. If the child fails first, with_background cancels the body and propagates the child's cause. Use with_supervised_background when child failure should be recorded without interrupting the body; suppress typed failures explicitly with ignore_errors when that work is best-effort.

Concurrency Data

Choose the smallest primitive that owns the required portability or lifecycle:

Need Use
Bounded producer/consumer queue Eio.Stream
Backend-neutral one-shot signal or shared result Eta.Promise
Countdown or wait-for-condition Eio.Condition with Eio.Mutex
Eta-owned FIFO with close/error fences Eta.Queue or Eta.Channel
Scoped broadcast with drop/backpressure policy Eta.Pubsub

Use Eta.Promise when the same one-shot coordination must run on native and js_of_ocaml backends. Eio.Promise remains the right direct primitive for Eio-only code; this wrapper is a portability fence, not a takeover of local Eio coordination.

Pubsub uses a shared hub buffer with scoped subscriptions. Published messages are admitted once at the hub, then retained until current subscribers receive them or unsubscribe. The overflow policy is explicit:

let hub = Pubsub.create ~overflow:(Pubsub.Backpressure { capacity = 64 }) ()

let use_events =
  Pubsub.subscribe hub @@ fun sub ->
  let open Syntax in
  let rec loop () =
    let* event = Pubsub.recv sub in
    let* () = handle event in
    loop ()
  in
  loop ()

Use Unbounded only for low-volume signals where unbounded retention is an intentional choice. Drop_new drops a new message for all current subscribers when the hub is full. Backpressure waits before admitting a message, and publisher cancellation while waiting cannot partially publish to some subscribers.

Wrap Eio operations in Effect.sync at the leaf when they need Eta tracing names or defect diagnostics. If a synchronous leaf has expected failures, return an ordinary OCaml result and lift it with Effect.sync_result; exceptions remain unchecked defects. If a protocol is reusable and owns lifecycle semantics, prefer a focused module such as Eta_cache.Refreshable or Pubsub rather than a generic concurrency-data wrapper.

Redacted Values

The optional eta_redacted package provides Redacted.t, which wraps sensitive values so that formatting and JSON output show <redacted> instead of the underlying data. Equality and hash use the original value, so redacted strings can still be used as map keys.

let token = Redacted.make ~label:"api_key" "secret-token"
let auth_header = "Bearer " ^ Redacted.value token

The type is abstract and has no [@@deriving show] hooks; accidental formatter derivation is blocked at the API level. wipe_unsafe best-effort erases the cell, but the value may still exist in other references.

When adding values to tracer attributes or logs, explicitly extract or render them. The tracer attribute API remains (string * string) list; wrap secrets in Redacted.t at the source and call Redacted.value or Format.asprintf "%a" Redacted.pp only when constructing the attribute list.

Trace Propagation

Tracing is configured on the runtime. eta_observability ships the public in-memory tracer; root eta uses a private noop capability when none is installed:

let tracer = Eta_observability.Tracer.in_memory ()

let rt =
  Eta_eio.Runtime.create ~sw ~clock ~tracer:(Eta_observability.Tracer.as_capability tracer) ()

Production exporters such as OpenTelemetry live in optional packages:

let rt =
  Eta_eio.Runtime.create ~sw ~clock ~tracer:(Eta_otel.tracer exporter) ()

Observability is pay-as-you-go. A runtime created without tracer, logger, or meter capabilities uses Eta's noop sinks and cuts off tracing/logging/metrics inside the core interpreter before records enter eta_otel queues or OTLP/JSON encoding. Eta_observability.named still keeps Eta diagnostics such as defect span names and annotations, so use it where that context is useful rather than as a per-element marker in the hottest loops.

Typed failures render as "<typed failure>" in span status and exception events unless a named effect supplies a typed renderer:

let save =
  Eta_observability.named
    ~error_pp:(fun fmt -> function
      | `Db code -> Format.fprintf fmt "db:%d" code)
    "db.save"
    (Effect.fail (`Db 42))

Use Eta_observability.with_error_pp when several named spans share the same error channel. The renderer is scoped to that effect subtree; caught inner errors keep the conservative default unless they provide their own renderer.

Span attributes can be attached one at a time or as a list:

let load_rows : (int list, [ `Fetch_failed ]) Effect.t =
  Effect.pure [ 1; 2; 3 ]

let load_assets =
  Eta_observability.fn
    ~attrs:[ ("component", "ingest"); ("source", "yahoo") ]
    __POS__ __FUNCTION__
    (Eta_observability.with_result_attrs
       ~ok_attrs:(fun rows ->
         [ ("result", "ok"); ("row_count", string_of_int (List.length rows)) ])
       ~err_attrs:(fun `Fetch_failed -> [ ("result", "fetch_failed") ])
       load_rows)

Use Eta_observability.event for structured markers on the active span:

let symbol = "AAPL"

let progress =
  Eta_observability.event ~attrs:[ ("asset", symbol) ] "ingest.assets.progress"

Eta_observability.event is not a log record. It is dropped when no span is active. Put with_result_attrs inside Eta_observability.named or Eta_observability.fn; outcome attributes are also dropped when the wrapped effect settles outside an active span.

At service boundaries, extract W3C headers and install the context around the request effect:

let handle headers =
  let body = Eta_observability.named ~kind:Capabilities.Server "http.request" work in
  match Eta_observability.Trace_context.extract headers with
  | None -> body
  | Some ctx -> Eta_observability.with_context ctx body

For eta_http request values, use the request helper instead of reaching into the header list:

let handle_request request =
  let body = Eta_observability.named ~kind:Capabilities.Server "http.request" work in
  match Eta_http.Trace_context.extract_request request with
  | None -> body
  | Some ctx -> Eta_observability.with_context ctx body

Inside the request, outbound clients can read and inject the current context:

let outbound_headers =
  Eta_observability.current_context
  |> Effect.map (function
       | None -> []
       | Some ctx -> Eta_observability.Trace_context.inject ctx)

Eta_observability.with_context preserves the W3C sampled flag, tracestate, and baggage. Eta_observability.with_external_parent remains as a compatibility helper when only a trace ID and parent span ID are available.

Development

Eta uses a Nix-managed OxCaml 5.2.0+ox toolchain. Enter the shell with:

nix develop

First-time setup creates the local opam switch:

eta-oxcaml-init

The handoff gate is:

nix develop -c dune runtest --force

Eta targets OxCaml 5.2.0+ox only. Eta source can use OxCaml modes, stack allocation, unboxed layouts, zero-allocation checks, and other OxCaml extensions. Eta does not provide an upstream OCaml compatibility gate.

The active OxCaml toolchain does not build the js_of_ocaml packages. The native gates do not verify those adapters.

Build all installable packages without running tests:

nix develop -c dune build @install

Benchmarks are opt-in and are not run by dune runtest:

nix develop -c bash bench/run.sh --quick   # quick snapshot
nix develop -c dune build @bench           # build runtime benchmark executables

See bench/README.md for the JSON format, comparison tool, and bisect workflow.

Without Nix, use an OCaml 5.2.0+ox switch, install dependencies, then run the same Dune gates:

opam install . --deps-only --with-test
dune build @install
dune runtest --force

Footguns:

  • dune build without an alias also builds tests, examples, and benchmark executables. Use dune build @install when you only need installable packages.
  • test/http is the low-level protocol test target. test/http_eio is the green Eio transport gate.

The historical research journal lives at .scratch/research/journal.md. It records project history and local design reasoning, but it is not part of the published package.

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages