Skip to content

closed #362 - #365

Merged
thanos merged 5 commits into
mainfrom
v0.10.2/livebook-tutorials
Aug 15, 2026
Merged

closed #362#365
thanos merged 5 commits into
mainfrom
v0.10.2/livebook-tutorials

Conversation

@thanos

@thanos thanos commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Fix Cuckoo filter eviction-slot cycling that caused spurious :full errors

The kick-eviction loop chose which slot to evict with a plain rem(fingerprint + kick_count, bucket_size), a linear function of both inputs. With only 2^fingerprint_size possible fingerprint values shared across a much larger item count, two colliding fingerprints that differed by a multiple of bucket_size could synchronize the kick sequence into a short, exactly-repeating cycle among a handful of buckets, exhausting max_kicks well below the table's designed ~95.5% load factor. Confirmed via direct source-level tracing: 500,000 sequential "session_N" keys into Cuckoo.new(capacity: 500_000) hit a 4-step cycle between 3 buckets on both the Pure and Rust backends.

Fixed by routing the evicted fingerprint and kick count through the existing fingerprint-mixing hash before reducing mod bucket_size, keeping slot choice fully deterministic while eliminating the arithmetic periodicity. Ported identically to both backends; Pure and Rust now produce byte-identical serialized state for identical input. Random-key load factor confirmed at ~95% on both backends post-fix, matching the documented target.

Since the eviction-slot formula changed, any Cuckoo build that exercises kick-eviction now produces a different (non-cyclic) final state than before -- a behavior change, not a binary format change; old serialized sketches still decode and work.

closed #364

thanos added 5 commits August 12, 2026 15:58
Fix Cuckoo filter eviction-slot cycling that caused spurious :full errors

The kick-eviction loop chose which slot to evict with a plain
rem(fingerprint + kick_count, bucket_size), a linear function of both
inputs. With only 2^fingerprint_size possible fingerprint values shared
across a much larger item count, two colliding fingerprints that
differed by a multiple of bucket_size could synchronize the kick
sequence into a short, exactly-repeating cycle among a handful of
buckets, exhausting max_kicks well below the table's designed ~95.5%
load factor. Confirmed via direct source-level tracing: 500,000
sequential "session_N" keys into Cuckoo.new(capacity: 500_000) hit a
4-step cycle between 3 buckets on both the Pure and Rust backends.

Fixed by routing the evicted fingerprint and kick count through the
existing fingerprint-mixing hash before reducing mod bucket_size,
keeping slot choice fully deterministic while eliminating the
arithmetic periodicity. Ported identically to both backends; Pure and
Rust now produce byte-identical serialized state for identical input.
Random-key load factor confirmed at ~95% on both backends post-fix,
matching the documented target.

Since the eviction-slot formula changed, any Cuckoo build that
exercises kick-eviction now produces a different (non-cyclic) final
state than before -- a behavior change, not a binary format change;
old serialized sketches still decode and work.

closed #364
…d add CQF overflow detection

Quotient.member?/2 and CQF's member?/estimate_count/2/delete/2 always
decoded the entire slot table into a tuple before reading the one run
they needed, regardless of :backend (no per-item Rust NIF exists for
these). 300,000 sequential Quotient.member?/2 calls against a 524,288-
slot filter took ~40 minutes; the same calls now take ~0.13s. Fixed by
adding a lazy decode path that parses only the header and reads
individual slots on demand. Added Quotient.member_many?/2 for batch
checks.

Separately, CQF silently dropped inserts once its table filled up,
in both backends -- the shared shift-right insertion machinery was
bounded to terminate but had no way to report failure back to the
caller. Fixed by threading a success/failure result through the
insertion chain: the Pure backend's immutability makes a failed
attempt's partial mutations automatically discarded, while the Rust
NIF (which mutates in place) now checks whether each insert will fit
before applying any mutation, including preparatory metadata changes,
to avoid leaving the table structurally inconsistent on failure.
CQF.put/2 and put_many/2 now return {:ok, cqf} / {:error, :full,
partial} mirroring Cuckoo, with put!/2 added for chaining and
update/2 raising FilterFullError. Quotient's equivalent internal fix
prevents a genuine hang on a 100%-full Pure-backend table but keeps
its existing silent-no-op public behavior, since Quotient's put API
wasn't part of this change.

Also fixed the CQF tutorial's q sizing: q must budget for total
occurrence count (each duplicate costs a physical slot), not distinct
key count. The tutorial's q: 18 (262,144 slots) was sized for 50,000
distinct keys when it needed capacity for 1,000,000 occurrences,
making put_many take over an hour; q: 21 (2,097,152 slots, ~2x
headroom) finishes the same call in under a second.

Summary:

1. Livebook q sizing — bumped cqf.livemd's q: 18 → q: 21 (2,097,152 slots) for the 1,000,000-occurrence dataset; added a "Sizing: q must budget for total occurrences" section.
2. Single-item Rust bypass — Quotient.member?/2/delete/2 and CQF.member?/2/estimate_count/2/delete/2 now use a lazy binary-offset decode instead of materializing the whole slot table per call. New Quotient.member_many?/2 batch API. Confirmed: 300,000 Quotient.member? calls went from ~40 minutes to ~0.13s; CQF member? went from ~190ms/call to ~1.2μs/call.
3. CQF overflow detection — put/2/put_many/2 now return {:ok, cqf} / {:error, :full, partial} mirroring Cuckoo, with put!/2 added for chaining and update/2/update_many/2 raising FilterFullError. This required check-before-mutate restructuring in the Rust NIF (since it mutates in place, unlike Pure's immutable discard-on-failure) to avoid leaving the table structurally corrupted on a failed insert. Also fixed a real hang risk in Quotient's Pure backend (an unbounded shift loop that could spin forever on a 100%-full table). Verified Pure/Rust byte-identical parity at the overflow boundary and zero corruption of successfully-inserted items.
Introduce ExDataSketch.Config, a small helper that reads a single
`config :ex_data_sketch, defaults: [family: [...]]` key and merges it
under the caller's explicit opts (explicit opts always win). Wire it
into `new/1` (and `build/2` for XorFilter) across all 15 sketch
families, so options like backend selection or default `p`/`q`/`r`/
`capacity` can be set once in Application config instead of passed at
every call site.

Fix FilterChain performance and API:
- Add put_many/2, batching inserts through each stage's own
  put_many/2 (Rust-accelerated where available) instead of looping
  single-item put/2, which forced every stage onto the Pure backend
  and made large inserts O(state size) per item. update_many/2 now
  delegates to put_many/2 instead of reducing over update/2.
- Simplify delete/2 to return a bare t() instead of {:ok, t()} -- it
  never had a real error case to report (missing items are absorbed
  as no-ops per stage, unsupported stages raise upfront).
- Add :put_many to capabilities/0.

Fix livebooks/sketches/.verify_extract.exs, which extracted every
livebook's cells for automated verification but silently skipped the
Mix.install cell entirely, so any config: block a livebook set (e.g.
backend: Rust) was never applied during verification. It now parses
the Mix.install call's config: option via Code.string_to_quoted/1 and
applies it before running the cell blocks.

Add a "No NIF acceleration" note to REQ's moduledoc, matching the
existing MisraGries note -- ExDataSketch.Backend.Rust's req_* functions
are a thin pass-through to Pure, so :backend has no effect on REQ.

Add a Configuration section to guides/usage_guide.md indexing every
config :ex_data_sketch key (backend, defaults, dirty_thresholds,
storage, persistence_backends, telemetry_enabled, telemetry,
integrations), and fix the ULL Options row to point at ULL's own
Precision Range section, since ULL's p<=26 is a hard limit unlike
HLL's.

Update all sketch and integration livebooks' Mix.install cells to
nest config under ex_data_sketch: [...] (Mix.install's config: option
is keyed by OTP application, unlike the config :app, ... macro), and
set backend: Rust plus integrations: [opentelemetry: false] where
appropriate. Also:
- cqf.livemd: fix q-sizing example (q: 18 -> 21) and put/put_many call
  shapes, add "Sizing" and "What full looks like" sections.
- ddsketch.livemd: fix a misleading rounded-to-zero min_value display.
- filter_chain.livemd: rewrite the Inserting section around
  put_many/2, update the delete/2 example for its new bare return,
  and rework the XorFilter hybrid-chain section to explain why newly
  inserted items stay absent from the whole-chain query.
- hll.livemd, ull.livemd: add "Why 4..26?" sections explaining the
  shared p>=4 floor and why HLL's p<=26 ceiling is a practical choice
  but ULL's is a hard limit tied to a fixed-size estimator table.
- frequent_items.livemd: bump the basic-usage example from k: 20 to
  k: 100.
- cms.livemd: restore the floating "~> 0.10" Mix.install pin.

Add regression tests for FilterChain.put_many/2 (including the
{:error, :full, partial} case and a performance bound) and for
ExDataSketch.Config (direct merge_defaults/2 coverage plus
integration with several families' new/1).

Remove livebooks/sketching_one_billion_rows.livemd.
…laced

Caught by the new FilterChain.put_many/2 performance-regression test
flaking on CI. The bitset-to-tuple "O(1) update" trick doesn't survive
Enum.reduce/3's closure-based iteration -- each put_elem/3 call was a
full tuple copy, making a batch cost O(n * hash_count * bit_array_size)
instead of the intended O(n * hash_count). Fixed by collecting bit
positions into a map and applying them in one linear pass: a 1,000-item
batch at 500,000 capacity drops from 1.8-2.6s to ~80ms.
@thanos
thanos merged commit 1d42c6e into main Aug 15, 2026
33 checks passed
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.

add regression tests for #362

1 participant