Per-stream position tracking helpers — pure functions, no process.
Position lives in two places:
- Postgres (
scriba_positionstable) — authoritative. Updated atomically with read-model writes inside the target'sEcto.Multiviamulti/5. Per-stream rows: PK(projection_name, projection_version, stream_id). - ETS (one shared named table for the whole BEAM, keyed on
{name, version, stream_id}tuples) — hot-read cache forScriba.info/1. Not authoritative.
Shared-table model
The cache lives in a single named ETS table — Scriba.Position.Cache — that
is created once by Scriba.Supervisor.init/1 and lives for the
application's lifetime. Each cached entry is keyed by a
{name, version, stream_id} tuple; init_cache/3 and drop_cache/2
scope all their work to one projection's rows in that shared table.
This replaces the earlier per-projection :named_table design, which
generated a fresh atom per projection — unbounded growth of the BEAM
atom table when projection names were not bounded (e.g. property tests
with :erlang.unique_integer/1-derived names). The shared table allocates
exactly one atom regardless of projection count.
No PositionStore GenServer mediates the cache. Workers (the Pipeline)
write directly to the shared table via cache_put/4. Readers
(Scriba.info/2, telemetry) read directly via cache_get/3 /
stream_positions/2 and the internal safe-position helper.
Lifecycle
Scriba.Supervisor.init/1callscreate_shared_table/0once at boot.Coordinatoron entering:runningcallsinit_cache/3— wipe-then-preload: deletes any stale rows for this projection (from a previously-crashed Coordinator), then preloads from Postgres if:repois set. Idempotent across pause→resume cycles.Coordinatoron entering:stoppedcallsdrop_cache/2to clean up this projection's rows. (Crash recovery is covered by the wipe in the nextinit_cache/3;:stoppedcleanup matters for projections that the user explicitly stops permanently.)
Why no schema module
We use raw SQL through Ecto.Adapters.SQL.query/3 rather than an
Ecto.Schema. The position rows are internal plumbing — there's no
business logic that needs a changeset, and avoiding a schema means Scriba
doesn't impose primary-key or timestamp conventions on the user.
Summary
Functions
Returns {:ok, position} for one stream from the ETS cache, or :error
on cache miss.
Cache-first lookup with optional Postgres fallback.
Writes one stream's position into the shared cache. Silently no-ops if the shared table doesn't exist (path used by unit-test code that bypasses the supervisor).
Returns the atom name of the shared ETS cache table. Useful for
introspection (:ets.info/1, :observer).
Creates the shared ETS cache table. Called once from
Scriba.Supervisor.init/1. Idempotent — safe to call when the table
already exists (returns :ok either way).
Removes all cache entries belonging to (name, version) from the shared
table. Returns the number of rows deleted.
Wipes any existing cache entries for (name, version) and (optionally)
preloads up to 10000 rows from Postgres into the shared table.
Appends a per-stream position upsert to an Ecto.Multi. Use this from
inside an Scriba.Target.apply_batch/6 implementation building the
atomic-commit Multi alongside its handler operations.
Reads one stream's position from Postgres. Returns nil if no row exists
for this (name, version, stream_id).
Resolves the Ecto.Repo to use for position-tracking I/O — both the
Coordinator's init_cache/3 preload and the Pipeline's source-side
dedup cache_get/4 fallback consult this.
Returns a %{stream_id => position} map of all streams known to the
cache for this projection. Empty map if the shared table doesn't exist
or no streams are tracked.
Types
@type name() :: String.t()
@type position() :: non_neg_integer()
@type stream_id() :: String.t()
@type version() :: pos_integer()
Functions
Returns {:ok, position} for one stream from the ETS cache, or :error
on cache miss.
Pure ETS lookup — no I/O. If you want a cache-then-Postgres lookup that
also backfills the cache on miss, use cache_get/4 with repo:.
Cache-first lookup with optional Postgres fallback.
Behaviour:
- Hit in ETS →
{:ok, position}(no Postgres call). - Miss in ETS, no
:repoopt →:error. - Miss in ETS,
:repoopt set → reads from Postgres viaread_from_repo/4. If the row exists, the cache is back-filled and{:ok, position}is returned. If the row does not exist (no commit for this stream yet), returns:error.
Source-side dedup is the primary caller of the
:repo-backed form: dedup needs the durable cursor for a stream that
hasn't appeared in the cache yet (e.g. a newly-discovered stream after
Coordinator restart with a cache preloaded only up to @preload_cap
rows).
Writes one stream's position into the shared cache. Silently no-ops if the shared table doesn't exist (path used by unit-test code that bypasses the supervisor).
@spec cache_table() :: atom()
Returns the atom name of the shared ETS cache table. Useful for
introspection (:ets.info/1, :observer).
@spec drop_cache(name(), version()) :: non_neg_integer()
Removes all cache entries belonging to (name, version) from the shared
table. Returns the number of rows deleted.
No-op (returns 0) if the shared table doesn't exist yet — this can happen in unit-tested code paths that bypass the application supervisor.
Wipes any existing cache entries for (name, version) and (optionally)
preloads up to 10000 rows from Postgres into the shared table.
Pass repo: SomeRepo to enable Postgres preload. Beyond the
@preload_cap cutoff, un-preloaded streams fall back to lazy lookup via
cache_get/4 with repo:.
Returns :ok.
Emits [:scriba, :projection, :cache_initialized] telemetry with
measurements %{wiped_count, preloaded_count} and metadata
%{name, version, source} where source is :postgres (preloaded) or
:empty (no repo configured).
@spec multi(Ecto.Multi.t(), name(), version(), stream_id(), position()) :: Ecto.Multi.t()
Appends a per-stream position upsert to an Ecto.Multi. Use this from
inside an Scriba.Target.apply_batch/6 implementation building the
atomic-commit Multi alongside its handler operations.
The Multi step is keyed by {:scriba_position, stream_id} so a batch
touching N streams produces N independent steps.
Monotonicity is enforced here, not upstream
The upsert is GREATEST(existing, incoming), so a cursor can never move
backwards no matter what the caller passes.
The Pipeline also filters :skip results out of stream_advances so a
redelivered below-cursor batch does not regress the cursor. That filter is
still correct and still wanted — but it is one Enum.reject/2 in another
module, and a cursor moving backwards is this library's worst failure mode:
it silently re-applies committed effects. The invariant belongs in the
storage layer where no upstream change can violate it.
Trade-off, deliberately taken: GREATEST also masks a genuine Pipeline bug
that computes a regressing advance, converting loud corruption into a silent
no-op. That is the right trade for a v0.1 whose stated first principle is
correctness over throughput. If detection is later wanted, add
WHERE EXCLUDED.position > scriba_positions.position and raise on zero rows
affected — but do not go back to an unconditional SET.
Reads one stream's position from Postgres. Returns nil if no row exists
for this (name, version, stream_id).
Resolves the Ecto.Repo to use for position-tracking I/O — both the
Coordinator's init_cache/3 preload and the Pipeline's source-side
dedup cache_get/4 fallback consult this.
Single source of truth: keeping repo resolution in one place means the Coordinator and Pipeline can never disagree about which repo backs the cache. Diverging would silently break source-side dedup (Pipeline reads one repo, Coordinator writes another).
Order:
- Explicit
:repoopt on the projection wins (override path; e.g. a custom target that also wants cache preload from Postgres). Scriba.Target.Ecto's target_spec carries:repoin its target opts — extract it. Raises if missing, since the Ecto target needs it anyway.- Otherwise
nil(Test target, custom non-Postgres targets).
Returns a %{stream_id => position} map of all streams known to the
cache for this projection. Empty map if the shared table doesn't exist
or no streams are tracked.