Skip to content

Release 0.0.18: hub-node scale remediation - #44

Merged
eldonm merged 9 commits into
mainfrom
release/0.0.18-hub-node
Sep 13, 2026
Merged

eldonm merged 9 commits into
mainfrom
release/0.0.18-hub-node

Conversation

@eldonm

@eldonm eldonm commented Sep 13, 2026

Copy link
Copy Markdown
Member

Summary

  • Derive adjacency (edge_ids_mode="derive" on Postgres/MongoDB/SQLite): hub connect() / save() no longer rewrite or row-lock node rows; cost is flat across degree. Migration: jvspatial migrate strip-node-edges --dsn … --apply (dry-run by default). Opt out with JVSPATIAL_NODE_EDGE_IDS=persist.
  • SQL neighbour pushdown: list-form nodes(edge=[E], node=[…], limit=N) is one round trip; new count_nodes(), nodes_page(), nodes_bulk(limit_per_source=…).
  • Index hygiene: entity-leading functional indexes; optional whole-document GIN via JVSPATIAL_PG_GIN_INDEX=off; $textto_tsvector / plainto_tsquery; escape_regex().
  • Tenant + pooling: RLS covers graph joins/traverse; JVSPATIAL_POSTGRES_COMMAND_TIMEOUT and documented pooler sizing.
  • Bench gates recorded in docs/bench/2026-09-hub-node-baseline.md.

Adopter notes (any jvspatial app)

After upgrade, apps that want the new defaults can set:

  • JVSPATIAL_NODE_EDGE_IDS=derive (already the Postgres/MongoDB/SQLite default)
  • JVSPATIAL_PG_GIN_INDEX=off (recommended for large deployments whose queries are scalar/equality, not @> / $elemMatch)

Then run jvspatial migrate strip-node-edges --dsn "$JVSPATIAL_POSTGRES_DSN" --apply and VACUUM (ANALYZE) on node when convenient.

Test plan

  • pre-commit run --all-files
  • Remediation suite against live Postgres (test_edge_ids_derive, test_node_query_pushdown, test_postgres_indexes_text, test_postgres_tenancy_graph)
  • Hub-node bench 1k / 10k / 100k + typed-find gate (docs/bench/2026-09-hub-node-final.jsonl)
  • CI green on this PR
  • After merge: confirm PyPI 0.0.18 publish from version.py
  • After merge: rebase origin/dev onto updated main

Note: Local full pytest also surfaces pre-existing tests/storage/test_security.py MIME detection failures that reproduce on main (filetype/libmagic env drift) — not introduced here.

eldonm and others added 8 commits August 12, 2026 15:48
Allowlist and map the query-param HTTPS gate (plus WEBHOOK_HTTPS_REQUIRED) into ServerConfig so local HTTP tunnels can disable it via env.

Co-authored-by: Cursor <cursoragent@cursor.com>
Phase 0 of the hub-node scale remediation. Seeds a Postgres hub with
1k/10k/100k edges (COPY) and records p50/p95 latency, DB round trips
(db_op_counter) and on-disk sizes for connect(), save(), neighbour
listings, the len(nodes()) count pattern and 32-way concurrent
connect(). Opt-in via `-m bench` (`bench_slow` for 100k).

The baseline confirms the expected shape on 0.0.17: connect()/save()
grow with hub degree (save 11 ms -> 1.15 s from 1k to 100k), concurrent
connects serialise on the hub row lock (11.7 s wall for 32 at 100k),
and list-form nodes(limit=20) costs 1 + ceil(degree/500) + 1 round
trips (201 at 100k) while the class form stays at 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 1 of the hub-node scale remediation. Node rows no longer persist
their incident edge ids in an `edges` array on backends whose edge
collection is indexed on source/target (`edge_ids_mode="derive"`, new
`Database` capability flag; JsonDB and DynamoDB keep "persist").
connect()/disconnect()/save() never rewrite or row-lock a node, so their
cost no longer grows with degree and concurrent connects to one hub no
longer serialise. edges(), connection_count(), cascade delete,
expand_node/subgraph_bfs and Root rehydration read the edge collection;
legacy arrays are ignored on read and dropped on the next save.

- resolve_edge_ids_mode(): instance attribute > JVSPATIAL_NODE_EDGE_IDS
  env > adapter class default (wrappers unwrapped via `inner`)
- `jvspatial migrate strip-node-edges` (dry run by default, --apply),
  backed by strip_node_edges() on PostgresDB (one PK keyset pass) and
  MongoDB (batched $unset)
- expand_node pages from the edge collection by id; adds keyset
  `after` / `pagination.next_after` alongside the int cursor
- Fix: QueryEngine.apply_update ignored $pull, leaving stale edge ids on
  Postgres rows in persist mode after disconnect/delete
- Bench harness: warm pool and re-open connections before the burst;
  baseline re-measured, Phase 1 numbers in the bench doc

Bench (100k hub, p50): connect 160 -> 1.9 ms, save 1161 -> 0.7 ms,
32x concurrent connect 10.3 s -> 19 ms wall, hub row 2.5 MB -> 136 B.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 2 of the hub-node scale remediation. Node.nodes() normalises every
filter shape - classes (subclass-inclusive on the node side), names,
lists, {Name: criteria} dicts and property kwargs - to entity lists plus
record-path queries and sends them, limit included, to the backend's
find_connected_nodes in one round trip, direction="both" included.

- PostgresDB: shared SQL builder; join form (one edge type, one
  direction: duplicate-free under the (source, target, entity) unique
  index, LIMIT streams) or id IN (...) semi-join (dedups); filters that
  do not translate raise NotImplementedError so nothing is dropped;
  count_connected_nodes, find_connected_nodes_bulk (ROW_NUMBER per source)
- MongoDB ($lookup aggregation) and SQLite (json_extract join) implement
  the same contract; table-alias support in both SQL translators
- Node.count_nodes(), Node.nodes_page() (keyset; cursor helpers moved to
  core/pager.py and shared with GraphContext.find_page),
  nodes_bulk(limit_per_source=); count_neighbors delegates to count_nodes
- Python fallback keeps every filter and filters in the database find
- Fix: list-form edge filters were ignored by nodes()
- Fix: ObservableDatabase exposed find_connected_nodes/traverse even when
  the wrapped backend lacks them

Bench (100k hub): nodes(edge=[E], node=["Leaf"], limit=20) 4.9 s / 201
round trips -> 1.2 ms / 1; count 4.8 s (len(nodes())) -> 83 ms / 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ObservableDatabase (create_database(observe=True)) and CachingDatabase
subclass Database, whose default bulk_save_detailed is a serial save()
loop. Because neither wrapper defined the method, that default shadowed
their __getattr__ forwarding: a wrapped Postgres COPY or Mongo
bulk_write ran as one round trip per record (~0.6 ms/row on loopback).
Both wrappers now forward it; the cache refreshes saved ids and drops
failed ones.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 3 of the hub-node scale remediation.

- PostgresDB.create_index indexes entity/id/tenant_id as the real
  columns the translator compares, emits DESC NULLS LAST (the order
  find(sort=...) uses), and rebuilds indexes defined by the old rules in
  place (the edge (source, target, entity) unique index indexed
  data->entity, which no query used)
- ensure_indexes scopes per-class (annotation-declared) indexes to the
  class's entity: (entity, <fields>) by default, WHERE entity = ... with
  partial_by_entity; the unscoped pre-0.0.18 index is dropped once
  replaced. attribute()/compound_index() gain the scoping options
- gin_index="off" / JVSPATIAL_PG_GIN_INDEX=off skips the whole-document
  GIN on new collections; find() warns once for $all/$elemMatch then
- $text: to_tsvector('simple', ...) @@ plainto_tsquery on Postgres,
  GIN via @fulltext_index / attribute(fulltext=True); QueryEngine
  evaluates the same semantics in memory; MongoDB strips $fields
- escape_regex() helper; find_edges_between(limit=)
- Bench: typed-find gate on a 1M-row shared node table

Bench: sorted, limited typed find at 1M rows, GIN off: p95 9.2 -> 3.0 ms,
one index scan and no sort (0.0.17 needed a BitmapAnd + sort).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 5 of the hub-node scale remediation (verification of the tenancy
and pooling paths).

- traverse, find_one_and_update, find_one_and_delete and
  bulk_save_detailed took a raw pool connection without the
  app.tenant_id GUC, so under enable_rls they saw nothing (atomic ops
  returned None, traverse no hops) and COPY failed the policy's WITH
  CHECK before falling back to per-record saves. They now use the
  tenant-scoped connection (nested transactions become savepoints).
- New RLS tests: two tenants sharing one hub id across
  find_connected_nodes / count_connected_nodes / _bulk, traverse,
  atomic ops and bulk_save_detailed (unprivileged role).
- JVSPATIAL_POSTGRES_COMMAND_TIMEOUT for PostgresDB; postgres guide
  gains a pool-sizing rule of thumb and transaction-pooler notes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Derive adjacency from the edge table, push neighbour filters/limits/counts
into SQL, entity-leading Postgres indexes, optional GIN, and $text search.
Final pre-release bench at 51186f1 confirms gates still hold.

Co-authored-by: Cursor <cursoragent@cursor.com>
@github-actions

Copy link
Copy Markdown

Benchmark comparison

Threshold: ±25% (informational, does not block merge)

benchmark baseline (s) current (s) delta status
tests/benchmarks/test_deferred_save_benchmarks.py::test_bench_deferred_save_batched_100 0.039561 0.027879 -29.5% IMPROVED (-29.5%)
tests/benchmarks/test_deferred_save_benchmarks.py::test_bench_immediate_save_100 0.039551 0.026320 -33.5% IMPROVED (-33.5%)
tests/benchmarks/test_jsondb_benchmarks.py::test_bench_jsondb_batched_saves_500 0.416769 0.501432 +20.3% OK
tests/benchmarks/test_jsondb_benchmarks.py::test_bench_jsondb_count_empty_query 0.894717 1.058627 +18.3% OK
tests/benchmarks/test_jsondb_benchmarks.py::test_bench_jsondb_count_filtered 1.025928 1.181562 +15.2% OK
tests/benchmarks/test_jsondb_benchmarks.py::test_bench_jsondb_find_filtered 0.786144 0.918026 +16.8% OK
tests/benchmarks/test_jsondb_benchmarks.py::test_bench_jsondb_save_throughput 0.001524 0.001702 +11.6% OK
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_count_empty 0.253429 0.348323 +37.4% REGRESSION (+37.4%)
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_count_fallback_via_regex 0.296234 0.392794 +32.6% REGRESSION (+32.6%)
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_count_pushdown 0.251419 0.374829 +49.1% REGRESSION (+49.1%)
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_find_fallback_via_regex 0.277488 0.386950 +39.4% REGRESSION (+39.4%)
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_find_pushdown 0.241435 0.368957 +52.8% REGRESSION (+52.8%)
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_sort_limit_pushdown 0.282997 0.392653 +38.7% REGRESSION (+38.7%)

Rename bench/index fixture field track_id → group_id, neutralize
consumer names in CHANGELOG, and use hub/Leaf in optimization examples
so the remediation stays framework-generic.

Co-authored-by: Cursor <cursoragent@cursor.com>
@github-actions

Copy link
Copy Markdown

Benchmark comparison

Threshold: ±25% (informational, does not block merge)

benchmark baseline (s) current (s) delta status
tests/benchmarks/test_deferred_save_benchmarks.py::test_bench_deferred_save_batched_100 0.039561 0.022645 -42.8% IMPROVED (-42.8%)
tests/benchmarks/test_deferred_save_benchmarks.py::test_bench_immediate_save_100 0.039551 0.021384 -45.9% IMPROVED (-45.9%)
tests/benchmarks/test_jsondb_benchmarks.py::test_bench_jsondb_batched_saves_500 0.416769 0.644284 +54.6% REGRESSION (+54.6%)
tests/benchmarks/test_jsondb_benchmarks.py::test_bench_jsondb_count_empty_query 0.894717 1.627758 +81.9% REGRESSION (+81.9%)
tests/benchmarks/test_jsondb_benchmarks.py::test_bench_jsondb_count_filtered 1.025928 1.162388 +13.3% OK
tests/benchmarks/test_jsondb_benchmarks.py::test_bench_jsondb_find_filtered 0.786144 1.001627 +27.4% REGRESSION (+27.4%)
tests/benchmarks/test_jsondb_benchmarks.py::test_bench_jsondb_save_throughput 0.001524 0.002357 +54.7% REGRESSION (+54.7%)
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_count_empty 0.253429 0.256565 +1.2% OK
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_count_fallback_via_regex 0.296234 0.305430 +3.1% OK
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_count_pushdown 0.251419 0.283796 +12.9% OK
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_find_fallback_via_regex 0.277488 0.300850 +8.4% OK
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_find_pushdown 0.241435 0.256301 +6.2% OK
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_sort_limit_pushdown 0.282997 0.294116 +3.9% OK

@eldonm
eldonm merged commit 19c242d into main Sep 13, 2026
7 checks passed
@eldonm
eldonm deleted the release/0.0.18-hub-node branch September 13, 2026 21:31
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