OpentelemetryStatifier.SpanTable (OpentelemetryStatifier v0.3.0)

Copy Markdown View Source

Owns the ETS table that holds open macrostep spans and, per session, the last span context ots-j82's links read.

The table is created in init/1 and otherwise untouched by this process - it is :public and :named_table so handlers read and write it directly from the session process that calls OpentelemetryStatifier.Handler.handle_event/4, with no message hop through this GenServer. Owning the table this way (rather than letting setup/1 create it) means the table exists before any handler can fire, survives every session process, and gives ots-lt6's sweep timer a natural home - opentelemetry_ecto's latent "owned by whoever called setup" bug does not reach this package.

Row shapes, fixed here because ots-j82 and ots-lt6 both read them directly against the table name carried in OpentelemetryStatifier.Config:

{{:span, span_ref},             session_id, %SpanEntry{}}
{{:last_span_ctx, session_id},  session_id, span_ctx}
{{:invoke_parent, session_id},  session_id, span_ctx}
{{:session_pid, session_id},    session_id, pid}
{{:sibling_span, span_ref},     pid,        %SiblingEntry{}}

The :sibling_span row is the sibling families' half (ADR-0004): a [:statifier_persistence, :run, :step, :start] opens one keyed on the same span_ref convention, and it carries a pid in element 2 where the macrostep rows carry a session_id, because a step span is scoped to the process that drove it rather than to a logical session. That is what makes fetch_innermost_sibling_span/2 a one-match_object lookup for "is one of this bridge's own spans open around me, in this process" - the question every nested sibling span and every durable macrostep span asks.

The :invoke_parent row is written when a child session's :init event names an invoked_by parent whose macrostep span is open at that moment, and consumed (removed) by the child's own :initialize macrostep start, which turns it into a span link.

The :session_pid row records the session process behind session_id - the handler runs inside the session process, so self() at event time is that pid. It exists for the sweep: :terminate does not fire on a brutal kill, so sweep/1 walks these rows and, for every session whose process is no longer alive, ends any still-open macrostep spans with an error status and deletes all the session's rows - the design note's "sweeps entries whose sessions no longer exist rather than trusting the event alone". This GenServer runs the sweep on a timer; tests call sweep/1 directly.

session_id is duplicated into element 2 of every row shape so a sweeper can find every row for a session with one :ets.match_object/2 or :ets.match_delete/2, without decoding either row's structured value.

Summary

Functions

Returns a specification to start this module under a supervisor.

Removes every row session_id owns, first ending any still-open macrostep spans with an error status carrying orphan_message - an orphan is reported, never silently dropped. Called by the handler's :terminate clause and by sweep/1 for sessions whose process died without a :terminate.

Fetches the innermost open span for session_id - the entry with the greatest started_at among the session's open rows, since ADR-0039 re-entry can hold two spans open at once and an intra-macrostep event belongs to the most recently opened one. Returns :error when the session has no open span (an effect event racing a crash's cleanup is contract-legal, not a bug).

Fetches the innermost sibling span open in pid - the entry with the greatest started_at, since a parent run creating a durable child inside its own step holds two step spans open on one process. Returns :error when the process has none, which is the ordinary case for a host that attached only the sibling setups it uses.

Fetches the last span context recorded for session_id, returning {:ok, span_ctx} on a hit or :error for an unknown session.

Fetches the process recorded for session_id by put_session_pid/3, or :error for a session the bridge has not seen. The sibling handlers use it to check that a session's open macrostep span belongs to this process before landing a span event on it - a delivery-seam event on an Oban worker must never write onto a span another process has open for the same scope.

Creates (or returns, if it already exists under this process) a :public/:named_table ETS table under name. Tests call this directly with a unique name per test so they never depend on the application-started table.

Records the parent macrostep span context a child session's :initialize macrostep will link to, keyed by the child's session_id.

Records span_ctx as the last span context observed for session_id.

Stores an open span's %SpanEntry{} under its span_ref.

Records pid as the session process behind session_id, for the sweep's liveness check. Idempotent - the table is a :set, so a session's row is written once per macrostep at no accumulating cost.

Stores an open sibling span's %SiblingEntry{} under its span_ref, keyed for lookup by the process that opened it.

Ends the orphans a brutal kill leaves behind: for every :session_pid row whose process is no longer alive, delegates to delete_session/3, and for every :sibling_span row whose process is no longer alive, ends that span with an error status and deletes the row. A live process's rows are never touched - an open span on a live process is just a step or a macrostep in flight, whatever its age.

Looks up and removes the invoke-parent span context stored for session_id, returning {:ok, span_ctx} on a hit or :error when no parent was recorded - a session that was not invoked by anyone.

Looks up and removes the open span under span_ref, returning {:ok, entry} on a hit or :error on a miss - a :stop event with no matching :start is a contract-legal shape, not a bug, so this never raises.

Looks up and removes the open sibling span under span_ref. Returns :error on a miss, which - as for take_open_span/2 - is a contract-legal shape rather than a bug.

Functions

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

delete_session(table, session_id, orphan_message)

@spec delete_session(atom(), String.t(), String.t()) :: :ok

Removes every row session_id owns, first ending any still-open macrostep spans with an error status carrying orphan_message - an orphan is reported, never silently dropped. Called by the handler's :terminate clause and by sweep/1 for sessions whose process died without a :terminate.

fetch_innermost_open_span(table, session_id)

@spec fetch_innermost_open_span(atom(), String.t()) ::
  {:ok, OpentelemetryStatifier.SpanEntry.t()} | :error

Fetches the innermost open span for session_id - the entry with the greatest started_at among the session's open rows, since ADR-0039 re-entry can hold two spans open at once and an intra-macrostep event belongs to the most recently opened one. Returns :error when the session has no open span (an effect event racing a crash's cleanup is contract-legal, not a bug).

fetch_innermost_sibling_span(table, pid)

@spec fetch_innermost_sibling_span(atom(), pid()) ::
  {:ok, OpentelemetryStatifier.SiblingEntry.t()} | :error

Fetches the innermost sibling span open in pid - the entry with the greatest started_at, since a parent run creating a durable child inside its own step holds two step spans open on one process. Returns :error when the process has none, which is the ordinary case for a host that attached only the sibling setups it uses.

fetch_last_span_ctx(table, session_id)

@spec fetch_last_span_ctx(atom(), String.t()) ::
  {:ok, OpenTelemetry.span_ctx()} | :error

Fetches the last span context recorded for session_id, returning {:ok, span_ctx} on a hit or :error for an unknown session.

fetch_session_pid(table, session_id)

@spec fetch_session_pid(atom(), String.t()) :: {:ok, pid()} | :error

Fetches the process recorded for session_id by put_session_pid/3, or :error for a session the bridge has not seen. The sibling handlers use it to check that a session's open macrostep span belongs to this process before landing a span event on it - a delivery-seam event on an Oban worker must never write onto a span another process has open for the same scope.

new_table(name)

@spec new_table(atom()) :: atom()

Creates (or returns, if it already exists under this process) a :public/:named_table ETS table under name. Tests call this directly with a unique name per test so they never depend on the application-started table.

put_invoke_parent(table, session_id, span_ctx)

@spec put_invoke_parent(atom(), String.t(), OpenTelemetry.span_ctx()) :: :ok

Records the parent macrostep span context a child session's :initialize macrostep will link to, keyed by the child's session_id.

put_last_span_ctx(table, session_id, span_ctx)

@spec put_last_span_ctx(atom(), String.t(), OpenTelemetry.span_ctx()) :: :ok

Records span_ctx as the last span context observed for session_id.

put_open_span(table, span_ref, entry)

@spec put_open_span(atom(), reference(), OpentelemetryStatifier.SpanEntry.t()) :: :ok

Stores an open span's %SpanEntry{} under its span_ref.

put_session_pid(table, session_id, pid)

@spec put_session_pid(atom(), String.t(), pid()) :: :ok

Records pid as the session process behind session_id, for the sweep's liveness check. Idempotent - the table is a :set, so a session's row is written once per macrostep at no accumulating cost.

put_sibling_span(table, span_ref, pid, entry)

@spec put_sibling_span(
  atom(),
  reference(),
  pid(),
  OpentelemetryStatifier.SiblingEntry.t()
) :: :ok

Stores an open sibling span's %SiblingEntry{} under its span_ref, keyed for lookup by the process that opened it.

sweep(table)

@spec sweep(atom()) :: :ok

Ends the orphans a brutal kill leaves behind: for every :session_pid row whose process is no longer alive, delegates to delete_session/3, and for every :sibling_span row whose process is no longer alive, ends that span with an error status and deletes the row. A live process's rows are never touched - an open span on a live process is just a step or a macrostep in flight, whatever its age.

take_invoke_parent(table, session_id)

@spec take_invoke_parent(atom(), String.t()) ::
  {:ok, OpenTelemetry.span_ctx()} | :error

Looks up and removes the invoke-parent span context stored for session_id, returning {:ok, span_ctx} on a hit or :error when no parent was recorded - a session that was not invoked by anyone.

take_open_span(table, span_ref)

@spec take_open_span(atom(), reference()) ::
  {:ok, OpentelemetryStatifier.SpanEntry.t()} | :error

Looks up and removes the open span under span_ref, returning {:ok, entry} on a hit or :error on a miss - a :stop event with no matching :start is a contract-legal shape, not a bug, so this never raises.

take_sibling_span(table, span_ref)

@spec take_sibling_span(atom(), reference()) ::
  {:ok, OpentelemetryStatifier.SiblingEntry.t()} | :error

Looks up and removes the open sibling span under span_ref. Returns :error on a miss, which - as for take_open_span/2 - is a contract-legal shape rather than a bug.