Skip to content

Release 0.0.16 - #36

Merged
eldonm merged 1 commit into
mainfrom
release/0.0.16
Aug 5, 2026
Merged

Release 0.0.16#36
eldonm merged 1 commit into
mainfrom
release/0.0.16

Conversation

@eldonm

@eldonm eldonm commented Aug 4, 2026

Copy link
Copy Markdown
Member

Summary

Release PR for 0.0.16, per RELEASING.md: bumps jvspatial/version.py (what the publish workflow reads) and rolls the [Unreleased] block into a dated heading. A fresh empty [Unreleased] is left at the top.

Diff is 2 files, +3/−1. No source changes — everything below already landed on main and passed CI there.

Version choice: patch, not minor

RELEASING.md §3 routes ### Added entries to a minor bump, and this release has them, so flagging the reasoning as that section asks:

Every addition is additive and backwards compatible — new optional ServerConfig.database fields, a db_type branch that previously raised, an exported helper (resolve_sort_value), and a new exception hierarchy whose base still derives from RuntimeError so existing handlers keep working. Nothing existing changes shape, and there are no **BREAKING** entries. Since you treat each pre-1.0 minor as a breaking-change boundary, spending one here would signal a break that isn't there.

Say the word and I'll re-cut as 0.1.0.

Release notes

[0.0.16] - 2026-08-04

Added

  • Postgres is selectable from Server (jvspatial/api/components/database_configurator.py).
    initialize_graph_context() now builds a PostgresDB prime database for
    db_type="postgres" (alias "postgresql") instead of raising
    ValueError: Unsupported database type: postgres. The backend and
    create_database("postgres", ...) already worked; only the Server path
    was missing, so every API-layer deployment — and anything built on it — was
    locked out of a documented backend.

  • ServerConfig.database carries Postgres settings (jvspatial/api/config_groups.py,
    jvspatial/env_adapter.py) — postgres_dsn, postgres_min_pool_size,
    postgres_max_pool_size, postgres_pooler_mode, populated from the
    already-allowlisted JVSPATIAL_POSTGRES_* env keys. Connection settings now
    flow through the config object like every other backend's rather than being
    readable only by the driver. Unset values still defer to PostgresDB's own
    defaults. Coverage: tests/test_env_adapter_postgres.py,
    tests/api/components/test_database_configurator.py.

  • resolve_sort_value(record, field) (jvspatial/db/database.py) — the
    dotted-path resolution finalize_find_results uses, exported so adapters and
    cursor logic resolve a sort field the same way. Added to the module's
    __all__.

  • Deferred-task exception hierarchy (jvspatial/exceptions.py) —
    DeferredTaskErrorTaskDispatchErrorTaskSchedulerNotConfiguredError,
    replacing the bare RuntimeErrors raised by strict dispatch. A strict caller
    can now tell "retrying may succeed" (TaskDispatchError) from "this
    deployment will never dispatch" (TaskSchedulerNotConfiguredError).
    DeferredTaskError also derives from RuntimeError, so handlers written
    against the previous behavior keep working.

Fixed

  • PostgresDB pool is event-loop aware (jvspatial/db/postgres.py). The
    asyncpg pool and its lock bind to the loop that created them, but
    _ensure_pool() memoized both for the lifetime of the instance. A host that
    bootstrapped in one asyncio.run() and then served from a second loop hit
    cannot perform operation: another operation is in progress /
    ConnectionDoesNotExistError on its first query. The pool and lock are now
    rebuilt when the running loop changes. Coverage:
    tests/db/test_postgres_unit.py::TestPoolLoopAffinity.

  • Partial-index repair log is INFO, not WARNING (jvspatial/db/sqlite.py).
    Dropping a non-partial index so it can be recreated with WHERE is expected
    one-shot migration noise; log at info. Also satisfy ruff SIM110 in
    _index_needs_partial_repair and mypy narrowing for $eq string literals in
    _sqlite_translate.py.

  • SQLite connect-time repair of global session_id unique indexes
    (jvspatial/db/sqlite.py). Opening a SQLite DB now drops UNIQUE indexes on
    json_extract(data, '$.context.session_id') that lack a WHERE clause,
    so a process that never re-ran ensure_indexes(Conversation) after the
    partial-filter fix still stops wiping Interaction rows. Partial unique
    create_index failures now raise instead of logging a warning.
    Coverage: tests/db/test_sqlite_partial_index.py.

  • SQLite partial unique indexes (jvspatial/db/sqlite.py, _sqlite_translate.py).
    SQLiteDB.create_index ignored Mongo-style partialFilterExpression /
    partial_filter_expression kwargs and created global unique indexes on
    shared node collections. That made INSERT OR REPLACE wipe Interaction
    rows when they shared context.session_id with a Conversation (orchestrator
    history empty on SQLite, fine on JsonDB). SQLite now translates the same
    small dialect as Postgres into a WHERE clause, raises if a unique partial
    filter cannot be translated, and drops/recreates a pre-existing non-partial
    index of the same name so a restart self-heals. Coverage in
    tests/db/test_sqlite_partial_index.py and translator unit tests.

  • SQLiteDB.find treated sort=[] as an untranslatable sort
    (jvspatial/db/sqlite.py). An empty list failed the sort is None guard, so
    it took the fallback branch: the whole collection was loaded and limit
    applied in memory instead of being pushed into SQL. Results were correct, the
    work was not. A falsy sort is now normalized to None.

  • ObjectPager re-sorted each page with a key that disagreed with the
    database slice
    (jvspatial/core/pager.py; paginate_by_field inherited
    it). The in-Python safety-net sort used
    item.get("context", {}).get(order_by, 0), so a record missing order_by
    was ordered as 0 — among the real values — while the DB-side
    sort + limit that produced the slice had placed it in the trailing
    missing-value run. Records could therefore appear on two pages or on none.
    A blanket contextlib.suppress(KeyError, TypeError) also left a page
    silently unsorted on mixed-type keys. The re-sort now routes through
    finalize_find_results, making it a genuine no-op whenever the backend
    honored the sort.

  • GraphContext.find_page broke on dotted sort fields and could not reach
    records missing the sort value
    (jvspatial/core/context.py). Two defects:
    the cursor payload was minted with a flat last.get(primary_field), so a
    sort=[("context.started_at", -1)] page always encoded sort: None and the
    next page's keyset filter compared against None — raising
    TypeError: '>' not supported between instances of 'int' and 'NoneType' from
    QueryEngine on JsonDB. And with records missing the sort field now sorting
    last, the keyset filter {field: {"$lt": value}} could never match them, so
    iteration silently stopped at the last record that had a value. The cursor now
    uses resolve_sort_value, the filter carries a {field: None} branch to reach
    the trailing run, and a cursor minted inside that run walks it by id.

  • Postgres applied LIMIT in SQL even when the sort could not be pushed
    down
    (jvspatial/db/postgres.py, both PostgresDB.find and
    PostgresTransaction.find). translate_sort returns None for a field path
    it cannot safely interpolate (e.g. context.my-field), leaving the ordering
    to finalize_find_results — but the LIMIT was still pushed, so the database
    returned an arbitrary N rows and the in-memory sort ordered that arbitrary
    subset. find(sort=..., limit=10) returned "the top 10 of an arbitrary 10"
    instead of the true top 10. The LIMIT is now withheld whenever the sort
    falls back to memory, matching SQLiteDB.find and DynamoDB.find. Vector
    ($near) queries additionally no longer have their distance ordering
    overwritten by an in-memory re-sort on the user's sort.

  • In-memory find sort ignored dotted field paths (jvspatial/db/database.py).
    _find_sort_key resolved sort fields with a flat record.get(field), so a
    spec like sort=[("context.started_at", -1)] produced None for every row and
    left the result in arbitrary order. The SQLite and Postgres pushdowns
    (translate_sort) and Mongo's native sort already resolved dotted paths, so
    the same query ordered correctly on those backends and silently did not on
    JsonDB/DynamoDB — and on SQLite/Postgres whenever the query fell back to the
    in-memory path. Dotted paths now resolve in memory too; a non-dict segment
    along the path yields None rather than raising.

  • Descending in-memory sorts placed records missing the sort field first
    (jvspatial/db/database.py). finalize_find_results sorts with
    reverse=True, which flipped _find_sort_key's None flag along with the
    values. Both SQL translators emit NULLS LAST for descending and Mongo sorts
    missing values last, so a "newest N" sort + limit fetch returned real rows
    on SQLite/Postgres/Mongo and a window of records missing the field on the
    in-memory path. Missing values now sort last in both directions everywhere.
    The comment in _sqlite_translate.translate_sort asserting the in-memory path
    already matched has been corrected.

  • Strict deferred scheduling only caught the no-op-scheduler case
    (jvspatial/serverless/). dispatch_deferred_task(..., strict=True) raised
    when serverless mode resolved a logging no-op, but every provider failure
    still logged and returned a synthetic reference — so a caller with its own
    failure handling (retry, error signalled upstream, dedup claim released) was
    told the task was queued when it had been dropped. strict now raises on:
    an unset AWS_LAMBDA_FUNCTION_NAME; a Lambda invoke that raises or
    answers a non-2xx StatusCode / carries a FunctionError (an async invoke
    returns 202 on acceptance, so boto3 not raising was never proof of
    dispatch); an unconfigured SQS client or queue; a failed SQS send_message;
    a NoopOrSyncScheduler with no executor — the scheduler every
    non-serverless caller gets, which silently dropped strict tasks; and an
    EventBridge scheduling failure for a task deferred beyond Lambda's 900s
    timeout, where the fallback immediate invoke cannot honor run_at.

  • Non-strict dispatch failed differently per transport
    (jvspatial/serverless/tasks/aws_sqs.py). SQS send_message errors
    propagated while the Lambda transport swallowed them, so identical
    application code had opposite failure semantics depending on
    JVSPATIAL_AWS_DEFERRED_TRANSPORT. strict is now the single switch on
    every transport: False is fire-and-forget, True raises.

  • The one-time no-op diagnostic never fired for strict callers
    (jvspatial/serverless/factory.py). The strict raise preceded
    _note_noop_in_serverless, so a deployment whose callers are all strict
    never got the startup error explaining why nothing dispatches. The
    diagnostic is now emitted first.

Changed

  • TaskScheduler.schedule takes a strict argument
    (jvspatial/serverless/tasks/base.py). TaskScheduler is a public/stable
    extension point and config.task_scheduler is duck-typed, so
    dispatch_deferred_task introspects schedule() and omits strict for
    third-party implementations that predate it — those keep serving non-strict
    dispatches unchanged. A strict=True dispatch through such a scheduler
    raises TaskSchedulerNotConfiguredError (it cannot honor the guarantee)
    rather than TypeError.

Documentation

  • find sort contract moved to SPEC §4.1 (beside the Database method
    table it governs, rather than under §4.2 capability flags) and extended: the
    limit-must-not-outlive-the-sort-pushdown rule, plus a Known divergences
    table covering MongoDB's ascending sorts (native cursor.sort() places
    missing values first — documented, not normalized), array-index path segments,
    and heterogeneous value types.
  • Corrected stale NULL-ordering docstrings in
    jvspatial/db/_sqlite_translate.py (module docstring and translate_sort)
    and jvspatial/db/_postgres_translate.py (translate_sort). All three still
    claimed "NULLs sort last for ascending, first for descending, mirroring
    finalize_find_results" — the opposite of what the code emits and of the
    contract.
  • Database.find now documents the ordering contract adapter authors must
    satisfy; Database.find_iter no longer claims a composite
    (sort_value, id) cursor — the default implementation tracks id only, so a
    non-id sort drops records that sort late but carry a lower id.

Pre-merge checklist (RELEASING.md §2)

  • pre-commit run --all-files — all 8 hooks pass.
  • git log origin/main..HEAD reviewed — every commit since v0.0.15 is represented in the notes above.
  • pytest --cov=jvspatial --cov-fail-under=50 — running locally; CI is authoritative and re-runs it on this PR.
  • mypy jvspatial/see note.

Note on mypy jvspatial/. The bare invocation RELEASING.md §2 lists reports 86 errors across 35 files — but it reports the identical 86 on unmodified main, so this release introduces none. The pre-commit mypy hook passes because it runs with different settings than a bare mypy jvspatial/. Worth reconciling: as written, that checklist step can never be green, so a releaser either skips it or ships knowing it's red. Out of scope here.

After merge

The publish workflow tags v0.0.16 from version.py and uploads to PyPI via Trusted Publishing. Then §8 (cut the GitHub release from the auto-created tag) is still manual.

Downstream, jvagent is waiting on this: its pin is jvspatial==0.0.15, and jvagent#139 documents Postgres as blocked precisely because the fix is unreleased. Once 0.0.16 is on PyPI, that pin bumps and those docs flip to a supported-backend guide.

Roll the [Unreleased] block into a dated 0.0.16 heading and bump
jvspatial/version.py, which is what the publish workflow reads.

Patch rather than minor: the release contains ### Added entries, which
RELEASING.md section 3 would normally route to a minor bump, but all of
them are additive and backwards compatible -- new optional ServerConfig
fields, a previously-unreachable db_type branch, an exported helper, and
a new exception hierarchy whose base still derives from RuntimeError.
Nothing existing changes shape, so adopters can take this without
reading the notes.
@github-actions

github-actions Bot commented Aug 4, 2026

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.030557 0.036940 +20.9% OK
tests/benchmarks/test_deferred_save_benchmarks.py::test_bench_immediate_save_100 0.030536 0.037552 +23.0% OK
tests/benchmarks/test_jsondb_benchmarks.py::test_bench_jsondb_batched_saves_500 1.564247 0.407868 -73.9% IMPROVED (-73.9%)
tests/benchmarks/test_jsondb_benchmarks.py::test_bench_jsondb_count_empty_query 1.097741 0.835473 -23.9% OK
tests/benchmarks/test_jsondb_benchmarks.py::test_bench_jsondb_count_filtered 1.366355 1.003665 -26.5% IMPROVED (-26.5%)
tests/benchmarks/test_jsondb_benchmarks.py::test_bench_jsondb_find_filtered 0.973260 0.754742 -22.5% OK
tests/benchmarks/test_jsondb_benchmarks.py::test_bench_jsondb_save_throughput 0.002376 0.001482 -37.6% IMPROVED (-37.6%)
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_count_empty 0.196904 0.232321 +18.0% OK
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_count_fallback_via_regex 0.232967 0.305687 +31.2% REGRESSION (+31.2%)
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_count_pushdown 0.202078 0.272584 +34.9% REGRESSION (+34.9%)
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_find_fallback_via_regex 0.232486 0.287573 +23.7% OK
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_find_pushdown 0.193927 0.249776 +28.8% REGRESSION (+28.8%)
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_sort_limit_pushdown 0.249263 0.276762 +11.0% OK

@eldonm eldonm self-assigned this Aug 5, 2026
@eldonm
eldonm merged commit 3bed558 into main Aug 5, 2026
6 checks passed
@eldonm eldonm mentioned this pull request Aug 5, 2026
4 tasks
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