Harden the cache-key core, lock down the HTTP server, and fix concurrency edges (0.4.5)#23
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
__dict__are hashed by state, not memory address. Slottedvalue types, including
@dataclass(slots=True), fell through torepr(), whichembeds 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 trulyopaque object with the default repr warns instead of address-hashing.
that read a global
intinvalidated when it changed, but a referenceddict/list/setdid not. Value-stable containers are now hashed too, soconfig-style globals behave consistently.
ResultRefs nested in custom-object attributes are recorded in lineage. Thehasher 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.
ast.unparse, notast.dump. Thedump field set changes across Python versions (for example
type_paramsin3.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
Content-Lengthheader. The old check trusted a header that is absent forchunked 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.
token-protected servers the middleware now verifies the
Authorizationheaderand returns 401 before reading any body, so an unauthenticated client cannot
force buffering up to
max_content_length. The per-route check stays as defensein depth.
?limit=, a non-JSON/gcbody, or any internal error surfaced as an uncaught500 (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
/gcaccepts an empty body.Store and Redis robustness
last_accessed_atbump on a cache hit is best-effort. That write runsoutside the write lock, so a concurrent writer holding the lock past
busy_timeoutturned a successful cache read into anOperationalError. Alocked database is now logged at debug and the cached result is still returned.
VACUUMno longer fails the eviction. Vacuum isbest-effort space reclaim; a concurrent writer must not undo an eviction whose
deletes already committed. It is now extracted and guarded.
cashet:tag:{...}, so a tag keya:bcollided with the taga=b, and a:inany 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.
DECRunconditionally; on a missing reference counter that yields
-1, which is<= 0, so it deleted a blob another commit still referenced. It now returnsearly when the counter is absent.
find_running_by_fingerprintscanned afingerprint'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
dropped commits with missing blobs and returned only the imported count.
import_archivenow returnsImportResult(imported, skipped)and the CLIprints the skipped count.
showandgetexit non-zero on a miss, matchingrm, so scripts candetect a missing commit from the exit code.
submit_manycancels its in-flight siblings instead ofleaving them running and writing commits after the error is surfaced.
submit_manyno longer recomputes the dependency graph that the asyncclient already builds.
Behavior and migration notes
earlier versions recompute on first access. Blobs are content-addressed and
remain until garbage collected.
commits to rebuild their tag indexes.
import_archivereturnsImportResult(imported, skipped)instead of a bareinteger count; read
result.imported.showandgetnow exit 1 on a missing commit (was 0).from the middleware, including for unknown paths.
Known limitations (deferred)
Cross-process reclaim is already race-free:
find_running_by_fingerprintisre-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.
UnhashableArgWarningreports its location inside the hasher rather thanthe
submit()call site, because the hash runs many frames belowsubmit(thesame property as
ClosureWarning). The message names the offending type, whichis the actionable part.
Validation
ruff check,pyright(strict), and the fullpytestsuite including Redisare green: 384 passed.
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_manyDAGs, and batch cancellation.Docs
triple-documented HTTP server, consolidated the per-task option and
serialization docs, and fixed the
import_archiveexample.Ships as 0.4.5.
🤖 Generated with Claude Code