Skip to content

Harden the cache-key core, lock down the HTTP server, and fix concurrency edges (0.4.5)#23

Merged
jolovicdev merged 18 commits into
masterfrom
release-0.4.5
Jun 21, 2026
Merged

Harden the cache-key core, lock down the HTTP server, and fix concurrency edges (0.4.5)#23
jolovicdev merged 18 commits into
masterfrom
release-0.4.5

Conversation

@jolovicdev

Copy link
Copy Markdown
Owner

What

0.4.5 is a correctness and hardening release. The cache-key hashing is the whole
value proposition, and it had silent-correctness edges where a wrong answer
looked like a right one. The HTTP server had a real size-limit bypass and leaky
error responses. A few SQLite and Redis paths had concurrency and data-integrity
gaps. This fixes all of it, adds a regression test per fix (each verified to fail
without the fix) plus end-to-end smoke scripts, and tidies the README. 18
self-contained commits, each green on its own.

Cache-key correctness

  • Objects without __dict__ are hashed by state, not memory address. Slotted
    value types, including @dataclass(slots=True), fell through to repr(), which
    embeds the instance address. Identical calls produced a new key every run (a
    permanent miss), and a lossy custom repr could collide (a wrong cached result).
    They are now hashed by their merged __slots__ and __dict__ state; a truly
    opaque object with the default repr warns instead of address-hashing.
  • Referenced module-global containers invalidate like scalars. A function
    that read a global int invalidated when it changed, but a referenced
    dict/list/set did not. Value-stable containers are now hashed too, so
    config-style globals behave consistently.
  • ResultRefs nested in custom-object attributes are recorded in lineage. The
    hasher descended object state but the input-ref collector did not, so a ref
    held as an attribute was in the cache key yet missing from the commit graph and
    could be garbage-collected out from under a commit. The collector now matches
    the hasher.
  • Function source is canonicalized with ast.unparse, not ast.dump. The
    dump field set changes across Python versions (for example type_params in
    3.12), so the same function hashed differently on different interpreters and
    missed in mixed-version deployments. Unparse keeps the comment, whitespace, and
    docstring insensitivity but is source text and stable across versions.

Server hardening

  • The request size limit is enforced on bytes received, not the
    Content-Length header.
    The old check trusted a header that is absent for
    chunked requests, so a client could stream an unbounded body and the handler
    buffered all of it. A pure-ASGI middleware now counts bytes as they arrive,
    rejects with 413 once the cap is exceeded, and replays the buffered body so
    handlers read it normally.
  • Unauthenticated requests are rejected before the body is buffered. On
    token-protected servers the middleware now verifies the Authorization header
    and returns 401 before reading any body, so an unauthenticated client cannot
    force buffering up to max_content_length. The per-route check stays as defense
    in depth.
  • Every handler validates input and has a generic error barrier. A bad
    ?limit=, a non-JSON /gc body, or any internal error surfaced as an uncaught
    500 (and the async commit/log/stats/gc handlers had no barrier at all, leaking
    through Starlette's default 500). All routes now map bad input to 400 and
    unexpected errors to a generic 500, and /gc accepts an empty body.

Store and Redis robustness

  • The last_accessed_at bump on a cache hit is best-effort. That write runs
    outside the write lock, so a concurrent writer holding the lock past
    busy_timeout turned a successful cache read into an OperationalError. A
    locked database is now logged at debug and the cached result is still returned.
  • A blocked post-eviction VACUUM no longer fails the eviction. Vacuum is
    best-effort space reclaim; a concurrent writer must not undo an eviction whose
    deletes already committed. It is now extracted and guarded.
  • Redis tag index keys cannot collide. Presence and value indexes both built
    cashet:tag:{...}, so a tag key a:b collided with the tag a=b, and a : in
    any key or value could cross-match unrelated commits during delete_by_tags.
    The two now use distinct prefixes and the value key length-prefixes the tag key.
  • The Redis blob delete is idempotent. The Lua script called DECR
    unconditionally; on a missing reference counter that yields -1, which is
    <= 0, so it deleted a blob another commit still referenced. It now returns
    early when the counter is absent.
  • Running-claim lookup is O(1). find_running_by_fingerprint scanned a
    fingerprint's full commit history on every submit. A per-fingerprint running
    set, toggled on each status change, makes the lookup O(in-flight claims).

Smaller correctness and cleanup

  • A lossy import is reported, not silently swallowed. A truncated archive
    dropped commits with missing blobs and returned only the imported count.
    import_archive now returns ImportResult(imported, skipped) and the CLI
    prints the skipped count.
  • CLI show and get exit non-zero on a miss, matching rm, so scripts can
    detect a missing commit from the exit code.
  • A failing parallel submit_many cancels its in-flight siblings instead of
    leaving them running and writing commits after the error is surfaced.
  • Sync submit_many no longer recomputes the dependency graph that the async
    client already builds.

Behavior and migration notes

  • The hashing fixes change function and argument cache keys, so results cached by
    earlier versions recompute on first access. Blobs are content-addressed and
    remain until garbage collected.
  • The Redis tag index key scheme changed and is not migrated; rewrite affected
    commits to rebuild their tag indexes.
  • import_archive returns ImportResult(imported, skipped) instead of a bare
    integer count; read result.imported.
  • CLI show and get now exit 1 on a missing commit (was 0).
  • On token-protected servers, unauthenticated requests now receive a uniform 401
    from the middleware, including for unknown paths.

Known limitations (deferred)

  • Lease fencing for stale-claim reclamation is not in this release.
    Cross-process reclaim is already race-free: find_running_by_fingerprint is
    re-read inside the fingerprint lock each iteration, so two workers cannot both
    reclaim a stale claim. The only residual gap is a heartbeat stalling during an
    over-long task, whose worst case is duplicate compute (deterministic results
    overwrite identically), not corruption. A real fix needs a fencing token plus a
    schema migration on both backends, so it is its own change.
  • UnhashableArgWarning reports its location inside the hasher rather than
    the submit() call site, because the hash runs many frames below submit (the
    same property as ClosureWarning). The message names the offending type, which
    is the actionable part.

Validation

  • ruff check, pyright (strict), and the full pytest suite including Redis
    are green: 384 passed.
  • Each fix has a focused regression test, verified to fail without the fix and
    pass with it. 14 end-to-end smoke scripts additionally exercise slot hashing,
    global-container invalidation, locked-DB reads, blocked VACUUM, tag collisions,
    Redis delete idempotency, the size-limit bypass and auth-before-buffer, handler
    validation, lossy import, CLI exit codes, ast canonicalization, the
    running-claim index, submit_many DAGs, and batch cancellation.

Docs

  • CHANGELOG entry for 0.4.5 with a 0.4.4 to 0.4.5 compatibility note.
  • README rewritten for clarity and cut roughly in half: removed the
    triple-documented HTTP server, consolidated the per-task option and
    serialization docs, and fixed the import_archive example.

Ships as 0.4.5.

🤖 Generated with Claude Code

jolovicdev and others added 18 commits June 21, 2026 17:08
Objects without __dict__ (e.g. @DataClass(slots=True), __slots__ value
types) fell through to repr(obj), embedding the memory address. Identical
calls produced a new cache key every instantiation/process (permanent
misses), and lossy custom reprs could collide (wrong cached result).

_stable_repr_to now reconstructs object state from both __dict__ and the
MRO's __slots__ and hashes by value. Genuinely opaque objects (no state,
default object.__repr__) now emit UnhashableArgWarning instead of silently
address-hashing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_collect_input_refs descended dict/list/tuple/set/frozenset but not
custom-object state, while the hasher (_stable_repr_to) does. A ResultRef
held as an object attribute was hashed into the cache key but omitted from
commit lineage, so GC could evict an upstream blob the commit depends on.

_collect_input_refs now descends object state via the shared object_state
helper, matching the hasher. _object_state is promoted to public
object_state and memoized by type (lru_cache) since it now runs per-arg.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A function referencing a module-level scalar invalidated its cache when the
scalar changed, but a referenced dict/list/set did not. That was a silent
stale-result bug and an undocumented inconsistency.

_should_hash_global_value now admits dict/list/set whose members are all
value-stable (recursively), so config-style globals invalidate like scalars.
Containers with unstable members (e.g. a custom object) remain unhashed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
find_by_fingerprint issued an UPDATE of last_accessed_at on every cache hit.
That write runs outside the write lock, so a concurrent writer holding the
lock past busy_timeout would raise OperationalError and turn a successful
cache lookup into a hard failure.

The bump only feeds LRU eviction ordering, so it is now best-effort: a locked
database is logged at debug and the cached commit is still returned.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
After deleting commits, evict ran VACUUM (or incremental_vacuum) unguarded.
A concurrent writer holding the lock makes VACUUM raise OperationalError,
which propagated out of evict even though the deletes had already committed.

Vacuum is extracted to _vacuum and wrapped in a best-effort guard: space
reclaim can be skipped, but a completed eviction must not surface as an error.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_tag_key and _tag_value_key both built "cashet:tag:{...}", so a presence
index for tag key "a:b" collided with the value index for tag a="b", and a
':' inside any key or value could cross-match unrelated commits. delete_by_tags
then removed commits it should not have.

Presence and value indexes now use distinct prefixes (tagk:/tagv:) and the
value key length-prefixes the tag key, making the construction injective.

Note: this changes the Redis tag index key scheme. Tag indexes written by
older versions are not migrated; rewrite affected commits to rebuild them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_DECR_DELETE_SCRIPT called DECR unconditionally. On a missing ref key DECR
returns -1, which is <= 0, so the script deleted the blob even though that
ref key was not tracking it. An inconsistent or already-cleaned counter could
therefore drop a blob another commit still references.

The script now returns early when the ref key does not exist, so the decrement
never goes negative and a blob is only removed when its own counter reaches
zero.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The size guard only checked the Content-Length header, which is absent for
chunked requests. A client could stream an unbounded body with no
Content-Length and the handler would buffer all of it via request.json(),
defeating the only DoS control on the server.

The limit is now a pure-ASGI middleware that counts bytes as they arrive,
rejects with 413 once the cap is exceeded, and replays the buffered body to
the app so handlers still read it normally. The Content-Length fast path is
kept for early rejection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Several handlers parsed input without guards: ?limit=abc raised ValueError and
/gc with a bad type or non-JSON body raised, all surfacing as uncaught 500s
(the async commit/log/stats/gc handlers had no exception barrier at all, so
internals could leak through Starlette's default 500).

Adds a shared _safe_handler barrier applied to every route that maps bad input
to 400 and any unexpected error to a generic 500, a _query_int helper for query
params, and explicit gc parameter validation. /gc now also accepts an empty
body and falls back to defaults.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
import_store silently dropped any commit whose blobs were absent from the
archive and returned only the imported count, so a truncated archive looked
like a clean import. It now returns an ImportResult with imported and skipped
counts; import_archive on both clients returns it and the CLI prints the
skipped count.

This changes the import_archive return type from int to ImportResult (a
NamedTuple), so callers read result.imported instead of the bare count.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
show and get printed a not-found message but exited 0, while rm exited 1 for
the same condition, so scripts could not detect a miss via the exit code.
show and get now exit 1 on not-found, matching rm.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…stability

_ast_canonical hashed ast.dump output, whose field set changes across Python
versions (for example type_params added in 3.12), so the same function hashed
differently on different interpreters and missed the cache in distributed
deployments mixing versions.

It now hashes ast.unparse output, which keeps the comment, whitespace, and
docstring insensitivity but is source text and far more stable across versions.
Docstring stripping inserts a pass when it would leave an empty body so
unparse stays valid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
find_running_by_fingerprint scanned every commit ever recorded for a
fingerprint and called get_commit on each, so submits on a hot fingerprint
(for example accumulated cache=False salted runs) cost O(history) per call
inside the claim lock.

A per-fingerprint running set is now maintained: a hash is added on a RUNNING
put and removed on any other status, delete, or eviction. The lookup reads
that set, which holds at most the in-flight claims. Each index update only
touches the commit's own hash, so the set stays correct.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sync submit_many normalized tasks, built the dependency graph, and ran a
topological sort only to discard the results, then called the async client
which computes all of it again. That was wasted work and a second, divergent
source of validation.

It now derives only the result keys it needs and delegates normalization,
dependency resolution, and ordering to the async client.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In parallel submit_many, a failing task re-raised straight out of the drain
loop while its sibling tasks kept running, so they were orphaned and could go
on writing commits after the error was already surfaced.

The drain loop now cancels the remaining tasks and awaits them before
propagating, so a batch failure stops its siblings cleanly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bump version to 0.4.5 and document the correctness, robustness, server
hardening, and performance fixes in the changelog.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cut the README roughly in half by removing duplicated sections (the HTTP
server was documented three times), tightening prose, and consolidating the
per-task option and serialization docs into single references.

Fix the import_archive example for the new ImportResult return value and add a
0.4.4 to 0.4.5 compatibility note.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The size-limit middleware runs outside the per-route token check, so its
body-buffering loop could buffer up to max_content_length for an
unauthenticated client before the route returned 401, widening the
memory-DoS surface on token-protected servers.

When a token is configured, the middleware now verifies the Authorization
header and returns 401 before reading any body. The per-route check stays as
defense in depth. The shared _token_authorized helper is reused by both.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jolovicdev
jolovicdev merged commit 41a8ba9 into master Jun 21, 2026
3 checks passed
@jolovicdev
jolovicdev deleted the release-0.4.5 branch June 21, 2026 16:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant