AuroraMeter.Oban.CreditExpiry (Aurora Meter v1.0.0)

View Source

Expires promotional credit grants whose date has passed, in bounded batches.

The work is AuroraMeter.Credits.expire_due/2 and nothing else. The worker opens no transaction, takes no lock and reads no clock to decide anything: expire_due/2 compares against a persisted expires_at, so the instant it compares with comes from the database (AuroraMeter.Clock).

config :my_app, Oban,
  plugins: [{Oban.Plugins.Cron, crontab: [
    {"*/30 * * * *", AuroraMeter.Oban.CreditExpiry}
  ]}]

Recommended schedule "*/30 * * * *", which is what AuroraMeter.Oban.cron_entries/1 returns. Nothing depends on the frequency for correctness: a grant expires the first time a run sees it is due, and the delay between the date passing and the run is the schedule's period.

Job arguments

KeyDefaultMeaning
"batch_size"200Grants examined per batch.
"max_batches"10Batches one job runs before returning.

Both are bounded integers and neither is a cursor. The cursor is never a job argument (L05c-1): it lives in aurora_meter_checkpoints under "credit_expiry:global", so two jobs for this worker read the same position and the second one resumes where the first got to rather than restarting from a stale copy of its own.

Pausing

AuroraMeter.Operations.pause("credit_expiry:global")

The next batch boundary cancels the job with {:cancel, :paused}, leaving the cursor where the last committed batch left it. AuroraMeter.Operations.resume/1 and the next tick carry on from there.

The scan's instant is pinned

The candidate set is expires_at <= now, which moves. A scan that spans several jobs stores the now it started with in its checkpoint beside the cursor and hands the same one back until the scan finishes, so a resumed cursor points into the set it came from. When the scan completes the cursor is cleared and the next run takes a fresh instant.

Running it twice

Harmless, and that is the operation's doing rather than this worker's. Two ticks of one minute, a rescued job, or two nodes sweeping at the same instant all call expire_due/2 twice. Each expiry re-reads expired_at under the grant row's own FOR UPDATE and refuses when it is already set, so the second caller expires nothing and counts it as skipped.

The unique option below is defence in depth and not the guarantee. It saves the duplicate run's work; it is not what keeps the ledger right.

Without Oban, call AuroraMeter.Credits.expire_due/2 from whatever scheduler you do have, or drive the same loop with AuroraMeter.Operations.run_batches/3. See the scheduler map.

Summary

Functions

The checkpoint name this worker pauses and resumes under.

Expires due grants in bounded batches and returns {:ok, report}.

Functions

operation()

@spec operation() :: String.t()

The checkpoint name this worker pauses and resumes under.

perform(job)

@spec perform(Oban.Job.t()) :: {:ok, term()} | {:error, term()} | {:cancel, term()}

Expires due grants in bounded batches and returns {:ok, report}.

{:cancel, :paused} when the operation is paused; {:error, reason} when the listing or the checkpoint write fails, which fails the job and lets Oban retry it.

Examples

{:ok, report} = AuroraMeter.Oban.CreditExpiry.perform(%Oban.Job{args: %{}})