StatifierOban.Timer (StatifierOban v0.17.3)

Copy Markdown View Source

Consumes {:send_delayed, %Statifier.Effect.SendDelayed{}} into a uniquely-keyed job on the host's Oban instance.

This is the scheduling and cancellation half of the durable-timer recipe in statifier-ex's docs/durable-timers.md: the host reads the effect off a live session's subscriber stream (or a direct interpreter drive) and hands it here with the session scope and its StatifierOban.Config. When the job fires, StatifierOban.Timer.Worker feeds the event back through the config's StatifierOban.Timer.Delivery module, behind the mandatory execution-liveness check (st-ADR-0054 decision 4) - the delivery module travels on the job's meta, so it is fixed at schedule time.

Two contract rules from statifier-ex are enforced at this door:

  • Only a nil target is schedulable (st-ADR-0055): any other target's route is resolved inside the session and does not travel on the effect, so a non-nil target is a typed error here - the caller should not have offered it. The effect stream is observational; leaving such a send to the library costs nothing.
  • delay_ms is relative, milliseconds from the moment the send was scheduled, so the fire time is computed at insert (DateTime.utc_now/0 plus the delay), never re-derived later.

A host re-arming delayed sends after a persisted resume has two of st-ADR-0074's decisions met here. Decision 1, the absolute fire time computed once when the send is first handed over, is the job's scheduled_at: schedule/3 computes it at insert, and a re-arm of the same send conflicts with the stored row on the dedup key and leaves that fire time where it was. Decision 3's claim at fire time is Oban's fetch moving the row out of the pending states into executing: cancel/3 reaches only the pending states, so a cancel that landed first leaves nothing to fire, and one that lands after the claim matches nothing and loses the race, the loss spec 6.3 allows. The liveness check that follows the claim is the delivery module's, as above.

The config's :opaque_codec is fixed at schedule time: schedule/3 reads it once, from the Config the caller hands it, and encodes the effect's host-opaque fields through it before the job is ever stored. The module name travels on the row alongside the encoded bytes (StatifierOban.OpaqueTerm's "codec" tag), so the worker that later decodes the fired job needs no configuration of its own - it reads whatever tag the row carries.

scope is the one argument that decides what any answer here is about. It is ctx.session_id for a live session and the host's own durable execution id for a process-less host (StatifierOban.Timer.Key), and pending_for/2 reads the same scopes back: handed durable execution ids it answers how many timers each execution is still waiting on, which is the question a host asks when something durable has to know whether an execution has outstanding work. Handed a live session's scope it answers about that session, which lasts as long as the session does.

Summary

Types

Why a SendDelayed effect could not be scheduled.

Functions

Consumes one %Cancel{} into cancellation of every matching timer job.

Counts the timer jobs still pending under each of scopes.

Schedules one %SendDelayed{} as one Oban job, unique per dedup key.

Types

schedule_error()

@type schedule_error() ::
  {:non_self_target, String.t()}
  | StatifierOban.Timer.Key.error()
  | StatifierOban.Timer.JobArgs.encode_error()
  | Ecto.Changeset.t()

Why a SendDelayed effect could not be scheduled.

Functions

cancel(config, scope, effect)

Consumes one %Cancel{} into cancellation of every matching timer job.

Consumes the effect vocabulary's {:cancel, %Cancel{}}, never the instruction vocabulary's {:cancel_timers, ...} (st-ADR-0054). The match is the cancellation key {scope, send_id} from StatifierOban.Timer.Key, read off each stored job's args, and it may legitimately hit several jobs: spec 6.3 cancels every delayed send under a sendid, and an author-written id executed twice stores two jobs under one send_id. Returns {:ok, count} with the number of jobs cancelled; a cancel matching nothing is {:ok, 0}, a no-op rather than an error - the same shape as Timers.take/2's {[], timers} in statifier-ex.

A cancel only ever reaches a timer that has not fired. The match is restricted to the states a pending timer can be in - scheduled, available, retryable, and on Oban 2.21 or later suspended - so a job that is executing right now is never swept, and neither is one that already reached a terminal state. Both stay out for the same reason: the timer has fired, and a cancel that arrives after the fire loses the race. That is spec-faithful - a real-time <cancel> can lose to a timer that already fired - and the execution-liveness check the delivery seam owes (st-ADR-0054 decision 4) is the guard on that side, not this one.

Leaving executing out is what makes the common self-cancel safe. A fired timer's delivery routinely drives the chart out of the state that armed it, which runs that state's onexit <cancel> for the very send_id being delivered - so the cancel and the delivery are the same job. Sweeping executing here would have Oban.cancel_all_jobs/2 signal a :pkill at the delivery's own process and kill the step mid-flight, leaving the row cancelled with {:cancel, :shutdown} and the execution's progress unpersisted (sob-uon, found downstream on the Lite engine). A cancel raised from inside a job's own delivery must not kill that delivery.

Cancelling a genuinely in-flight delivery from outside is therefore not offered. A host that needs it holds the job id and can call Oban.cancel_job/2 itself; this package will not do it blind, because from the query's side the self-cancel and the outside cancel are indistinguishable.

The match ignores the queue, exactly as the dedup key's uniqueness does: a host that moved its timers queue can still cancel jobs stored under the old name.

pending_for(config, scopes)

Counts the timer jobs still pending under each of scopes.

Answers a map carrying every requested scope, 0 for a scope with no pending timer, so a caller never has to tell "no timers" from "scope absent from the answer". Only this package's own timer jobs are counted, and only in the states a timer that has not fired can be in - scheduled, available, retryable and, on an Oban that has it, suspended: the internal set of cancellable states cancel/3 sweeps as well. A timer that already fired, was cancelled, or is running its delivery right now is not pending and does not count; executing is out here for the same reason it is out of a cancel.

The scopes are the scopes schedule/3 was given. A process-less host passes its own durable execution ids and reads back how many timers each execution is still waiting on. A live session passes ctx.session_id, which addresses a running session rather than anything durable, so the count it reads back lives and dies with that session.

No job row is loaded: the count is a grouped count run through Oban.Repo on the instance's own Oban.config/1, so the repo, the prefix and the log setting are the host instance's rather than anything this package configures. A scopes list longer than the chunk size (500 scopes) is counted in several such queries and the counts merged; duplicate scopes are asked once. Each query reads its own chunk at its own moment, so such an answer is a snapshot per chunk, not of one point in time: a timer scheduled, fired or cancelled between two of the queries is counted as its own chunk's query saw it.

Raises when the Oban instance is not running or its repo cannot answer, rather than answering with zeros, so "no pending timers" and "could not count" stay two answers.

schedule(config, scope, effect)

Schedules one %SendDelayed{} as one Oban job, unique per dedup key.

scope follows StatifierOban.Timer.Key: ctx.session_id for a live session, or the host's own durable execution id for a process-less host - always the caller's to supply, never derived.

Inserting the same scope and effect again returns {:ok, job} with job.conflict? set and leaves exactly one stored job: the at-least-once no-op the dedup key exists for. The job is inserted into the host's :timers_queue, scheduled at now plus delay_ms, carrying the config's delivery module in its meta - meta is not part of the unique fields, so a replay under a reconfigured delivery still conflicts with the stored job (same scheduling decision) rather than inserting a second one. A finite :timer_timeout rides in the same meta, and the worker's timeout/1 reads it back as the fired job's run-time bound.