Files
bikoret/vav_pipeline/lib/vav_pipeline/azazel_ingest.ex
T
Scott KarpandClaude Fable 5.1 c0d0555aed the name: Bikoret — code, contracts, images
Headers X-Bikoret-Key / -Rebuke / -Verdict / -Bedikah; the `bikoret`
object and the "— bikoret …" fringe on a gate reply; environment
variables BIKORET_*; the image user and paths (/home/bikoret,
/tmp/bikoret, /var/lib/bikoret/manna, /etc/bikoret/secrets,
/opt/bikoret/spec); the tmp-bikoret pod volume; local databases
bikoret_dev/_test. Kubernetes resource names, the shorashim_app role and
the domain are unchanged (Tamid §1.1, "the names that still carry the
old word"). Doorpost c963c4fe…. Elixir 604/0, L3 10/0, korban 295,
Prolog bedikah 44/44. Gitea repo renamed heaviness510/bikoret (old URL
redirects).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-13 22:13:36 +00:00

265 lines
8.4 KiB
Elixir

defmodule VavPipeline.AzazelIngest do
@moduledoc """
Cross-language bridge between the Python Azazel Processor and the
Elixir L3 layer.
The Python orchestrator (azazel_processor/src/azazel_processor/
azazel.py) serializes completed AzazelRecords to JSON files in a
pending-l3 directory. This GenServer watches that directory,
reads each file, calls L3.Store.record_disposal/1, and on success
shreds the source file.
The pattern matches Bikoret's other cross-language file bridges
(the manna provision file). Recoverable: if either the Python side
or this GenServer crashes mid-write, the file remains and is
re-processed on the next poll.
POLL-BASED, NOT FileSystem.event-BASED:
We do not bring the :file_system dependency into vav_pipeline.
Polling is simpler and adequate for the disposal cadence (the
Azazel channel is for exceptional content, not a high-throughput
stream). Default poll interval 5 seconds; configurable.
RETRY SEMANTICS:
If L3.Store.record_disposal/1 fails (validation error, DB
unavailable), the file is LEFT IN PLACE — it will be retried on
the next poll. The reasoning: L3 is append-only; a write that
failed for a transient reason should be retried, and a write
that failed for a permanent reason (schema mismatch) should
remain visible to operators rather than be silently destroyed.
Operator intervention path: if a file persists across many polls
with the same error, the operator inspects it directly in the
pending directory and decides whether to fix the schema, fix the
record, or move it to an error-archive subdirectory for analysis.
PROPERTY COVERAGE:
Property 9 (operator-internal half) — the L3 record exists. The
public-side endpoint (commit 8) reads from the same table.
"""
use GenServer
require Logger
alias VavPipeline.{Chatzotzrot, L3.Store}
@default_poll_interval_ms 5_000
@default_pending_dir "/tmp/bikoret/azazel/pending_l3"
# ============================================================
# Client API
# ============================================================
@doc """
Start the AzazelIngest GenServer. Options:
- :pending_dir — directory to watch (default
/tmp/bikoret/azazel/pending_l3)
- :poll_interval_ms — milliseconds between polls (default 5000)
- :name — process name (default __MODULE__)
- :recorder — function used to write the L3 record;
defaults to &Store.record_disposal/1.
Tests inject mocks here so they don't
need PostgreSQL.
"""
def start_link(opts \\ []) do
name = Keyword.get(opts, :name, __MODULE__)
GenServer.start_link(__MODULE__, opts, name: name)
end
@doc """
Run a single poll synchronously. Useful for tests and for
operator-triggered manual flushes.
"""
def poll_now(server \\ __MODULE__) do
GenServer.call(server, :poll_now)
end
@doc "Return the GenServer's current configuration and state."
def state(server \\ __MODULE__) do
GenServer.call(server, :state)
end
# ============================================================
# GenServer
# ============================================================
@impl true
def init(opts) do
state = %{
pending_dir: Keyword.get(opts, :pending_dir, @default_pending_dir),
poll_interval_ms: Keyword.get(opts, :poll_interval_ms, @default_poll_interval_ms),
recorder: Keyword.get(opts, :recorder, &Store.record_disposal/1),
polls_completed: 0,
records_ingested: 0,
errors: 0
}
File.mkdir_p!(state.pending_dir)
schedule_poll(state.poll_interval_ms)
{:ok, state}
end
@impl true
def handle_call(:poll_now, _from, state) do
new_state = do_poll(state)
{:reply, :ok, new_state}
end
def handle_call(:state, _from, state), do: {:reply, state, state}
@impl true
def handle_info(:poll, state) do
new_state = do_poll(state)
schedule_poll(new_state.poll_interval_ms)
{:noreply, new_state}
end
# ============================================================
# Internal
# ============================================================
defp schedule_poll(interval_ms) do
Process.send_after(self(), :poll, interval_ms)
end
defp do_poll(state) do
files = list_pending(state.pending_dir)
{ingested, errors} =
Enum.reduce(files, {0, 0}, fn path, {i, e} ->
case ingest_file(path, state.recorder) do
:ok -> {i + 1, e}
{:error, _reason} -> {i, e + 1}
end
end)
%{
state
| polls_completed: state.polls_completed + 1,
records_ingested: state.records_ingested + ingested,
errors: state.errors + errors
}
end
defp list_pending(dir) do
case File.ls(dir) do
{:ok, names} ->
names
|> Enum.filter(&String.ends_with?(&1, ".json"))
|> Enum.sort()
|> Enum.map(&Path.join(dir, &1))
{:error, _reason} ->
[]
end
end
defp ingest_file(path, recorder) do
with {:ok, body} <- File.read(path),
{:ok, raw} <- Jason.decode(body),
{:ok, attrs} <- normalize_record(raw),
{:ok, _record} <- recorder.(attrs) do
# Property 6 (out-of-band acknowledgment) — fire the Chatzotzrot
# signal AFTER the L3 write succeeds. The chatzotzrot is the
# side channel; the disposal flow itself does not return
# acknowledgment to the caller (the caller already knows they
# handed the input to disposal).
#
# :moadim is the right signal type — Yom Kippur completion
# semantics. This disposal cycle is closed; the public fact
# (property 9) is now witnessable via /peah/azazel_log.
#
# Process.whereis-guarded so this works in tests that don't
# bring up the full supervision tree.
fire_chatzotzrot(attrs)
# Property 10 follow-through: shred the source file after
# successful L3 write. The disposal is now permanent oral
# tradition; the pending artifact must not linger.
File.rm(path)
:ok
else
{:error, reason} ->
Logger.warning(
"AzazelIngest: failed to ingest #{path} — #{inspect(reason)}. " <>
"File left in place for retry."
)
{:error, reason}
end
end
defp fire_chatzotzrot(attrs) do
if Process.whereis(Chatzotzrot) do
summary = chatzotzrot_summary(attrs)
Chatzotzrot.signal(:moadim,
reason: "Azazel disposal completed: #{summary}"
)
end
:ok
end
defp chatzotzrot_summary(attrs) do
case_ref = Map.get(attrs, :case_ref) || "unknown"
shaliach = Map.get(attrs, :shaliach) || "unknown"
vidui = Map.get(attrs, :vidui) || %{}
categories =
Enum.filter(["avonot", "peshaim", "chatot"], fn slot ->
case Map.get(vidui, slot) do
list when is_list(list) and list != [] -> true
_ -> false
end
end)
"case=#{case_ref} shaliach=#{shaliach} categories=#{Enum.join(categories, ",")}"
end
# Translate the Python-side JSON shape into Ecto-friendly attrs.
# The Python AzazelRecord.to_storage_dict() shape is:
# {
# "case_ref": str,
# "vidui": {avonot, peshaim, chatot, confessor, confessed_at},
# "routing": "eretz_gezerah",
# "authorized_by": str,
# "shaliach": str,
# "triggering_call": str | null,
# "goral_outcome_id": str | null,
# "disposed_at": iso8601 string
# }
# We pass `vidui` through as-is (JSONB column) and parse the
# disposed_at into a UTC DateTime.
defp normalize_record(raw) when is_map(raw) do
with {:ok, disposed_at} <- parse_iso8601(Map.get(raw, "disposed_at")) do
{:ok,
%{
case_ref: Map.get(raw, "case_ref"),
vidui: Map.get(raw, "vidui"),
routing: Map.get(raw, "routing"),
authorized_by: Map.get(raw, "authorized_by"),
shaliach: Map.get(raw, "shaliach"),
triggering_call: Map.get(raw, "triggering_call"),
goral_outcome_id: Map.get(raw, "goral_outcome_id"),
disposed_at: disposed_at
}}
end
end
defp normalize_record(_), do: {:error, :not_a_map}
defp parse_iso8601(nil), do: {:error, :missing_disposed_at}
defp parse_iso8601(s) when is_binary(s) do
case DateTime.from_iso8601(s) do
{:ok, dt, _offset} -> {:ok, DateTime.truncate(dt, :second)}
{:error, _} = err -> err
end
end
end