Skip to content
Merged
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -358,8 +358,9 @@ exposition setup.
<summary><strong>Thread Safety Details</strong></summary>

**Per-Function Statistics:**
- Statistics tracked per decorated function (shared across all calls)
- Statistics tracked per function identity (`module.qualname`), shared across all calls and across re-decorations of the same function
- Thread-safe via RLock (all methods safe for concurrent access)
- Fork-safe: a forked child starts with zeroed counters and its own session ID

```python
from concurrent.futures import ThreadPoolExecutor
Expand Down
33 changes: 17 additions & 16 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -809,22 +809,23 @@ either from the in-memory L1 layer or from the backend L2 layer, never both. `ma
`currsize` are kept only for `lru_cache` API parity and are always `None` because the cache
lives in an external store, not a bounded in-process dict.

Statistics are tracked per decorated function (shared across all calls) and are thread-safe.
`cache_clear()` resets the counters and rotates `session_id`.

> **Decorate once, at module scope — never inside a function you call repeatedly.** The
> statistics tracker is bound to the wrapper when the decorator is applied, but the session
> identity (`session_id` is derived from a process-scoped UUID plus the function's
> `module.qualname`) is stable across re-decorations. Rebuilding the wrapper per call —
> e.g. `cache.io(namespace=...)(fn)` inside a request handler or loop — therefore sends the
> **same session ID with counters reset to zero** on every request. Against the CachekitIO
> backend, the server's anti-replay validation reads those non-advancing counters as a replay
> signature: the events lose their session tag (they vanish from session-scoped analytics
> such as the layer-breakdown chart) and the server logs a `counters decreased (replay
> attack?)` warning for entirely legitimate traffic. If the wrapped callable must vary per
> call (a per-call closure or key), build the decorated wrapper once, cache it (e.g. in a
> module-level holder or `functools.lru_cache` keyed by namespace), and route the per-call
> state through an argument or a thread-local — not through re-decoration.
Statistics are tracked per function identity (`module.qualname`) and are thread-safe.
Re-applying a decorator to the same function — even with different options — reuses the
existing counters from a process-global registry rather than resetting them, so `session_id`
stays stable and the counters reported to the CachekitIO backend stay monotonic (counters
that reset under an unchanged session ID would trip the server's anti-replay validation).
`cache_clear()` resets the counters and rotates `session_id`. A forked child process starts
with zeroed counters and a session ID derived from its own process UUID; the parent's
statistics are unaffected.

> **Decorate once, at module scope — as performance advice.** Rebuilding the wrapper per
> call (e.g. `cache.io(namespace=...)(fn)` inside a request handler or loop) repeats the
> decoration-time setup on every request and, in L1-only mode (`backend=None`), discards
> the in-process object cache each time — every call becomes a miss. Session telemetry is
> unaffected either way: the rebuilt wrapper reuses the same statistics tracker. If the
> wrapped callable must vary per call, build the decorated wrapper once, cache it (e.g. in
> a module-level holder or `functools.lru_cache` keyed by namespace), and route the
> per-call state through an argument or a thread-local.

A minimal stats endpoint that surfaces `cache_info()` over HTTP:

Expand Down
137 changes: 16 additions & 121 deletions src/cachekit/backends/cachekitio/session.py
Original file line number Diff line number Diff line change
@@ -1,139 +1,37 @@
"""Session management for cachekit.io backend with process and thread isolation.
"""Session headers for cachekit.io requests.

This module provides process-scoped session tracking for cachekit.io requests.
Session IDs are generated once per process (shared across threads) and
regenerated on process restart (PID change detection).

Thread safety is achieved via threading.Lock for PID checking and UUID regeneration.
Process session identity (UUID + start timestamp, PID-aware) lives in
:mod:`cachekit.decorators.session` — the single source of truth shared
with the decorator stats tracker. This module only assembles the SaaS
HTTP headers from it.
"""

from __future__ import annotations

import os
import threading
import time
import uuid

# Module-level initialization (regenerated on PID change)
_session_lock = threading.Lock()
_session_pid: int | None = None
_session_id: str | None = None
_session_start_ms: int | None = None
from cachekit.decorators.session import get_session_id, get_session_start_ms

# Thread-local storage for header dict caching
_thread_local = threading.local()


def _ensure_session_initialized() -> None:
"""Ensure session is initialized for current process.

Regenerates session ID and timestamp if PID changed (process restart).
Thread-safe via lock (only first thread per process does initialization).
"""
global _session_pid, _session_id, _session_start_ms

current_pid = os.getpid()

# Fast path: session already initialized for this process
if _session_pid == current_pid and _session_id is not None:
return

# Slow path: need to (re)initialize for new process
with _session_lock:
# Double-check inside lock (another thread might have initialized)
if _session_pid == current_pid and _session_id is not None:
return

# Generate new session ID for this process
_session_pid = current_pid
_session_id = str(uuid.uuid4())
_session_start_ms = int(time.time() * 1000)

# Clear thread-local cache (force header regeneration)
if hasattr(_thread_local, "headers"):
_thread_local.headers = None


def get_session_id() -> str:
"""Get the process-scoped session ID.

Returns a stable UUID v4 string that is generated once per process
and regenerated on process restart (PID change). All threads within
the process share the same session ID.

This ID should be included in all requests to cachekit.io to enable
correlation of cache operations across threads and time.

Returns:
str: UUID v4 format session ID (e.g., "550e8400-e29b-41d4-a716-446655440000")

Raises:
RuntimeError: If session initialization failed (should never happen)

Example:
>>> session_id = get_session_id()
>>> len(session_id)
36
>>> # All calls in same process return the same ID
>>> session_id == get_session_id()
True

Note:
On process restart (PID change), a new UUID is generated automatically.
This ensures session IDs are unique per process lifetime.
"""
_ensure_session_initialized()
if _session_id is None:
raise RuntimeError("Session ID not initialized (should never happen)")
return _session_id


def get_session_start_ms() -> int:
"""Get the millisecond timestamp when the process started.

Returns the process start time as milliseconds since Unix epoch.
This timestamp is regenerated on process restart (PID change).

This value enables server-side session scope detection and request
grouping by session lifetime.

Returns:
int: Milliseconds since Unix epoch (e.g., 1700000000000)

Raises:
RuntimeError: If session initialization failed (should never happen)

Example:
>>> start_ms = get_session_start_ms()
>>> start_ms > 0
True
>>> # All calls in same process return the same timestamp
>>> start_ms == get_session_start_ms()
True
"""
_ensure_session_initialized()
if _session_start_ms is None:
raise RuntimeError("Session start not initialized (should never happen)")
return _session_start_ms


def get_session_headers() -> dict[str, str]:
"""Get session headers for cachekit.io requests.

Returns a dictionary containing X-CacheKit-Session-ID and
X-CacheKit-Session-Start headers needed for cachekit.io API requests.

The returned dict is cached in thread-local storage to avoid repeated
dictionary allocations. A fresh copy is returned on each call to prevent
accidental mutation of cached data while maintaining efficiency.

Automatically detects process restarts (PID changes) and regenerates
session ID when needed.
dictionary allocations and revalidated against the current session ID,
so a process restart or fork (PID change) transparently regenerates it.
A fresh copy is returned on each call to prevent accidental mutation of
cached data while maintaining efficiency.

Returns:
dict[str, str]: Headers dict with keys:
- X-CacheKit-Session-ID: Process session UUID
- X-CacheKit-Session-Start: Process start milliseconds
- X-CacheKit-Session-Start: Session start milliseconds

Example:
>>> headers = get_session_headers()
Expand All @@ -144,16 +42,13 @@ def get_session_headers() -> dict[str, str]:
>>> headers["X-CacheKit-Session-Start"].isdigit()
True
"""
_ensure_session_initialized()

# Thread-local cache for header dict (eliminates repeated allocation)
if not hasattr(_thread_local, "headers") or _thread_local.headers is None:
session_id = get_session_id()
cached = getattr(_thread_local, "headers", None)
if cached is None or cached["X-CacheKit-Session-ID"] != session_id:
_thread_local.headers = {
"X-CacheKit-Session-ID": _session_id,
"X-CacheKit-Session-Start": str(_session_start_ms),
"X-CacheKit-Session-ID": session_id,
"X-CacheKit-Session-Start": str(get_session_start_ms()),
}

# Return a copy to prevent caller mutations from affecting cached data
return dict(_thread_local.headers)


Expand Down
111 changes: 101 additions & 10 deletions src/cachekit/decorators/session.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,88 @@
"""Session management for cache operations.
"""Process-scoped session identity for cache operations.

Provides unique session identifiers for tracking cache operations.
Single source of truth for the process session UUID (and its start
timestamp) used for SaaS session correlation — both the per-function
session IDs built by the decorator stats tracker and the fallback
``X-CacheKit-Session-ID`` header derive from the UUID minted here.

PID-aware: a forked child detects the PID change and mints a fresh
identity, so parent and child never report under one session ID. Shared
IDs with independently-reset counters read as a replay attack to the
server's session validator, which strips the session tag from otherwise
healthy telemetry.
"""

from __future__ import annotations

import os
import threading
import time
import uuid

# Module-level session ID (lazy initialized)
# Module-level state (regenerated on PID change)
_session_lock = threading.Lock()
_session_pid: int | None = None
_session_id: str | None = None
_session_start_ms: int | None = None


def _reset_session_state() -> None:
"""Discard the inherited identity in a newly forked child.

Runs from the post-fork handler while the child is still
single-threaded, so wholesale lock replacement is safe. The lock must
be replaced, not reused: a parent thread holding it at fork time
leaves it permanently locked in the child.
"""
global _session_lock, _session_pid, _session_id, _session_start_ms
_session_lock = threading.Lock()
_session_pid = None
_session_id = None
_session_start_ms = None


if hasattr(os, "register_at_fork"):
os.register_at_fork(after_in_child=_reset_session_state)


def _ensure_session_initialized() -> None:
"""Ensure session identity exists for the current process.

Regenerates the UUID and timestamp if the PID changed (fork or process
restart). Thread-safe via lock; only the first thread per process does
the initialization.
"""
global _session_pid, _session_id, _session_start_ms

current_pid = os.getpid()

# Fast path: session already initialized for this process
if _session_pid == current_pid and _session_id is not None:
return

with _session_lock:
# Double-check inside lock (another thread might have initialized)
if _session_pid == current_pid and _session_id is not None:
return

# _session_pid is assigned LAST: the fast path above reads without the
# lock and admits readers once pid+id are set, so all other fields must
# already be populated by then (a reader admitted between id and
# start_ms assignments would find start_ms still None).
_session_start_ms = int(time.time() * 1000)
_session_id = str(uuid.uuid4())
_session_pid = current_pid


def get_session_id() -> str:
"""Get or create a unique session ID for cache operations.
"""Get the process-scoped session UUID.

The session ID is a UUID4 that uniquely identifies this process/session.
It's lazily initialized on first call and remains constant for the
lifetime of the process.
Lazily initialized on first call and stable for the lifetime of the
process; all threads share the same value. A forked child mints its
own UUID (PID-change detection), so sessions are unique per process.

Returns:
Unique session identifier string.
Unique session identifier string (UUID v4 format).

Examples:
Session ID is a valid UUID format:
Expand All @@ -36,7 +99,35 @@ def get_session_id() -> str:
>>> id1 == id2
True
"""
global _session_id
_ensure_session_initialized()
if _session_id is None:
_session_id = str(uuid.uuid4())
raise RuntimeError("Session ID not initialized (should never happen)")
return _session_id


def get_session_start_ms() -> int:
"""Get the millisecond timestamp when this process session started.

Regenerated together with the session UUID on PID change, enabling
server-side session scope detection and request grouping.

Returns:
Milliseconds since Unix epoch (e.g., 1700000000000).

Examples:
>>> start_ms = get_session_start_ms()
>>> start_ms > 0
True
>>> start_ms == get_session_start_ms()
True
"""
_ensure_session_initialized()
if _session_start_ms is None:
raise RuntimeError("Session start not initialized (should never happen)")
return _session_start_ms


__all__ = [
"get_session_id",
"get_session_start_ms",
]
Loading
Loading