Skip to content

Reservations#

ReservationBook spreads a crowd across candidate capture times: it hands each user a distinct time, keeps that choice stable (a user who polls repeatedly is handed the same time), and self-expires it (a reservation is released once its time is past, or once any capture lands within grace of it), so an abandoned or fulfilled slot frees up for someone else.

A reservation is just a datetime. Choosing which times are worth offering — Void & Cluster selection over solar angle, weather, and so on — happens before the book and hands it a plain list of candidates.

Basic usage#

ReservationBook is ImpureState: it evolves a persistent book of who holds which time, so each reserve call runs the whole load-modify-commit under the state lock. The open key shards one independent book per coordinated crowd (e.g. per target).

from datetime import timedelta

import capturegraph.scheduling as cgsh

when = cgsh.ReservationBook.open("my-target").reserve(
    user_id,
    candidate_times,
    captured=last_capture,
    grace=timedelta(minutes=30),
)

reserve returns the datetime this user holds — a member of the candidate list, or None when it is empty. The same user is handed the same time on every call until it expires.

Key features#

Feature Description
Distinct times Each user gets a different candidate (falls back to the earliest once all are taken, without recording a hold).
Stable A user polling repeatedly keeps their time, re-snapped onto the freshest candidates.
Self-expiring A time more than grace in the past is released; captured= (the target's most recent capture, by any user) releases every hold within grace of it — that moment's conditions were just sampled, so its holders are steered to fresh times.

The book persists in the recipe scratch's object store as the ReservationBook itself (a cg.Map of each user's held instant). The whole load-modify-commit is one serialized, cross-process read-modify-write, so concurrent workers never hand two users the same time. The candidate list is re-supplied every call, so a held reservation survives the list being regenerated between requests.

Complete example#

This interceptor lives in a target's module on the server, which calls it whenever a client asks for its next notification time. See Targets and Interceptors for how interceptors are declared, and [notification_candidates][server.notifications.candidates.notification_candidates] for the cached void-and-cluster selection it pairs with; the walkthrough explains every line.

from datetime import UTC, datetime, timedelta

import capturegraph as cg
import capturegraph.scheduling as cgsh
from server import authoring as cgserver
from server.notifications import hourly_bucket, notification_candidates


@cgserver.intercept(PlantJournal, lambda s: s._metadata._next_notification)
def notification(profile, target) -> cg.Time:
    now = datetime.now(UTC)
    location = target._metadata._location
    if cg.is_missing(location):
        return cg.Time(now + timedelta(hours=1))

    candidates = notification_candidates(target.sessions, location, hourly_bucket(now))
    chosen = cgsh.ReservationBook.open("plant-journal").reserve(
        profile.user_id,
        [candidate.value for candidate in candidates],
        captured=max(target.sessions.to_dict(), default=None),
        grace=timedelta(minutes=30),
        now=now,
    )
    return cg.Time(chosen if chosen is not None else now + timedelta(hours=1))

Note

reserve returns None when the candidate list is empty, so guard before wrapping it in cg.Time.

Coverage reservations#

For spatial campaigns, CoverageBook is the spatial sibling of ReservationBook: where that hands out times released by the clock, this hands out places released by proximity. A void-and-cluster pass computes a batch of well-spread target locations once (it is expensive); the book hands them out one per user, stably, and every location is in exactly one of three states:

            reserve()              capture within threshold
in batch ─────────────▶ held ─────────────────────────────▶ retired
    ▲                     │        (the spot is covered)
    │     lease lapses    │
    └─────────────────────┘        (nobody captured it)

A handout is a lease (lease=, 30 minutes by default): the user is handed the same place on every request while it holds. A capture within threshold retires the location for good — the spot is covered, and the next user is sent somewhere still uncovered. A lease that lapses without a capture returns the location to the batch so someone else can be sent there.

import capturegraph as cg
import capturegraph.scheduling as cgsh
from server import authoring as cgserver

BOUNDS = cg.Array[cg.Location](
    [
        cg.Location(latitude=42.4440, longitude=-76.5020),
        cg.Location(latitude=42.4440, longitude=-76.5000),
        cg.Location(latitude=42.4425, longitude=-76.5000),
        cg.Location(latitude=42.4425, longitude=-76.5020),
    ]
)
DISTANCE = cgsh.distance.location(sigma_m=10.0)


@cg.pure_function("survey_batch/1")
def survey_batch(covered: cg.Array[cg.Location]) -> cg.Array[cg.Location]:
    return cgsh.select_sessions(
        potential_sessions=cgsh.forecast.locations_area(BOUNDS, resolution_meters=10.0),
        previous_sessions=covered,
        distance_fn=DISTANCE,
        energy_fn=lambda d: -d,
        energy_mode="max",
        selections=8,
    )


@cgserver.intercept(ParkSurvey, lambda s: s._metadata._location)
def next_spot(profile, target) -> cg.Location:
    covered = cg.Array[cg.Location](
        [
            session._metadata._location
            for session in target.sessions.values()
            if not cg.is_missing(session._metadata._location)
        ]
    )
    chosen = cgsh.CoverageBook.open("park-survey").reserve(
        profile.user_id,
        at=profile.location,
        fulfilled_by=list(covered),
        distance=DISTANCE,
        threshold=1.0,
        refill=list(survey_batch(covered)),
    )
    if chosen is None:
        raise LookupError("every survey point is covered")
    return chosen

distance is the same metric used to select the batch, a distance between two bare cg.Locations, and threshold is in the units it returns (1.0 is ten metres under sigma_m=10.0). refill= seeds the batch (kept in the order given) when the book is exhausted, folding the if exhausted: refill idiom into the call; reserve(user_id, at=..., fulfilled_by=...) hands out the batch location closest to the requester's location at, falling back to insertion order (FIFO) when no location is shared, after first retiring any reservation or batch location within threshold of a captured location in fulfilled_by, so a spot the crowd has already covered is never offered. release(user_id) drops a user's hold without recycling its location. The batch selection is a pure function keyed on the covered locations, so it is recomputed exactly when a new capture lands; the walkthrough shows the whole target.

See also#