Repository navigation
Conversation
…ject_hook and without reading its side array (refs #315) to_list() and iter_dicts() of the JVM wheel build a json object_hook for every batch and read the side array of every batch, whether or not the batch holds a tagged value. These tests count the hook factory, the object_hook argument of json.loads and the batch elements that are read, for a result of plain values (no hook, no read of the side array) and for every kind of value the bridge tags (the hook stays). They also compare to_list() and iter_dicts() with the decoding that always builds the hook, over single shapes, a mixed result and batch boundaries. They fail on the current decoder (every batch gets a hook and a read of the side array) and pass once the decoder looks for the tag escape in the batch text first. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…s text without an object_hook and without reading its side array (refs #315) Every batch was decoded with json.loads(text, object_hook=_typed_hook(side)) and its side array was read first, also when the batch held only plain values. The hook is a Python call per JSON object and the read of the side array is a JPype operation per batch, which for iter_dicts (one row per crossing) is one per row. TypedRows writes every tag as {"\u0001": [kind, payload]} with the escape spelled out, writes a control character of a string as the same escape, and never writes the raw character, and every side array entry is referred to by a tag in the text. A text without the escape therefore holds no tag and no side index, the hook would return each object unchanged, and plain json.loads gives the same value. The check is a substring scan of a str that already exists (_loads_typed_row did it for one row). A string that holds U+0001 or the text of the escape only keeps the hook, which then changes nothing. No Java change, and the native wheel is untouched. The speed effect is not measured: it waits for a timed A/B of a candidate wheel against 26.11.1.post2. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…taframe on the JVM wheel (refs #315) Round trips of every Arrow temporal type through a real load on both wheels (every timestamp unit with and without a zone, to the nanosecond; date32 and date64 before 1970; nulls; batches; vertex and parallel loads; pandas and Polars frames), and JVM-only tests that count what one load does: no java.time call and no convert_python_to_java per cell, and each timestamp or date column reaching the bridge as one TemporalColumn per batch. The counting tests fail on the code before, which builds one LocalDateTime or LocalDate per cell in Python and converts every element again. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…te column crosses as one long[] of epoch ticks, not one JPype call per cell (refs #315) _temporal_to_java made one LocalDateTime.ofEpochSecond or LocalDate.ofEpochDay call per cell, and insert_columns converted every element of that list again with convert_python_to_java: a million-row timestamp column was two million JPype calls. The column is now the integer epoch values in one long[] (plus one boolean[] when a row is null) wrapped in the new bridge class TemporalColumn (dateTimes(long[] ticks, long ticksPerSecond, boolean[] nulls) and dates(long[] ticks, long ticksPerDay, boolean[] nulls)), and DocumentBatcher builds each LocalDateTime or LocalDate as it fills the document of that row, as StringColumn does for text. The reading rules are the ones of the code before: UTC wall clock, a zoned timestamp is already UTC epoch time, a naive one is the clock it shows, the unit's precision is kept (a nanosecond tick is not cut to microseconds), a tick before the epoch floors, date64 is milliseconds divided by 86,400,000 with a floor. The factory checks the smallest and the largest tick, so a value past the years of java.time still raises before a row is written. The per-cell path is retired: the wheel carries its own bridge jar, so no fallback for an older one. Not measured: the speed claim waits for a timed A/B against 26.11.1.post2. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ly text, no free and no JSON decode (refs #315) arcadedb_n_tx returns the transaction state as one integer (bit 0 active, bits 1 to 31 depth, bits 32 to 62 commits, -1 for an error whose text lib.last_error holds), as arcadedb_n_cmd returns a cursor id. These tests pin that and that an error still reaches Python as the same typed exception with the same message: - with db.transaction() is three foreign calls (mark, begin, commit), all arcadedb_n_tx: no strlen, no copy into a str, no arcadedb_free (was twelve); - begin, commit, rollback, is_transaction_active and the mark each make exactly one foreign call; - no JSON decode in a transaction, not even for the mark that reads active and depth; - the layout of the integer, -1 for an unknown handle with the error in last_error, commit without begin, rollback with nothing to roll back, a failed statement in a block, nested with blocks, an injected failure on each entry point, a database closed with a transaction open. They fail on the old code: lib.tx does not exist there, and the foreign-call and decode counts are higher. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…not a JSON text (refs #315) A begin, a commit, a rollback and a mark each crossed into the library and got back a malloc'd JSON text of about 60 bytes that Python read with strlen, a copy into a str and arcadedb_free, and mostly never looked at. with db.transaction() is three crossings (the mark, begin, commit), so twelve foreign calls and three strings built, copied and freed. arcadedb_n_tx now returns a long: bit 0 active, bits 1 to 31 depth, bits 32 to 62 the low 31 bits of the commit count, -1 for an error. It is the way arcadedb_n_cmd returns a cursor id: the error text is kept per thread and read with arcadedb_n_last_error, built by the same errorJson as before, so the typed exception and its message do not change. Each crossing is one foreign call, and with db.transaction() is three. The Python side reads the fields of the integer inline, so the mark, the begin and rollback states the unwind and run_in_transaction read, and is_transaction_active no longer decode anything. Not measured: the speed claim waits for a timed A/B of a candidate wheel against 26.11.1.post2. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ossings, not three (refs #317) The transaction check of a saved record's save() cannot refuse while auto-transaction is on, yet it still crossed into the library (arcadedb_n_tx) before the SELECT and the UPDATE. These tests pin two crossings there, bare and inside a transaction, with and without the read-back of the row, and pin what must not move: the check is back the moment auto-transaction is off (first, before any read), a stale record is refused under auto-transaction too, and the stored values and the next save's baseline are those of an update inside a transaction for NULL, float, text, date-time, date, Decimal, nested values, a float32 array and a value set unchanged. The two-crossing tests fail on the code before the change (three crossings); the rest pass on both. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…te when auto-transaction is on (refs #317) _save_update asked is_transaction_active() (an arcadedb_n_tx crossing) first and only then looked at the auto-transaction flag, so the answer was thrown away whenever the flag was on. The operands are swapped: the flag is a Python attribute, the state is a crossing. With auto-transaction on, an update of a saved record is two crossings (the SELECT of the stale-record check and the UPDATE), not three. With it off nothing changes: the check still comes first, outside a transaction and inside one. The check is not replaced by the engine's refusal for the default mode: the engine runs the before-update callbacks and the property validation before it looks for a transaction (read in LocalDatabase.updateRecord of the 26.12.1-SNAPSHOT source), and an UPDATE of a record that is gone raises nothing, so a save outside a transaction would run callbacks, or pass silently, instead of raising "Transaction not begun" before it reads. A Python-side record of the transaction state is not exact either: a SQL COMMIT or ROLLBACK statement ends the transaction without passing the wheel's transaction calls. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… no failed assert leaves a transaction open (refs #315) CI of the first push (run 43478436168) failed two tests on a wrong assumption of mine: I expected the outer begin to raise the depth by one. LocalDatabase.getNestedTransactions() is the size of the thread's stack of transaction contexts (DatabaseContext.DatabaseContextTL.transactions). The stack always holds the thread's base context once the thread has used the database, so the depth is 1 while idle. The outer begin reuses that base context (depth 1, active). Only a begin while a transaction is active pushes a context (2), the commit or rollback of that level pops it (1, still active), and the outermost end leaves the base context on the stack (1, idle). popIfNotLastTransaction never removes the last one. The native tx entry point returns exactly that number, as the old JSON reply did, so the change is right and the two expectations were not. The fixture now rolls back whatever a test left open before it drops the database, so a failed assert is one failure and not also a "Cannot drop the database in transaction" teardown error. The close-and-reopen test does the same in a finally. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…(refs #388, #315) A RID, a list, a map or a mixed column was one tagged JSON array of the column (the json kind), parsed per cell in Python. Both writers (native ColumnFrames, bridge ColumnBatcher) now write rid (bucket and position, two int64 per row) for a column of valid RIDs and li8 or lf8 (int32 offsets, a bitmap of null elements, one flat int64 or float64 array) for a column of lists of integers or of floats; maps, nested lists, lists of strings, booleans or RIDs, lists that mix ints and floats, a list of nothing but nulls and everything else stay json. The shared decoder reads the new kinds and still reads json for them (a frame of an older writer): to_columns gives Python lists, to_arrow a string column for RIDs and list<int64> or list<double> over the frame's own offsets. The JVM writer also puts the engine's RID objects into the side array, so to_columns hands out the engine's own RID as before; the native wheel builds its RID class from the pair. The head frame the native wheel writes in Python for the first reply is unchanged (json, the values themselves). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… output of the released wheels (refs #388, #315) The fixture is the output of the released 26.11.1.post3 wheels (JSON route, one entry per wheel) for the tables of the test, dumped before the change by a throwaway run on a branch cut from the tag; it is not generated from this code. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…388) The pre-commit check of main runs prettier on json. The values of tests/fixtures/frame_kinds_post3.json are the tag's own output and are unchanged: the parsed JSON before and after is identical (checked by script). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
tae898
force-pushed
the
perf/388-frame-rid-list-kinds
branch
from
November 14, 2026 07:04
54cf84a to
b04e107
Compare
8 tasks
…bug, refs #388) The read ends with an empty frame that has no columns (JVM: KeyError), and on the native wheel the Python-written head frame of the first 256 rows is decoded after the library's frames and is always json. A binary column now has to show its kind in at least one frame and be json or that kind in all; a json column is json in every frame. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…py passes an importer setting that the latest engine refuses; refs #388) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This branch has not been deployed
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.
What does this PR do?
A column of record links or of lists of numbers inside a binary column frame was one tagged JSON array of the column (the
jsonkind), parsed per cell in Python. This is rank 10 of #388. Both writers (ColumnFramesof the native image andColumnBatcherof the bridge jar) now write two more column kinds, and the one shared decoderarcadedb/_columnar.pyreads them:rid: two int64 per row, the bucket and the position of a record link.to_arrowgives a string column of#bucket:position, as the column of RID objects degraded to before.to_columnsandto_dataframegive the wheel's own RID object: the native wheel builds itsRIDfrom the pair, and the JVM bridge puts the engine's RID objects into the side array in row order (the header names where they start,side), so the JVM wheel still hands out the engine's own RID, which a bucket and a position cannot be turned back into on the Python side without the database.to_arrownever touches the side array.li8andlf8: a list of int64 or of float64 per row. The buffer is int32 offsets (rows + 1), a bitmap with one bit per element of the flat array for a null element, then the flat int64 or float64 array of every list's elements. A null row is the frame's null bit, an empty list is two equal offsets,Noneinside a list is a bit of the bitmap. NaN, the infinities and-0.0are float64 values.to_columnsgives Python lists of exactintorfloat,to_arrowgiveslist<int64>orlist<double>over the frame's own offsets and flat array.What keeps the
jsonkind: maps, nested lists, lists of strings, booleans or RIDs, a list that mixes ints and floats (each element keeps its own type there), a list of nothing but nulls or empty lists (Arrow types itlist<null>, an element type cannot be read from it), embedded records, a RID that is not valid (negative bucket or position), a column that mixes kinds (a column of int lists in one row and float lists in another isjson),Set, primitive arrays, and a column with more than 2**27 list elements in one frame (int32 offsets). The decoder keeps readingjsonfor RID and list columns, so a frame of an older writer still decodes. The first 256 rows of a native result are written as a frame in Python from the JSON reply (the values themselves,jsonkind); that code is unchanged, and the decoder gives the same values and the same Arrow types for both forms.Related issues
Refs #388 and #315.
Performance changes
Not measured: no timed A/B has run, so this PR states no speed or memory figure.
Figure definition for the A/B (pre-registered before it runs): the operation is a columnar read of one query,
to_columns(),to_arrow(),to_dataframe()andto_polars(); the unit of one operation is one such read of the whole result; sizes are 1, 100 and 100,000 rows, plus thebatch_sizethat splits the 100,000 rows into several frames; shapes are aSELECT @ridcolumn, a link property, an int list column, a float list column, and the control shapes that keepjson(a map column, a nested list column) which must not change. Both wheels, each against the released26.11.1.post3wheel of the same distribution, ABBA with an A/A noise run, latency and peak RSS with anon growth as information figures. The parity digest istests/test_frame_rid_list_kinds.py::test_readers_match_the_tag_fixture: all four readers on a 700-row table of these shapes, per column the type and a digest of the exact values (type tags,reprof floats, NaN and-0.0apart from0.0), compared with the output of the released 26.11.1.post3 wheels stored intests/fixtures/frame_kinds_post3.json. The no-change control is the map and the nested list columns, and a result with no RID or list column.What the change allocates per frame, per column: a
ridcolumn is 16 bytes per row of buffer on each side (the JVM also holds one reference per row in the side array); anli8orlf8column is 4 bytes per row of offsets, one bit per element, 8 bytes per element, one copy of the flat array on each side of the crossing.to_columnsthen builds one Python list per row and one Python number per element (the JSON route built the same objects, plus the text and the parse tree);to_arrowbuilds no per-element Python object. The frame still holds one batch, never the whole result.Docs
No page under
docs/orREADME.mddescribes the frame kinds; the module docstrings ofarcadedb/_columnar.py,ColumnFrames.javaandColumnBatcher.javaand the one ofarcadedb/_native/colframes.pyare updated.How I checked it
tests/test_column_frames.py(hand-built frames, no database):rid,li8andlf8with nulls, empty lists,Noneinside, NaN, the infinities,-0.0and integers beyond 2**53; the decoder against the JSON form of the same column (values, Python types, Arrow type); the hook that makes the RID objects; and ajsonframe of RIDs, lists and maps still reads. Six of the seven fail on the decoder of the tag.tests/test_frame_rid_list_kinds.py(both wheels, no branch on the backend except the stored output): which kind each shape gets, read from the frame headers (this fails while a writer still writesjson), the four readers against the row route (to_list) cell by cell including the type and text of every RID, and against the stored output of the released wheels.Checklist
uvx pre-commit run --files <changed files>passes🤖 Generated with Claude Code