Skip to content

Native insert_many: date-times, Decimals, bytes, numpy scalars and pandas NA stay on the JSON bulk path; an uncarriable value moves its own row, not the chunk - #218

Draft
tae898 wants to merge 2 commits into
mainfrom
perf/native-insert-many-tagged
Draft

tae898 wants to merge 2 commits into
mainfrom
perf/native-insert-many-tagged

Conversation

@tae898

@tae898 tae898 commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator

Closes #217

Port of humemai/arcadedb-embedded-python#532 (closed unmerged in the move) onto this repo's main. The JVM build has done the same since #82.

On the native build, _insert_chunk called the untagged json_bulk_dumps, so one datetime, date, Decimal, bytes, numpy scalar or pandas NA/NaT sent the whole chunk to INSERT ... CONTENT :row for each row (arcadedb_n_exec_many). Json.java already decodes the tagged values, so this is Python only and needs no native-image rebuild.

What changes

  • src/arcadedb/_native/database.py: _insert_chunk uses json_bulk_dumps_tagged, so those values cross inside the arcadedb_n_insert_rows text. Such a chunk loses the columnar entry (columnar.pack still refuses it) but stays on the bulk entry.
  • A row with a value the text still cannot carry (NaN or Infinity, an integer beyond 64 bits, a date-time or Decimal inside a list or dict, a non-str key, a lone surrogate, a datetime.time) goes to the statement on its own. Runs of carriable rows go to the bulk entry (columnar first, then the JSON text), runs of uncarriable rows go to arcadedb_n_exec_many in one crossing, in the order given.
  • insert_many(parallel=True) already wrote every type as tagged JSON and is unchanged.
  • tests/test_native_columnar_insert.py: the datetime case is split in two. A datetime now stays on the JSON entry, and the old assertion (statement path, one crossing, no INSERT_ROWS) uses a datetime inside a list.

Tests

tests/test_native_mixed_chunk.py (native backend only, in collect_ignore elsewhere) is the parity test: it stores 17 value kinds (naive and UTC+9 datetime, whole-second datetime, date, three Decimals, bytes, bytearray, np.int64/int8/uint32/float64/float32/bool_, None, a plain int) through the statement path (INSERT ... CONTENT :row, what the chunk used to take) and through insert_many, in declared types and schemaless, and compares each stored value and its Python type. A spy on lib.call proves the entry (arcadedb_n_insert_rows once, arcadedb_n_exec_many never). It also covers pandas.Timestamp (nanoseconds, aware), pandas.NA/NaT and np.datetime64 (null / naive datetime, compared with the same rows holding None), a 10,000-row chunk with one datetime, one uncarriable row among 1,000 (one statement run, two bulk calls, stored rows equal the statement path's), insert order around statement runs, commit_every, rollback, and a user map keyed by the tag marker.

Not run locally: the tests need a native wheel, so CI is the check. Locally: black, isort and the repo pre-commit hooks pass, compile, pyflakes (only three unused-import notes that were already in database.py) and Bandit are clean, and a stub of _insert_chunk confirmed the run order (bulk, statement, bulk, ...) for five scattered uncarriable rows.

This PR is a draft: a timed A/B on the main CI wheel with only these source files overlaid comes before any speed claim. The untimed 1M-row figure in #217 is a hypothesis.

🤖 Generated with Claude Code

tae898 and others added 2 commits November 4, 2026 00:08
…as NA stays on the JSON bulk entry; an uncarriable value moves its own row, not the chunk

Port of humemai/arcadedb-embedded-python#532, which was closed unmerged in the move to this repo. The JVM build has done this since #82.

_insert_chunk called the untagged json_bulk_dumps, which raises on a datetime, date, Decimal, bytes, numpy scalar, pandas NA or NaT, so the whole 10,000-row chunk went through INSERT INTO t CONTENT :row for each row (arcadedb_n_exec_many). Json.java already decodes the tagged values, so the rows now cross inside the arcadedb_n_insert_rows text via json_bulk_dumps_tagged. A row with a value the text still cannot carry (NaN or Infinity, an integer beyond 64 bits, a date-time or Decimal inside a list or dict, a non-str key, a lone surrogate, a datetime.time) goes to the statement on its own; runs of carriable rows keep the bulk entry (columnar first), and insert order is kept. Python only, no native-image change.

tests/test_native_mixed_chunk.py stores 17 value kinds through the statement path and through insert_many, declared and schemaless, and compares the stored value and its Python type; a spy on lib.call proves the entry. test_native_columnar_insert.py's datetime case is split in two: a datetime stays on the JSON entry, a datetime inside a list is still a statement run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…insert_many bulk path now, so both builds assert the same stored values

test_insert_many_mixed_values.py asserted that the native build refuses pd.NA, pd.NaT and np.datetime64 with a TypeError, so that the two tests would turn red the day native accepted them. With json_bulk_dumps_tagged on the native path they do (null, null, naive datetime), as the JVM build stores them. Both tests now run the JVM assertions on both builds. Found by the dispatched native parity run 41660808672 (passed=2015 failed=2, exactly these two).

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

tae898 commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator Author

Timed A/B of this PR (native build; arm A = main's CI native wheel at 7e64c5e, arm B = the same tree with only _native/database.py from this PR at ab207e6, A/A control; laptop timed lane, ab.py --noise, ABBA pairs; 300,000 rows with a DATETIME per row, the rule written before the run):

figure median B/A 95% CI clean pairs
load_s (gating: whole timed load) 0.973 [0.972, 0.977] 11
insert_s (inside insert_many) 0.959 [0.957, 0.962] 11
peak_rss_mib (gating) 1.009 [1.009, 1.010] 11
ctl_load_s (control: same rows without the date-time column) 0.971 [0.969, 0.974] 11
A/A load_s (control arm, main again) 0.997 [0.997, 1.000] 11

By the study's rule (ship as a speed change only if the upper end of the load_s interval is below 0.90 and peak RSS stays within 1.10), this is do not ship as a speed change: the load is a few percent faster, not the large win the untimed #532 numbers suggested, and the control without date-times moved by about as much, so the gain is not specific to the date-time path. Nothing got slower, and peak RSS is within the bar.

What the PR still does is parity: on the native build insert_many now takes date-times, Decimals, bytes, numpy scalars and pandas NA/NaT through the bulk entry and stores what the JVM build stores (the stored-value differential of the study passed on all 17 kinds; the parity tests that expected a TypeError on native now run the JVM assertions on both builds). So the proposal is to merge it as a parity fix, with no speed claim; it will merge after the other timed A/Bs that pin _native/database.py have run, so their pins stay valid.

This branch has not been deployed

No deployments
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.

Native insert_many: date-times, Decimals, bytes, numpy scalars and pandas NA send the whole chunk through INSERT ... CONTENT per row

1 participant