A from-scratch C++20 implementation of PS-Tree and PSTDynamic, the boolean-expression matching design from "Efficient Parallel Boolean Expression Matching" (Ji, Yao, Wang, Wei, Jacobsen — ACM Trans. Database Syst. 50, 4, Article 16, 2025).
This is the same research lineage as a-tree and be-tree - the paper's own benchmarks report PSTDynamic outperforming both on matching time, index construction time, and memory. No public source exists for PS-Tree/PSTDynamic/PSTParallel (or their 2018 precursors PSTBloom/PSTHash) anywhere - this is a full reimplementation from the paper's own pseudocode (Algorithms 1-7), not an adaptation of an existing codebase.
Phase 3 complete: integrated into mrayva/nats_sidecar as a third pluggable matching engine
(engine: pstree) - see that repo's own README.md for the operator-support/limitations summary.
PSTParallel (Phase 4) remains deliberately not started.
A real, architectural scaling limitation was found via nats_sidecar's own
benchmarks/matching_engine_bench.cpp benchmark, then FIXED (not just documented): the paper's
own design (InsertPredicate materializing coverage on every leaf a range predicate spans) made
PSTDynamic::insertSubscription() degrade toward O(leaf-count) per insertion - and O(group-size)
per matching event - whenever a subscription's only available predicate was a wide, unbounded
comparison (price > X) and many such subscriptions with independent thresholds shared a
dimension. Measured at K=10,000 synthetic subscriptions (30% of which were exactly this shape):
insert throughput fell to ~1.4% of a competing engine's, search throughput to ~15% - a real,
growing-with-K disadvantage, not a fixed constant-factor gap.
Fixed by replacing the leaf-chain materialization with canonical ancestor markers (see
include/pstree/ps_tree.hpp's own file-level comment for the full derivation) - the classical
"canonical decomposition" technique for stabbing queries, generalized from a binary segment tree to
the multiway radix trie the value encoding already produces. Equality now uses a plain hashmap
(O(1), unaffected by any of this - it never had the scaling problem); range operators
(>=,>,<=,<,BETWEEN) now attach to O(depth) ancestor nodes instead of O(leaves-covered)
leaves - a small, per-attribute-type constant (16 for int64/double, <=128 for string) independent
of how many other predicates exist on that dimension. This supersedes the paper's own Algorithms
1-3 for range operators specifically - not a transcription anymore, a from-scratch fix for a real,
measured limitation of that design (equality and the overall PSTDynamic layer - access-predicate
selection, dimension-signature grouping - are unaffected and unchanged).
Re-measured, and the search side needed two further fixes of its own (see
mrayva/nats_sidecar's own README, "K-scaling investigation" section, for the full derivation):
the insert-side fix above didn't touch MatchEvent's own O(group-size)-per-event cost, which still
blew up at K=8000 single-attribute-degenerate subscriptions (avg_match_us 76.6µs, ~500x the K=1
baseline). Two follow-up match-time fixes - skipping a subscription's own access-predicate
re-verification once matchPoint()'s tree walk already proves it exactly (85d40ea), then inlining
the hot per-candidate fields directly into LeafGroupState::groups to avoid a cache-miss pointer
chase found via perf annotate (55dbd2b) - brought avg_match_us down to a measured 9.79µs
(~7.8x cumulative) and true sustained throughput up to ~620-635 rows/s at K=8000. Real, verified,
shipped - but still ~1,600x short of even a relaxed 1M rows/s bar for this specific workload shape;
not further pursued past this point without new direction.
Phase 1 complete: the PS-Tree index itself (InsertPredicate, MatchPoint,
DeletePredicate - originally a transcription of the paper's own Algorithms 1-3, now superseded
for range operators by the canonical-ancestor-marker redesign above), including strict >/<
support, domain-boundary edge cases, and a randomized property-based stress test (checked against
a brute-force oracle across dozens of seeds and thousands of operations each, plus an exhaustive
small-domain BETWEEN check) validating the whole insert/delete/match pipeline, not just the worked
examples.
Phase 2 complete: PSTDynamic (Algorithms 4-6 - InsertSubscription, MatchEvent,
DeleteSubscription), including the access-predicate selectivity heuristic, dimension-signature
(Bloom filter) grouping with real hysteresis on grow/shrink, the full Section 2.1 predicate
language (<,<=,=,!=,>,>=, BETWEEN, element-of/not-element-of) with its own evaluator, and a
randomized property-based stress test. Verified against the paper's own Fig. 3 worked example
(all 6 subscriptions - SelectAccPred reproduces the paper's own stated access-predicate choice
for every one of them, not just a plausible-looking one) and Section 5.3's event-matching
walkthrough.
Both phases are clean under ASan+UBSan with leak detection, in CI and via ad-hoc extended multi-seed runs (60+ seeds, up to 3000 operations each) before being considered solid enough to build on. PSTParallel (Algorithm 7, the multicore extension) is deliberately not started - see the plan/TODO below.
Supersedes Algorithms 1-3 for range operators (>=,>,<=,<,BETWEEN) - see
include/pstree/ps_tree.hpp's own file-level comment for the full
derivation; equality and everything in PSTDynamic (access-predicate selection, dimension-signature
grouping, the Section 2.1 predicate evaluator) are unaffected. A few implementation-time findings
worth recording, beyond what an independent design review (which the redesign was validated
against before implementation) already caught:
- BETWEEN's
lo==hicase is a required base case, not an optimization: the general LCA decomposition has no defined "first level where lo/hi digits differ" when they're equal - this is reachable in practice (test_pst_dynamic_stress.cpp's random BETWEEN generator hits it, especially on boolean dimensions), not just a hypothetical edge case. - BETWEEN's
lcaLevel == depth-1case needed its own handling, found only while implementing (not caught by the design review): when lo and hi diverge only at the LAST digit level, there is no deeper level to descend into for either branch - naively descending into "child loD" and running the same GE-style decomposition starting past the end of the key silently does nothing, losing lo's own inclusion entirely. The whole range collapses to one inclusive bounded marker at the shared ancestor node instead. - Buckets are never freed inline by
deletePredicate, unlike an earlier draft of this design - but they ARE reclaimed, via a separatePSTree::reclaim(pred)call (2026-08-28). PSTDynamic's own per-bucket group state (LeafNode::userData) is read and mutated by the caller strictly afterinsertPredicate/deletePredicatereturns the bucket list - freeing a zero-counter bucket eagerly, inside the delete call itself, would leave that access reading freed memory.reclaim()re-walks the identical deterministic decomposition a second time, freeing any bucket that reached zero (checked at that point, not assumed) and pruning anyInnerNodeleft with no children and no markers as a result, all the way back up to (not including) the root -PSTDynamic::deleteSubscriptioncalls it once per low-level predicate, right after its own group-bookkeeping loop is done reading every leaf'suserDatafor that delete, which is the earliest point it's safe. Seeps_tree.hpp's own file-level comment and doc comment onreclaim()for the full reasoning, andtests/test_ps_tree.cpp's dedicated reclaim tests (zombie-bucket freeing, empty-InnerNode pruning back to an empty root, a shared bucket staying alive until its last reference is gone, BETWEEN's own lca/branch structure, idempotence) plustest_random_stress.cpp's adversarial randomized walk (wide/overlapping ranges, reclaiming after every delete, asserting the tree structure - not just coverage - returns to an empty root once everything is drained). boundedMarkers' linear scan is real, tracked residual debt, not swept under "not reachable today": if many BETWEEN predicates ever shared the same LCA node, that node's scan would reintroduce an O(K)-shaped cost for that one operator specifically. Confirmed unreachable fromnats_sidecar's current dialect translation layer (no BETWEEN support there at all), so out of scope for this fix, but worth tracking if that ever changes.- Verification: the existing worked-example and edge-case tests were re-derived (not just
mechanically ported) against the new bucket model,
test_pst_dynamic.cpp/test_pst_dynamic_stress.cppneeded zero changes (both exercise onlyPSTDynamic's public API), and a new exhaustive small-domain BETWEEN test plus a direct complexity-regression test (a wide>=insertion's own touched-bucket count staysO(depth)regardless of how many other predicates already exist) were added - there was previously no test pinning the complexity claim itself, only functional correctness of a single insert. Clean under ASan+UBSan with leak detection, including a 10-seed ad-hoc confidence run beyond what's checked in.
superseded for range operators by the redesign above; equality's own PartitionLeafNodeEqual-style handling and everything below about it is now historical)
This implementation transcribes the paper's pseudocode directly wherever it's given, but the
pseudocode has real gaps and at least one apparent transcription error, found by cross-checking
against the paper's own worked examples rather than assumed. See the top-of-file comments in
include/pstree/order_key.hpp for value-encoding detail unaffected
by the redesign; briefly, on the now-superseded original design:
- Value-type element encoding (Section 4.5) is generalized rather than copying the paper's own
irregular per-type bit splits: every fixed-width numeric type (
int64_t,double) is encoded via the standard order-preserving "sortable bit pattern" bijection (the same trick radix sort uses for signed integers and IEEE-754 floats), then uniformly chunked into 4-bit elements - one shared mechanism instead of a bespoke split per type. - The four operators the paper's own Algorithms 1 and 3 give pseudocode for (
>=,=,<=,in/BETWEEN) are implemented directly; strict>/<are supported too, via adjacent-key normalization (>Vbecomes>=next(V)) rather than separately-derived tree wiring, since every value type here has a well-defined discrete successor (order_key.hpp'snextElementKey/prevElementKey- ordinary mixed-radix increment/decrement, generic across every codec). MatchPair(Algorithm 2, line 8): the paper printsGetLNode(currNode, ...); the surrounding prose and Algorithm 1's own identical two-step pattern both point toGetLNode(iRNode, ...)being correct instead - implemented that way.DeletePredicate(Algorithm 3, line 19): the paper printsif lNode.predCounter = 0, an undefined variable - clearly meant to bestartNode, the variable decremented on the previous line.- A real, deeper gap in both
PartitionLeafNodeLeftandPartitionLeafNodeEqual: neither function's literal pseudocode updates any node other than the one it's directly operating on when splitting a leaf - butGetLNode's own search (invoked by an insertion at some OTHER, unrelated value) can permanently wire an already-existing node's.llink directly to a leaf that a LATER, completely unrelated insertion then splits, leaving that reference stale. Found in two stages: first by hand-tracing Section 4.4's own worked example (a.eself-alias on the SAME node going stale), then - after that fix still left a subtler case unhandled - by a randomized property test that found a second instance involving a WHOLLY UNRELATED node's.l, wired up by its own earlier insertion. Fixed generally, not case-by-case: everyLeafNodetracks whichInnerNodes currently point to it via.l(l_refs), so every split can correctly redirect every such reference to the new "rightward" piece - seeps_tree.hpp'ssetLeafL/redirectLeafLRefsand file-level comment #3 for the full reasoning, including why every referrer is guaranteed to need the redirect (not just some of them). - Space merging is deferred: the paper describes
MergeSpaces(combining adjacent leaves that end up with equal predicate counters after a deletion) only as prose with a worked example - no pseudocode is given anywhere.DeletePredicatehere does the well-specified part (decrementing counters) and leaves both merging and freeing zero-counter leaves as follow-up work - skipping them doesn't breakMatchPair's correctness, only forgoes a memory-compaction opportunity. (Historical note: this whole leaf-chain design,MergeSpacesincluded, is superseded by the canonical-ancestor-marker redesign above - there is no leaf chain or "adjacent leaves" concept left to merge. The live equivalent of this deferred item - freeing zero-predCounterbuckets and pruning now-emptyInnerNodes in the current bucket model - is fixed; seePSTree::reclaim()above, not this historical section.)
- Dimension signatures cover every dimension a subscription has a predicate on, including the
access predicate's own dimension - confirmed directly from Fig. 3's own worked example (S1/S2
have predicates on
{attr1,attr2}, signature"110"; not just the "other" dimension). The paper gives no pseudocode for the Bloom filter itself; this implementation uses standard Kirsch-Mitzenmacher double hashing and is verified against the worked example at the semantic level (which subscriptions land in the same vs. different groups), not by trying to reproduce its specific, hash-function-dependent bit strings. - Grow/shrink uses two deliberately-separated threshold functions, not the paper's one
thresholds[DimSigLen]array reused for both directions - reusing one array for both the grow check (Algorithm 4) and the shrink check (Algorithm 6, evaluated at whatever length growth just changed it to) risks immediate thrashing unless the thresholds happen to already have hysteresis built in, which the paper never specifies. This implementation grows atcapacity(len)and only shrinks once belowcapacity(len)/4, giving real hysteresis by construction. - A real architectural gap found by the randomized stress test, not a hand-traced example this
time: PSTDynamic needs to attach its own per-leaf metadata (which subscriptions are grouped
there) to PS-Tree leaves - but a leaf's identity isn't stable, the same way
predCounteralready isn't: a later, wholly unrelated subscription's insertion can split a leaf that an earlier subscription is already grouped under, and nothing propagated that grouping to the new piece. Fixed by extendingps_tree.hppitself with a generic, opaqueLeafNode::userDataslot plus clone/destroy hooks supplied toPSTree's constructor -copyLeafNode()clonesuserDataexactly when it clonespredCounter, so whatever PSTDynamic attaches automatically follows every split the same way the counter does. This keeps PS-Tree itself fully generic (it never interprets what's attached) while making the upper layer's metadata correctly survive a leaf split it has no way to observe directly. kElemOf's literal value list is deduplicated by encoded key before building tree predicates - a repeated literal (e.g.['ee','ee']) would otherwise insertkEq(V)twice, returning the same leaf twice in the affected-leaf union and double-counting the subscription in its own group. Also caught by the randomized stress test (its random generator can produce duplicate literals), not anticipated in advance.kNe/kNotElemOf, if selected as the (least-bad available) access predicate, have no representable contiguous PS-Tree range at all - handled by inserting>=the dimension's minimum representable value, i.e. "matches every leaf": correct (never a false negative) but forgoes pruning, exactly the outcome Section 2.3's own selectivity ranking anticipates for these operators.list_valuedattributes are not supported (nats_sidecar'sstring_list/integer_list) - the paper's model has no analog (an event attribute is always a single value, never a list) - real follow-up work for thenats_sidecarintegration phase, not assumed solved.kIsNull/kIsNotNullare not part of the paper's model at all - added for a real caller's need (nats_sidecar's own "attribute is/is not present" predicates), evaluated on ABSENCE rather than a value comparison.kIsNullcan never be a POSITIVE access predicate PLACED IN a dimension's tree - there is structurally no way to index "this dimension was absent", sinceMatchEventonly ever consults a dimension's tree for events that DO have it. A subscription whose every predicate iskIsNull(e.g. a bare"discount is null") is still fully supported, though:InsertSubscriptiondetects that shape and routes it to a small side-list instead, keyed by one of the subscription's own attributes and checked directly against whichever event attributes are absent - seePSTDynamic::nullOnlySubsByTriggerAttr_andwalkNullOnlySubscriptionsinpst_dynamic.hpp. Every predicate's attribute must be a schema dimension:insertSubscriptionrejects one that isn't (up front, before touching any state), becausematchEventdrops event attributes outside the schema when it builds its indexed lookup array - a non-access predicate on one would be evaluated against "absent" even when the event carries it (a false match foris null, a false negative for everything else). This was only checked for the access predicate before.kIsNotNullCAN be indexed normally (falls back to "matches every leaf", which is exactly correct here, not just safe - see the code comment). A real, subtle bug found by extending the tests for this: a subscription's dimension signature must EXCLUDE any dimension used only viakIsNull- the signature's whole pruning contract assumes "this subscription needs dimension D" means "D must be present", which is backwards forkIsNulland would incorrectly prune out the exact events it's meant to match.
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug
cmake --build build
ctest --test-dir build --output-on-failureSanitizer build (ASan + UBSan, mirroring be-tree's own CI setup):
cmake -S . -B build-asan -G Ninja -DCMAKE_BUILD_TYPE=Debug \
"-DCMAKE_CXX_FLAGS=-fsanitize=address,undefined -fno-omit-frame-pointer" \
"-DCMAKE_EXE_LINKER_FLAGS=-fsanitize=address,undefined"
cmake --build build-asan
ASAN_OPTIONS=detect_leaks=1:halt_on_error=1 UBSAN_OPTIONS=halt_on_error=1:print_stacktrace=1 \
ctest --test-dir build-asan --output-on-failure- PS-Tree core (
InsertPredicate/MatchPoint/DeletePredicate), strict>/<, domain-edge cases, randomized stress testing. - Canonical-ancestor-marker redesign for range operators, fixing the O(leaf-count) wide-range
insertion cost found via
nats_sidecar's benchmark - see the dedicated section above. - PSTDynamic (Algorithms 4-6): access-predicate selection, dimension-signature (Bloom filter)
grouping,
InsertSubscription/MatchEvent/DeleteSubscription, own predicate evaluator. -
nats_sidecarintegration as a third pluggable matching engine (engine: pstree) - seemrayva/nats_sidecar's own README.md/src/pstree_dialect.hppfor the DNF-conversion layer that bridges PSTDynamic's pure-conjunction model to nats_sidecar's full AND/OR/NOT grammar.list_valuedattribute support (below) stayed unaddressed by design - the integration instead rejects any expression referencing one at subscribe time, a real limitation not an omission (see nats_sidecar's own README for the full list, includingis nullalone as an unindexable access predicate). -
list_valuedattribute support (nats_sidecar'sstring_list/integer_list- no direct analog in the paper's single-attribute-value-pair model) - not pursued; nats_sidecar's integration rejects these at subscribe time instead (see above). - PSTParallel (Algorithm 7) - a much bigger, separate architectural lift, deliberately not scoped yet.
- Zero-counter bucket reclamation (deferred, see the redesign section above for why eager freeing on decrement is unsafe as-is) - not a correctness requirement, but real memory growth under long-running insert/delete churn.
-
boundedMarkers' linear scan (BETWEEN only) has no asymptotic guarantee - tracked residual debt, not currently reachable fromnats_sidecar's dialect layer (see the redesign section).