Release 0.0.16 - #36
Merged
Merged
Conversation
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.
Benchmark comparisonThreshold: ±25% (informational, does not block merge)
|
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.
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
mainand passed CI there.Version choice: patch, not minor
RELEASING.md §3 routes
### Addedentries 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.databasefields, adb_typebranch that previously raised, an exported helper (resolve_sort_value), and a new exception hierarchy whose base still derives fromRuntimeErrorso 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 aPostgresDBprime database fordb_type="postgres"(alias"postgresql") instead of raisingValueError: Unsupported database type: postgres. The backend andcreate_database("postgres", ...)already worked; only theServerpathwas missing, so every API-layer deployment — and anything built on it — was
locked out of a documented backend.
ServerConfig.databasecarries 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 thealready-allowlisted
JVSPATIAL_POSTGRES_*env keys. Connection settings nowflow through the config object like every other backend's rather than being
readable only by the driver. Unset values still defer to
PostgresDB's owndefaults. Coverage:
tests/test_env_adapter_postgres.py,tests/api/components/test_database_configurator.py.resolve_sort_value(record, field)(jvspatial/db/database.py) — thedotted-path resolution
finalize_find_resultsuses, exported so adapters andcursor logic resolve a sort field the same way. Added to the module's
__all__.Deferred-task exception hierarchy (
jvspatial/exceptions.py) —DeferredTaskError→TaskDispatchError→TaskSchedulerNotConfiguredError,replacing the bare
RuntimeErrors raised by strict dispatch. A strict callercan now tell "retrying may succeed" (
TaskDispatchError) from "thisdeployment will never dispatch" (
TaskSchedulerNotConfiguredError).DeferredTaskErroralso derives fromRuntimeError, so handlers writtenagainst the previous behavior keep working.
Fixed
PostgresDBpool is event-loop aware (jvspatial/db/postgres.py). Theasyncpgpool and its lock bind to the loop that created them, but_ensure_pool()memoized both for the lifetime of the instance. A host thatbootstrapped in one
asyncio.run()and then served from a second loop hitcannot perform operation: another operation is in progress/ConnectionDoesNotExistErroron its first query. The pool and lock are nowrebuilt 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
WHEREis expectedone-shot migration noise; log at info. Also satisfy ruff SIM110 in
_index_needs_partial_repairand mypy narrowing for$eqstring literals in_sqlite_translate.py.SQLite connect-time repair of global
session_idunique indexes(
jvspatial/db/sqlite.py). Opening a SQLite DB now drops UNIQUE indexes onjson_extract(data, '$.context.session_id')that lack aWHEREclause,so a process that never re-ran
ensure_indexes(Conversation)after thepartial-filter fix still stops wiping Interaction rows. Partial unique
create_indexfailures 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_indexignored Mongo-stylepartialFilterExpression/partial_filter_expressionkwargs and created global unique indexes onshared
nodecollections. That madeINSERT OR REPLACEwipeInteractionrows when they shared
context.session_idwith aConversation(orchestratorhistory empty on SQLite, fine on JsonDB). SQLite now translates the same
small dialect as Postgres into a
WHEREclause, raises if a unique partialfilter 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.pyand translator unit tests.SQLiteDB.findtreatedsort=[]as an untranslatable sort(
jvspatial/db/sqlite.py). An empty list failed thesort is Noneguard, soit took the fallback branch: the whole collection was loaded and
limitapplied in memory instead of being pushed into SQL. Results were correct, the
work was not. A falsy
sortis now normalized toNone.ObjectPagerre-sorted each page with a key that disagreed with thedatabase slice (
jvspatial/core/pager.py;paginate_by_fieldinheritedit). The in-Python safety-net sort used
item.get("context", {}).get(order_by, 0), so a record missingorder_bywas ordered as
0— among the real values — while the DB-sidesort+limitthat produced the slice had placed it in the trailingmissing-value run. Records could therefore appear on two pages or on none.
A blanket
contextlib.suppress(KeyError, TypeError)also left a pagesilently unsorted on mixed-type keys. The re-sort now routes through
finalize_find_results, making it a genuine no-op whenever the backendhonored the sort.
GraphContext.find_pagebroke on dotted sort fields and could not reachrecords missing the sort value (
jvspatial/core/context.py). Two defects:the cursor payload was minted with a flat
last.get(primary_field), so asort=[("context.started_at", -1)]page always encodedsort: Noneand thenext page's keyset filter compared against
None— raisingTypeError: '>' not supported between instances of 'int' and 'NoneType'fromQueryEngineon JsonDB. And with records missing the sort field now sortinglast, the keyset filter
{field: {"$lt": value}}could never match them, soiteration 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 reachthe trailing run, and a cursor minted inside that run walks it by
id.Postgres applied
LIMITin SQL even when the sort could not be pusheddown (
jvspatial/db/postgres.py, bothPostgresDB.findandPostgresTransaction.find).translate_sortreturnsNonefor a field pathit cannot safely interpolate (e.g.
context.my-field), leaving the orderingto
finalize_find_results— but theLIMITwas still pushed, so the databasereturned 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
LIMITis now withheld whenever the sortfalls back to memory, matching
SQLiteDB.findandDynamoDB.find. Vector(
$near) queries additionally no longer have their distance orderingoverwritten by an in-memory re-sort on the user's
sort.In-memory
findsort ignored dotted field paths (jvspatial/db/database.py)._find_sort_keyresolvedsortfields with a flatrecord.get(field), so aspec like
sort=[("context.started_at", -1)]producedNonefor every row andleft the result in arbitrary order. The SQLite and Postgres pushdowns
(
translate_sort) and Mongo's native sort already resolved dotted paths, sothe 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
Nonerather than raising.Descending in-memory sorts placed records missing the sort field first
(
jvspatial/db/database.py).finalize_find_resultssorts withreverse=True, which flipped_find_sort_key'sNoneflag along with thevalues. Both SQL translators emit
NULLS LASTfor descending and Mongo sortsmissing values last, so a "newest N"
sort+limitfetch returned real rowson 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_sortasserting the in-memory pathalready matched has been corrected.
Strict deferred scheduling only caught the no-op-scheduler case
(
jvspatial/serverless/).dispatch_deferred_task(..., strict=True)raisedwhen 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.
strictnow raises on:an unset
AWS_LAMBDA_FUNCTION_NAME; a Lambdainvokethat raises oranswers a non-2xx
StatusCode/ carries aFunctionError(an async invokereturns 202 on acceptance, so boto3 not raising was never proof of
dispatch); an unconfigured SQS client or queue; a failed SQS
send_message;a
NoopOrSyncSchedulerwith no executor — the scheduler everynon-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). SQSsend_messageerrorspropagated while the Lambda transport swallowed them, so identical
application code had opposite failure semantics depending on
JVSPATIAL_AWS_DEFERRED_TRANSPORT.strictis now the single switch onevery transport:
Falseis fire-and-forget,Trueraises.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 strictnever got the startup error explaining why nothing dispatches. The
diagnostic is now emitted first.
Changed
TaskScheduler.scheduletakes astrictargument(
jvspatial/serverless/tasks/base.py).TaskScheduleris a public/stableextension point and
config.task_scheduleris duck-typed, sodispatch_deferred_taskintrospectsschedule()and omitsstrictforthird-party implementations that predate it — those keep serving non-strict
dispatches unchanged. A
strict=Truedispatch through such a schedulerraises
TaskSchedulerNotConfiguredError(it cannot honor the guarantee)rather than
TypeError.Documentation
findsort contract moved to SPEC §4.1 (beside theDatabasemethodtable it governs, rather than under §4.2 capability flags) and extended: the
limit-must-not-outlive-the-sort-pushdown rule, plus a Known divergencestable covering MongoDB's ascending sorts (native
cursor.sort()placesmissing values first — documented, not normalized), array-index path segments,
and heterogeneous value types.
jvspatial/db/_sqlite_translate.py(module docstring andtranslate_sort)and
jvspatial/db/_postgres_translate.py(translate_sort). All three stillclaimed "NULLs sort last for ascending, first for descending, mirroring
finalize_find_results" — the opposite of what the code emits and of thecontract.
Database.findnow documents the ordering contract adapter authors mustsatisfy;
Database.find_iterno longer claims a composite(sort_value, id)cursor — the default implementation tracksidonly, so anon-
idsort drops records that sort late but carry a lowerid.Pre-merge checklist (RELEASING.md §2)
pre-commit run --all-files— all 8 hooks pass.git log origin/main..HEADreviewed — 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 unmodifiedmain, so this release introduces none. The pre-commitmypyhook passes because it runs with different settings than a baremypy 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.16fromversion.pyand 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.