memschedulestore

package
v0.14.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 4, 2026 License: Apache-2.0 Imports: 6 Imported by: 0

Documentation

Overview

Package memschedulestore is the in-memory reference port.ScheduleStore (scheduled-tasks issue #189, Phase 1b): the offline, clock-injected single-process schedule registry the conformance suite validates and that composition can wire as the default-store opt-in. It keeps per-schedule Spec+State records and per-fire ScheduleFire records in mutex-guarded maps and reads `now` from an injected port.Clock, so tests advance a fake clock to express "the schedule is now due" / "the slot was missed" without real sleeps.

It is the reference for the SAME port.ScheduleStore contract a future JSONL store, a gRPC-driver client, and a k8s-backed store implement; the contract is the port, the in-memory map is one adapter. The conformance suite (engine/adapter/scheduleconformance) is the dual-path contract-unification: the Go port is the contract, the wire/disk is one adapter. This is the SECOND adapter-validation pattern after leases (leaseconformance).

PARSER-FREE: the store NEVER interprets a cron expression. Claim's nextFire is caller-computed (composition, which has the cronparse dependency). The store does NOT import engine/adapter/cronparse — that is a deliberate layering choice: the store is the durable ground truth, the parser is a composition-time helper, and a store backend (e.g. a remote SQL table) need not ship a cron parser to satisfy the port.

MISFIRE-FREE: the store does NOT read ScheduleSpec.Misfire. Due returns any schedule whose NextFireAt <= now (plus Enabled + MaxFires); the misfire policy (fire-once-now vs skip) is a COMPOSITION concern applied at tick time.

Index

Constants

This section is empty.

Variables

View Source
var ErrNotFound = fmt.Errorf("memschedulestore: schedule not found: %w", port.ErrScheduleNotFound)

ErrNotFound is returned by Load/Delete/Claim/RecordFire/LoadFire when no schedule (or fire) exists under the requested name/id. It wraps port.ErrScheduleNotFound so a consumer that may not import this adapter (e.g. engine/agent) can distinguish not-found from an infra failure via errors.Is, the same discipline memstore applies for ErrSessionNotFound.

Functions

This section is empty.

Types

type Store

type Store struct {
	// contains filtered or unexported fields
}

Store is a concurrency-safe in-memory ScheduleStore. The Claim path is the at-most-once atomic advance: under the mutex it re-checks the slot is still due (a peer's Claim may have advanced it between this caller's Due and Claim) and, only if still due, advances NextFireAt + LastFireAt, bumps FireCount, and stamps port.PendingFireSessionID on LastFireSessionID — all before the fire runs (claim-before-fire).

func New

func New() *Store

New constructs an in-memory ScheduleStore. Every port.ScheduleStore method takes `now` as an explicit argument, so the store needs no injected clock of its own — time is caller-supplied. (An earlier draft injected a port.Clock reserved for a future self-pushing tick helper, but it was never read; it was dropped rather than carried as dead constructor surface. If a lookahead phase ever needs an internal `now`, re-add the clock then — YAGNI until it exists.)

func (*Store) Claim

func (s *Store) Claim(_ context.Context, name string, now, nextFire time.Time) (port.Schedule, error)

Claim is the AT-MOST-ONCE atomic advance. Under the mutex it re-checks the slot is still due (NextFireAt <= now && Enabled && under MaxFires); if NOT (a peer's Claim already advanced it, or it was disabled, or it exhausted) it returns ErrScheduleNotFound — the slot is gone, the fail-safe interpretation the conformance suite pins (a second Claim at the same now does NOT re-claim). If still due, it atomically: sets LastFireAt=now, advances NextFireAt to nextFire, increments FireCount, stamps LastFireSessionID=port.PendingFireSessionID, and (for a zero nextFire: a one-shot or an exhausted cron) sets Enabled=false. It returns the claimed Schedule (with the advanced State).

func (*Store) ClaimNow

func (s *Store) ClaimNow(_ context.Context, name string, now, nextFire time.Time) (port.Schedule, error)

ClaimNow is the manual-trigger variant of Claim (the FireNow primitive). It performs the SAME atomic advance as Claim but does NOT enforce the NextFireAt <= now due-check — it claims the slot regardless of whether it is due (a manual fire bypasses the cadence but still claims atomically for at-most-once). The Enabled + MaxFires checks STILL apply. The at-most-once fence is LastFireAt == now: a second ClaimNow at the same now (or a ClaimNow racing a tick-loop Claim at the same now) is rejected — the advance already happened. See port.ScheduleStore.ClaimNow for the crash-recoverability rationale.

func (*Store) Create

func (s *Store) Create(_ context.Context, in port.Schedule) error

Create atomically creates a NEW schedule under in.Spec.Name (review finding 5, issue #368): under the SAME mutex Save/Load/Claim already serialize on, it checks for an existing record and creates the new one in one lock acquisition — unlike the manager's prior two-call Load-then-Save, which raced across two separate acquisitions. A name already in use returns ErrScheduleAlreadyExists (wrapped) and leaves the existing record untouched.

func (*Store) Delete

func (s *Store) Delete(_ context.Context, name string) error

Delete removes the schedule stored under name. It is IDEMPOTENT: deleting an unknown name is success (the PrunableStore.Delete discipline).

func (*Store) Due

func (s *Store) Due(_ context.Context, now time.Time) ([]port.Schedule, error)

Due returns the schedules whose NextFireAt <= now AND Enabled AND (when MaxFires > 0) FireCount < MaxFires. It is idempotent and side-effect-free; it does not advance state. The store does NOT read ScheduleSpec.Misfire — the misfire policy is a composition concern.

func (*Store) List

func (s *Store) List(_ context.Context) ([]port.Schedule, error)

List returns ALL stored schedules, in no guaranteed order, as deep copies.

func (*Store) ListFires

func (s *Store) ListFires(_ context.Context, scheduleName string) ([]port.ScheduleFire, error)

ListFires returns the fire records for a schedule, in no guaranteed order. The not-found case for the SCHEDULE wraps ErrScheduleNotFound; an empty fire list for an existing schedule is a successful empty slice (not an error).

func (*Store) Load

func (s *Store) Load(_ context.Context, name string) (port.Schedule, error)

Load returns the schedule stored under name. The not-found case wraps ErrScheduleNotFound. The returned Schedule is a DEEP COPY (the State is a struct, the Parts slice is copied) so a caller cannot mutate the store's record through the returned reference — the memstore precedent.

func (*Store) LoadFire

func (s *Store) LoadFire(_ context.Context, fireID string) (port.ScheduleFire, error)

LoadFire returns the fire record stored under fireID. The not-found case wraps ErrScheduleNotFound.

func (*Store) ReArmOneShot

func (s *Store) ReArmOneShot(_ context.Context, name string, nextFire time.Time) error

ReArmOneShot is the at-least-once re-arm primitive for a one-shot schedule (ADR 0059 Phase 2). It atomically: re-enables the schedule (Enabled=true), sets NextFireAt to nextFire, and increments OneShotRetryCount. The atomicity (the single mutex) is the re-arm fence: two concurrent re-arms cannot double-increment the counter or double-enable. The not-found case wraps ErrScheduleNotFound. The retry-budget gate (OneShotRetryCount < OneShotMaxRetries) is the CALLER's responsibility — the store does NOT enforce the budget, it only atomically advances the counter. One-shot-only; the caller never calls this on a cron schedule.

func (*Store) RecordFire

func (s *Store) RecordFire(_ context.Context, f port.ScheduleFire) error

RecordFire records the outcome of a fire (f) and updates the schedule's LastFireSessionID to f.SessionID (overwriting the port.PendingFireSessionID value Claim set). It is IDEMPOTENT per fire id: recording the same f.ID twice is a no-op (the second call returns nil without mutating state). The not-found case (the schedule was deleted between Claim and RecordFire) wraps ErrScheduleNotFound.

It FLIPS the fire terminal and clears the in-flight ScheduleState fields (LastFireStartedAt/LastFireProgressAt/FireDeadline) — a recorded (terminal) fire has no in-flight run (issue #386). It overwrites any in-flight fire record the same f.ID had under RecordFireStart with the terminal one.

func (*Store) RecordFireProgress

func (s *Store) RecordFireProgress(_ context.Context, name string, fireID string, at time.Time) error

RecordFireProgress advances the in-flight fire's last-observed-progress instant (issue #386). It updates LastFireProgressAt on the state and ProgressAt on the in-flight fire record (when `at` is after the stored value — an earlier `at` is ignored so a reordered update cannot rewind progress). It is best-effort/idempotent: a missing in-flight fire record records on the state alone; a not-found schedule wraps ErrScheduleNotFound; a terminal fire is untouched. fireID targets the single in-flight fire record by its known id directly (review finding M1 — no scan); a terminal fire record is untouched (review finding M2 — never revert terminal → in-flight).

func (*Store) RecordFireStart

func (s *Store) RecordFireStart(_ context.Context, name string, fire port.ScheduleFire) error

RecordFireStart persists the IN-FLIGHT fire (issue #386): the fire's run has begun but not yet produced a terminal outcome. It writes the fire record (in-flight: Stop empty, StartedAt set) and stamps the schedule's LastFireSessionID to the REAL session id (overwriting the pending sentinel Claim set) + LastFireStartedAt (and seeds LastFireProgressAt to StartedAt when the caller passed a zero ProgressAt) + FireDeadline. It is IDEMPOTENT per fire id: a re-record of the same in-flight fire (same StartedAt) is a no-op for the in-flight record; a re-record for a fire id that is ALREADY terminal is a no-op (a terminal fire is not re-opened). The not-found case wraps ErrScheduleNotFound.

func (*Store) Save

func (s *Store) Save(_ context.Context, in port.Schedule) error

Save upserts the schedule by Spec.Name. A schedule with the same name is overwritten on the Spec half; the State half is PRESERVED on overwrite (a Save with a fresh zero State does not reset firing progress — call Delete + Save to reset, the port doc says so). A NEW schedule is initialised with Enabled=true (a new schedule is active by default; pause it by overwriting State.Enabled=false, which Save preserves on re-save).

func (*Store) SetEnabled

func (s *Store) SetEnabled(_ context.Context, name string, enabled bool) error

SetEnabled atomically sets the schedule's Enabled flag WITHOUT touching any other State field (unlike Save, which preserves the State half on a Spec overwrite and so cannot mutate Enabled). It is the pause/resume primitive. The not-found case wraps ErrScheduleNotFound.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL