StatifierOban.Timer (StatifierOban v0.7.0)

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 run-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.

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.

Summary

Types

Why a SendDelayed effect could not be scheduled.

Functions

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

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 run-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 run'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.

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 run 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.