Skip to content

yggdrasil.dataclasses.expiring

expiring

yggdrasil/dataclasses/expiring.py

Thread-safe expiring cache primitives.

Classes

Expiring[T] Abstract base: subclasses implement _refresh() to reload a single value on demand.

ExpiringDict[K, V] Thread-safe dict where every key has an individual TTL. No subclassing required — pass a refresher callable if per-key auto-refresh is wanted. Pass on_evict to receive notifications when an entry leaves the cache, so values that own external resources (file handles, spilled IO temp files, GPU buffers, …) can release them deterministically.

Change log vs previous version - Expiring: no new_instance callable field; subclasses implement _refresh(). - ExpiringDict: new; built on the same time-utils / lock discipline as Expiring. - ExpiringDict: new on_evict callback fires for every removal path (explicit delete, TTL expiry sweep, capacity eviction, refresher replace, pop, clear, etc). Callback runs OUTSIDE the lock so it can do real work without serializing other dict ops; exceptions are caught and discarded so a bad callback can't poison the cache.

RefreshResult dataclass

RefreshResult(
    value: Optional[T],
    created_at_ns: int | None = None,
    ttl_ns: int | None = None,
    expires_at_ns: int | None = None,
)

Bases: Generic[T]

Output of _refresh().

Provide either: - ttl_ns (duration) OR - expires_at_ns (absolute time)

If both are None → non-expiring.

make classmethod

make(
    value: Optional[T] = None,
    *,
    created_at_ns: Optional[_IntLike] = None,
    ttl_ns: Optional[Union[_IntLike, timedelta]] = None,
    expires_at_ns: Optional[_IntLike] = None
) -> "RefreshResult[T]"

Convenience constructor with defaults + light casting.

Expiring dataclass

Expiring(
    _value: Optional[T] = None,
    _created_at_ns: int = now_utc_ns(),
    _expires_at_ns: Optional[int] = None,
    _ttl_ns: Optional[int] = None,
)

Bases: Generic[T], ABC

Abstract thread-safe expiring cache holder.

Subclasses implement _refresh() which is called when .value is accessed and the cache is expired.

value property writable

value: Optional[T]

Cached value with auto-refresh on expiry.

refresh

refresh() -> None

Force refresh now (same as accessing .value and discarding it).

ExpiringDict

ExpiringDict(
    default_ttl: Optional[Union[float, int, timedelta]] = 300.0,
    *,
    max_size: int | None = None,
    refresher: Optional[Callable[[K], RefreshResult[V]]] = None,
    on_evict: Optional[Callable[[K, V], None]] = None
)

Bases: Generic[K, V]

Thread-safe dictionary where every key carries an individual TTL.

Built on the same nanosecond time-utils as Expiring but fully lockless — every mutation rides the CPython GIL atomicity of dict.__setitem__ / dict.pop / list(dict.items()) with permissive race semantics, so the cache can never deadlock. No subclassing required.

Parameters

default_ttl : Default TTL as seconds (float), nanoseconds (int), timedelta, or None (keys never expire unless given a per-key TTL). max_size : Evict the soonest-to-expire key when capacity is reached. refresher : Optional Callable[[key], RefreshResult[V]]. When supplied, an expired get will call refresher(key) and atomically replace the entry rather than returning the default/raising. on_evict : Optional Callable[[key, value], None] invoked after every removal — TTL-driven sweeps, capacity evictions, explicit deletes, pop, clear, refresher replacements, __setitem__ overwrites of an existing key. The callback runs outside the cache's lock so it can do real work (close a file handle, unlink a temp file, decrement a refcount) without serializing other cache ops. Exceptions raised by the callback are swallowed so a bad callback can't poison the cache; if the callback's failure matters to the caller, they should log it themselves.

Serialization

__getstate__ / __setstate__ are implemented: only live (non-expired) entries are persisted. The on_evict and refresher callbacks are NOT persisted — they're typically closures over runtime state. Compatible with pickle, copy.deepcopy, and joblib.

set

set(key: K, value: V, ttl: Any = ...) -> None

Insert or overwrite key.

ttl accepts seconds (float), nanoseconds (int), timedelta, or None (no expiry). Omitting ttl uses the instance default. Schedules a background purge every 15 minutes. Fires on_evict for any entry that was capacity-evicted to make room or whose key got overwritten.

Lock-free. dict.__setitem__ / dict.pop / dict.get are GIL-atomic; the capacity check and overwrite-notify bookkeeping run under a permissive race regime (under heavy contention the cache may briefly hold max_size + 1 entries or notify a value that another thread already replaced — both harmless).

get

get(key: K, default: Any = None) -> Optional[V]

Return value for key, or default if missing / expired.

set_many

set_many(mapping: Dict[K, V], ttl: Any = ...) -> None

Insert multiple key-value pairs sharing a TTL.

Lock-free; each insert lands via GIL-atomic dict.__setitem__. The batch is no longer atomic end-to-end — a concurrent reader may observe a partially applied batch — but each individual entry's visibility is atomic, which is all a cache needs.

Fires on_evict (after the writes) for any entries that got capacity-evicted or overwritten.

update

update(
    other: Union[Dict[K, V], "ExpiringDict[K, V]", None] = None,
    ttl: Any = ...,
    **kwargs: V
) -> None

Update the dict from a mapping and/or keyword arguments, mirroring the stdlib dict.update signature.

Parameters

other : A plain dict, another ExpiringDict, or any object with an .items() method. When the source is an ExpiringDict and no explicit ttl is given, each key's remaining TTL is preserved so expiry semantics survive a copy. Already-expired source keys are silently skipped. ttl : Override TTL for every key written. Accepts seconds (float), nanoseconds (int), timedelta, or None (no expiry). When omitted, the instance default is used for plain-dict sources, or the source key's remaining TTL is used for ExpiringDict sources. kwargs : Additional key-value pairs merged after *other, using *ttl / the instance default.

get_many

get_many(keys: Iterable[K]) -> Dict[K, V]

Return {key: value} for all live keys in keys.

Pure read — no eviction side-effect. Lock-free; relies on the GIL atomicity of dict.get. Expired entries are skipped but left in place for the background purge / next get to evict (the previous pop-on-expire path raced with concurrent refreshers).

delete_many

delete_many(keys: Iterable[K]) -> int

Delete keys; returns count of keys actually removed.

ttl

ttl(key: K) -> Optional[float]

Remaining TTL in seconds for key, or None if gone/expired.

Pure inspection — no eviction side-effect. (The previous evict-on-expired variant raced with concurrent refresh and could drop a freshly-set value; lazy eviction happens in get and the background sweep.)

ttl_ns

ttl_ns(key: K) -> Optional[int]

Remaining TTL in nanoseconds for key, or None.

snapshot

snapshot() -> Dict[K, Tuple[V, Optional[int]]]

Shallow copy of live entries as {key: (value, expires_at_ns)}. Useful for debugging or external persistence.

clear

clear() -> None

Drop every entry. Fires on_evict for each removed entry.

Lock-free. When on_evict is wired up we snapshot first, then call dict.clear() — entries inserted between the snapshot and the clear get cleared too but won't trigger the callback (permissive). When no callback is set, the fast path is a single GIL-atomic dict.clear() call.

purge_expired

purge_expired() -> int

Explicitly evict all expired keys; returns count removed.

Fires on_evict for every entry that expired.

refresh_key

refresh_key(key: K, ttl: Any = ...) -> bool

Reset the TTL of an existing, live key. Returns True if key existed and was refreshed, False otherwise. Fires on_evict ONLY when the call discovers a silently expired entry and drops it; never when the key is genuinely refreshed.

Lock-free pop+reinsert. Permissive race: a concurrent writer that lands between our pop and our reinsert will be overwritten by ours (we restore the previous value with a new TTL). Acceptable for a TTL-bump operation.

get_or_set

get_or_set(key: K, default: Union[V, Callable[[], V]], ttl: Any = ...) -> V

Return the live value for key; if missing/expired, store default (or the result of calling it) and return that.

Fires on_evict if a previously-stored expired entry got replaced — the old value is leaving the cache.

Note: the default callable is invoked OUTSIDE the internal lock. This rules out a class of deadlocks where default() takes another lock (or re-enters this cache from a different thread). The tradeoff is that under contention two threads may both invoke default() — the first writer wins, the loser silently discards its own computation.

apply_refresh_result

apply_refresh_result(key: K, rr: RefreshResult[V]) -> None

Store the outcome of an external RefreshResult for key. Mirrors the contract from Expiring._apply_refresh_result.

now_utc_ns

now_utc_ns() -> int

UTC epoch nanoseconds (monotonic-wall hybrid via time_ns).

datetime_to_epoch_ns

datetime_to_epoch_ns(dt: datetime) -> int

datetime → epoch ns (microsecond precision ⇒ trailing *000).

timedelta_to_ns

timedelta_to_ns(td: timedelta) -> int

timedelta → ns (microsecond precision ⇒ trailing *000).