Hourglass.Worker (hourglass v0.1.0)

Copy Markdown View Source

Per-task-queue Worker — a GenServer that owns the bridge-handle registration for one task queue and runs a small inner Supervisor hosting the two poll loops driving Temporal Core.

Why a GenServer + inner Supervisor (not just use Supervisor)

This module needs init/1 and terminate/2 callbacks so it can bracket the BridgeHolder registration with the lifetime of the Worker. use Supervisor doesn't expose terminate; wrapping a Supervisor inside a trap-exits GenServer gives us the hook.

Sequence on graceful start:

  1. init/1 calls BridgeHolder.register_worker(task_queue, opts). The Application-level holder allocates a fresh bridge worker handle and stores it keyed by task_queue.
  2. init/1 starts the inner Supervisor with the two poll loops. The poll loops call BridgeHolder.poll_* against the now-live handle.

Sequence on graceful stop:

  1. Caller invokes Worker.Supervisor.stop_worker(task_queue) — OR — Application shutdown reverse-walks children and stops this GenServer.
  2. terminate/2 runs (we trap exits) and calls BridgeHolder.unregister_worker(task_queue). The bridge handle is destroyed; any in-flight long-poll on it returns {:error, %Bridge.Error{kind: :shutdown}}.
  3. The poll loops see :shutdown and exit :normal. The inner Supervisor reaps them and exits :normal.
  4. terminate/2 returns. The GenServer exits.

Children of the inner Supervisor (:one_for_one)

  1. Hourglass.Worker.WorkflowPollLoop — drives BridgeHolder.poll_workflow_activation/1; dispatches each activation to the shared Hourglass.WorkflowEvaluator.DynamicSupervisor.

  2. Hourglass.Worker.ActivityPollLoop — drives BridgeHolder.poll_activity_task/1; dispatches each task to the shared Hourglass.ActivityExecutor.DynamicSupervisor.

Loops are independent — neither holds state the other depends on — so :one_for_one is the natural fit. Both are :transient: a graceful :normal exit (the bridge returned :shutdown) does not trigger a restart; only abnormal exits do.

Why bridge ownership lives in this Worker (not Worker.Supervisor)

Putting register_worker in init/1 (rather than in Worker.Supervisor.start_worker/1) makes the OTP shape symmetric: the same process that allocated the handle also frees it on exit. Direct callers of Worker.start_link/1 (typical in tests via start_supervised/1) get correct lifecycle without having to remember the bracket.

Cascade-restart safety

In-flight evaluator and executor Tasks live under shared Application-level DynSups (Hourglass.WorkflowEvaluator.DynamicSupervisor, Hourglass.ActivityExecutor.DynamicSupervisor), not under this Worker's tree. A Worker child crash never destroys those Tasks. They survive, finish their work, and ship completions via BridgeHolder (which they reach by name, not by holding a reference). If the Worker exits between dispatch and completion, the in-flight Tasks see {:error, :worker_not_registered} and Core redelivers via heartbeat / start-to-close timeout.

Naming

start_link/1 registers the GenServer under Hourglass.WorkerRegistry.via(task_queue) for via-name reachability. The poll loops do not need per-task-queue names — they receive task_queue as an arg and call BridgeHolder with it.

Summary

Functions

Returns the inner Supervisor pid for task_queue, primarily for test introspection (e.g. Supervisor.which_children/1).

Types

opts()

@type opts() :: [
  task_queue: String.t(),
  namespace: String.t(),
  max_cached_workflows: pos_integer(),
  target_url: String.t()
]

Functions

inner_supervisor(task_queue)

@spec inner_supervisor(String.t()) :: pid()

Returns the inner Supervisor pid for task_queue, primarily for test introspection (e.g. Supervisor.which_children/1).

start_link(opts)

@spec start_link(opts()) :: GenServer.on_start()