Skip to content

Native columnar insert: date-time, date, decimal and bytes columns stay columnar (refs #316) - #333

Merged
tae898 merged 8 commits into
release/26.11.1from
perf/316-native-columnar-temporal
Nov 10, 2026
Merged

tae898 merged 8 commits into
release/26.11.1from
perf/316-native-columnar-temporal

Conversation

@tae898

@tae898 tae898 commented Nov 9, 2026 •

Copy link
Copy Markdown
Collaborator

For users nothing changes in the stored values: on the native wheel a batch with a date-time, date, Decimal or bytes column now stays on the columnar entry instead of turning every column of the batch into a dict per row and a JSON text with tagged values, and the same objects are stored. Part of #316 (all four kinds are in; I could prove each conversion from the code, see below). Base is release/26.11.1, for the wheels-only 26.11.1.post1.

This PR is stacked on #328 (it needs the same BulkOps.java and dataframe.py code, and both change the value switch), so its diff shows that PR's two commits until it merges; the change of this PR is the last commit. Merge #328 first.

What changes: BulkOps.insertColumns gets four kinds, each read as the object that Json.decodeTag builds for the tagged value the row path sends. T is a date-time: int64 ticks of the UTC wall clock since 1970-01-01T00:00:00 with dim ticks per second (1, 1000, 10^6 or 10^9, so an Arrow timestamp keeps its unit and a nanosecond value keeps its nanoseconds, as _temporal_to_tagged did), read with floorDiv and floorMod as a LocalDateTime (the object LocalDateTime.parse of the tag gave, so pre-1970 values are exact). D is a date: int32 epoch days, a LocalDate. n is a decimal: str(Decimal) in the string layout read with new BigDecimal(text), which is what the n tag does with the same text, so the scale is kept (1E+3 stays 1E+3). x is bytes in the string layout read as the byte[] the b tag decodes to. columnar.py lays out lists of datetime (also pandas.Timestamp, microseconds as _tag_datetime keeps them), date, Decimal, bytes and bytearray, and Arrow timestamp, date32, date64, decimal and binary arrays. The time zone rule is not changed: a time-zone-aware value is its UTC wall clock and a naive one is taken as it is, the rule of _tag_datetime, and nothing in the column decides a zone. Still refused (row path as before): numpy datetime64 arrays and elements, a column that mixes types (a datetime and a date, bytes and text, bytes and bytearray), a non-finite Decimal, and pandas NaT and NA columns. insert_arrow and insert_dataframe keep those Arrow columns for the entry and turn them into tagged values only when a batch falls back to the row path (another column, such as a list of strings, cannot cross), so a fall back stores what it stored before.

This is Java glue: the native library is rebuilt from the unchanged engine commit, no engine file is touched. Memory: the batch no longer builds the per-row dicts and the JSON text, but a list of Python date-times is converted to an int64 array first (one 8-byte value per row); I did not measure the peak.

Tests: tests/test_native_columnar_temporal.py (native only) loads the same rows and Arrow tables through the row path (the columnar path switched off, the private reference of the old behaviour) and through the columns, compares every stored value and its Python type, and spies on the crossings (the old code makes no arcadedb_n_insert_colp call there, so these fail on it); it covers declared and undeclared properties, nulls, pre-1970 values, aware and naive date-times, pandas Timestamps, Arrow units s, ms, us, ns with and without a zone, date32, decimal128, binary, the fall back with a list column, a parallel load, and the image's refusal of a wrong tick unit. tests/test_columnar_temporal_lists.py runs on both wheels with literal expected values (naive, aware to UTC, pre-1970, null, date, Decimal, bytes), so the same stored values on the JVM wheel and the native wheel are a CI result; the existing tests/test_dataframe_ingest.py precision, zone and date tests run through the new path on the native wheel. tests/test_native_columnar_pack.py pins the layouts without a library, and three existing tests that asserted the old fall back (a date-time column, a Timestamp column, an Arrow timestamp) now assert the columnar entry. The golden test tests/test_cross_wheel_golden.py is not touched.

No speed claim is made here. The measurement is the untimed profile study of the ingest shapes and a later laptop A/B on this change. I could not run any test locally (CI only); the CI runs are linked in the first comment.

Changes after an independent review of this PR. A date64 value beyond int32 days no longer wraps in the int32 cast (it did, silently; the row path raised): columnar.pack now returns None for it, so the batch takes the row path and raises what it raised before, with nothing stored. A timestamp[s] beyond the years of java.time (about 3.15e16 seconds) made LocalDateTime.ofEpochSecond throw in the middle of a batch; pack now checks the range in Python and returns None, so the row path raises its usual error before anything is stored (the synchronous call leaves no transaction open; a parallel call says that rows may have been stored, as it can have handed rows over before the failure). The review named timestamp[s] and timestamp[ms]: only s can pass the range, because the largest int64 of milliseconds is 9.2e15 seconds against 3.15e16, so milliseconds, microseconds and nanoseconds need no check (a test loads the int64 limits in those units and expects them to stay columnar).

One behaviour change to read: an insert_many chunk that mixes a date-time, date, Decimal or bytes column with a float column holding NaN now goes columnar, and the NaN is stored as Double.NaN; before, the temporal column forced the JSON path, where a NaN row goes through INSERT INTO t CONTENT :row on its own. I read the engine at a3a5d12457: CONTENT is UpdateContentStep, which calls doc.fromMap, and fromMap and doc.set both end in MutableDocument.putProperty with no special case for NaN, so the stored value is the same Double.NaN on both routes (an INSERT of a NaN into a declared DOUBLE keeps it). That is read from code; tests/test_native_columnar_temporal_review_fixes.py compares both routes by value and type, and the CI run is the proof.

Review tests (native only, tests/test_native_columnar_temporal_review_fixes.py): the pack-level limits of date64 and timestamp[s] (one value past the limit is enough to refuse the column, the limit itself stays columnar and exact); pa.array([0, 2**62], pa.timestamp("s")) and a date64 past int32 days through the synchronous call (nothing stored, no transaction left open) and the parallel call (may have been stored); and the NaN comparison above.

A chunk of insert_many rows in which one row holds a value the JSON text cannot carry still sends that row to the statement on its own, and the runs of rows around it stay on the bulk entry; since the columnar entry now carries date-time, date, Decimal and bytes columns, such a run crosses on arcadedb_n_insert_colp where it used to cross on the JSON entry (arcadedb_n_insert_rows). A Decimal column that holds a non-finite value (NaN, sNaN, Infinity) is refused as a whole, in lists, with nulls and in object arrays (new BigDecimal("NaN") would throw in the middle of a batch), so the non-finite row takes the statement path as before.

🤖 Generated with Claude Code

tae898 and others added 2 commits November 9, 2026 18:53
…325)

A parallel call skipped the columnar fast path and went through one dict per
row and one JSON text per chunk of 10,000 rows. The columnar entry
arcadedb_n_insert_colp now takes "parallel": true in its header: the image
builds the records from the column buffers and hands them to the async writers
(db.async().createRecord with a failure token), as
DocumentBatcher.insertColumnsParallel does on the JVM wheel. The Python side
waits for the writers and reads the failures exactly as insert_many(parallel=True)
does (_finish_parallel, shared). Rows the buffers cannot carry still take the row
path. The native library is rebuilt from the unchanged engine commit.

Tests (native only): a spy proves one colp crossing and no insert_parallel
chunk; the stored records equal those of insert_many(parallel=True); nulls,
vertex types, rejected records, an edge type, a missing type, the fall back and
the off switch.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… columnar entry (refs #325)

A document load with parallel=True left the Arrow columns as Python values and went
to insert_many(parallel=True). It now keeps the columns as they are for the columnar
entry, which hands the records to the async writers; a batch the buffers cannot carry
still takes the row path. A test spies on the entry points and compares the stored
records with the row path of the old code.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@tae898

tae898 commented Nov 9, 2026

Copy link
Copy Markdown
Collaborator Author

CI evidence for 4546c437b, dispatched on this branch (a draft PR skips the platform legs, and the PR runs use the latest jars): Test native version=26.11.1.post1 https://github.com/ArcadeData/arcadedb-python/actions/runs/42598022837, Test JVM arcadedb-tag=26.11.1 https://github.com/ArcadeData/arcadedb-python/actions/runs/42598026486, Test Python Examples arcadedb-tag=26.11.1 https://github.com/ArcadeData/arcadedb-python/actions/runs/42598030115. Results will be added here.

🤖 Generated with Claude Code

… after black wrapped it

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…loss (review of #328)

The failure token is now made by the caller (arcadedb_n_async "token") and its id travels in the colp header, so the caller holds the id
whatever happens to the crossing. An interrupt in it (or between the chunks of the row path) waits for the writers and reads the tokens
before it propagates; a wait that raises still reads every token (the entries of an unread token lived forever); the error of a failed
handover says how many rows were handed over and that some may have been stored. insert_table(parallel=True) no longer says the earlier
batches were written in the caller's transaction (the writers commit them, a rollback does not remove them).

Tests: duplicate keys (stored + reported >= n, stored == distinct), the writers' batch boundary, nothing lands after the call, an
interrupt in the crossing and in the row path, a failing wait, the in-transaction message.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
tae898 added a commit that referenced this pull request Nov 9, 2026
… the crossing (review of #333)

A date64 past int32 days wrapped in the int32 cast (the row path raised) and a timestamp[s] past the years of java.time threw
DateTimeException in the middle of a batch (BulkOps T kind, LocalDateTime.ofEpochSecond). Both now make pack() return None, so the call
takes the row path and raises what it raised before, with nothing stored. Only a timestamp[s] can pass the java.time range: an int64 of
milliseconds is at most 9.2e15 seconds against 3.15e16.

Tests: the pack-level limits, the sync call (nothing stored, no transaction left open), the parallel call (may have been stored), and a
date-time chunk with NaN stores the NaN that INSERT ... CONTENT :row stores (both end in MutableDocument.putProperty).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@tae898
tae898 force-pushed the perf/316-native-columnar-temporal branch from 0acd741 to b1001e2 Compare November 9, 2026 12:25
@tae898

tae898 commented Nov 9, 2026

Copy link
Copy Markdown
Collaborator Author

I applied an independent review to this PR (new head b1001e2, rebased on the updated #328). A and B are fixed in Python before the crossing: a date64 past int32 days and a timestamp[s] past the java.time range make pack return None, so the row path raises as before with nothing stored. One correction to the finding: it named timestamp[s] and timestamp[ms], but only s can exceed the range (an int64 of milliseconds is at most 9.2e15 seconds against 3.15e16), so ms, us and ns are not checked and a test keeps their int64 limits columnar. C is stated in the PR body: from the engine source (CONTENT goes through fromMap, which ends in the same putProperty as doc.set) the stored NaN is the same on both routes; that is read from code and a test compares them. CI on the fork: native, jvm, examples.

tae898 and others added 4 commits November 9, 2026 22:22
…'s open batch (CI of the first candidate)

The first candidate run showed stored 1000 of 2950 distinct keys: a writer that meets a duplicate key rolls back its whole open batch
(up to commit_every rows), so the valid rows of that batch are abandoned and reported too. stored == distinct was the wrong invariant.
Assert stored + reported >= n, stored <= distinct, only sent ids, none stored twice.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ay columnar (refs #316)

One column of a date-time, date, Decimal or bytes value used to make columnar.pack return
None, so every column of the batch went through one dict per row and a JSON text with tagged
values. BulkOps.insertColumns gets four kinds that read the same objects Json.decodeTag
builds for those tagged values, so the stored value is the same: T (int64 ticks of the UTC
wall clock since the epoch, dim = ticks per second so an Arrow timestamp keeps its unit,
LocalDateTime), D (int32 epoch days, LocalDate), n (decimal text in the string layout,
BigDecimal) and x (bytes in the string layout, byte[]). columnar.py lays out lists of
datetime (also pandas.Timestamp), date, Decimal, bytes and bytearray and Arrow timestamp,
date32, date64, decimal and binary arrays; a time-zone-aware value is its UTC wall clock and a
naive one is taken as it is, the rule of _tag_datetime. The DataFrame and Arrow loader keeps
those Arrow columns for the entry and turns them into tagged values only when a batch falls
back to the row path. The native library is rebuilt from the unchanged engine commit.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… the crossing (review of #333)

A date64 past int32 days wrapped in the int32 cast (the row path raised) and a timestamp[s] past the years of java.time threw
DateTimeException in the middle of a batch (BulkOps T kind, LocalDateTime.ofEpochSecond). Both now make pack() return None, so the call
takes the row path and raises what it raised before, with nothing stored. Only a timestamp[s] can pass the java.time range: an int64 of
milliseconds is at most 9.2e15 seconds against 3.15e16.

Tests: the pack-level limits, the sync call (nothing stored, no transaction left open), the parallel call (may have been stored), and a
date-time chunk with NaN stores the NaN that INSERT ... CONTENT :row stores (both end in MutableDocument.putProperty).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ds changed or that were mis-built

- review fixes: the date64 case built an int64 beyond its own range (DAY_MS * 2**40); use 2**33, still past int32 days. A non-finite
  Decimal (NaN, -NaN, sNaN, Infinity, -Infinity) is refused in lists, with None, and in object columns.
- nan-decimal fall back: the NaN row goes to the statement as before; the finite row around it is a run of carriable rows and crosses on
  the columnar entry now (a finite Decimal is carried), so one colp call is expected, not none.
- pack refusals: bytes alone are carried (kind x) now; refuse bytes with text and bytes with bytearray instead.
- temporal lists: a BINARY property reads back as the signed values of a Java byte[] or as bytes; compare after & 0xFF.
- mixed chunk: the rows around an uncarriable row cross on the columnar entry (the date-time column is carried), one colp call per run.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@tae898
tae898 force-pushed the perf/316-native-columnar-temporal branch from b1001e2 to c4d41e5 Compare November 9, 2026 13:23
@tae898 tae898 self-assigned this Nov 9, 2026
@tae898
tae898 merged commit c4d41e5 into release/26.11.1 Nov 10, 2026
19 checks passed
@tae898
tae898 deleted the perf/316-native-columnar-temporal branch November 10, 2026 02:23
@github-actions github-actions Bot added this to the 26.12.1 milestone Nov 10, 2026
abhirajadhikary06 pushed a commit to abhirajadhikary06/arcadedb-python that referenced this pull request Nov 10, 2026
…th its stack ArcadeData#328 and ArcadeData#333)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
abhirajadhikary06 pushed a commit to abhirajadhikary06/arcadedb-python that referenced this pull request Nov 10, 2026
Docstrings that still described the 26.11.1 routes after the combined PRs:
- core.py Database.insert_arrow: a vertex type no longer loads through GraphBatch (ArcadeData#335); it loads through the columnar insert like a document type.
- _native/database.py Database.insert_dataframe: batches go through the columnar insert (insert_many only when a column cannot cross as memory) for a document and a vertex type alike (ArcadeData#333, ArcadeData#335).
- dataframe.py: module docstring dropped 'No Java was added' (this candidate changes BulkOps.java and DocumentBatcher.java for this path) and lists the kinds that now cross as memory (ArcadeData#333); _insert_documents_native docstring covers vertices and the temporal, decimal and bytes columns.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
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