Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion docs/modules/ROOT/pages/how-to/bulk-loads.adoc
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
= Bulk loads and commit size
:description: The default commit size of insert_many, graph_batch and create_vertices on the arcadedb (native) and arcadedb-jvm packages, what it means for a failed load, what to do after an interrupted graph batch, how to change it, how insert_many treats Python's cyclic garbage collector on arcadedb-jvm, which property shapes a graph batch loads in bulk on the JVM build, and a known limitation: a graph batch does not reuse the space of deleted edges.
:description: The default commit size of insert_many, graph_batch and create_vertices on the arcadedb (native) and arcadedb-jvm packages, what it means for a failed load, what to do after an interrupted graph batch, how to change it, how insert_many treats Python's cyclic garbage collector on arcadedb-jvm, which property shapes a graph batch loads in bulk on the JVM build, how to pass time-series columns to append_samples on arcadedb (native), and a known limitation: a graph batch does not reuse the space of deleted edges.

`insert_many`, `graph_batch` and `GraphBatch.create_vertices` load data in transactions. The commit size is the number of rows in one transaction. It decides how much work the engine holds in memory before it commits, and how many rows stay in the database when a load fails.

Expand Down Expand Up @@ -111,6 +111,22 @@ The table is one laptop study of the `arcadedb-jvm` package: per-row time of thi

|===

== Time-series samples with `append_samples` (`arcadedb`, native)

`db.async_executor().append_samples(type_name, timestamps, *columns)` loads a columnar batch into a TIMESERIES type: the timestamps, then one column for each tag and field in the order of the type's declaration. Call `wait_completion()` before you read the samples back. How to declare the type is in the main ArcadeDB docs: see {arcadedb-docs-url}/concepts/timeseries[Time series].

On the `arcadedb` (native) package, a NumPy column crosses to the engine by its address, with no JSON text, when it is one of these:

* The timestamps: a one-dimensional, non-empty integer or bool array (`int64` is the plain case), or a `datetime64[ns]` array without `NaT`.
* A numeric column: a one-dimensional, non-empty array of any integer or bool kind, or of a float kind up to `float64` (`float32` widens to `float64` without a change of value). A `uint64` array, as timestamps or as a column, crosses only if its largest value fits in `int64`.
* Each column must have as many values as there are timestamps.

Everything else keeps the text path, which stores the same values and only takes longer: a list or a tuple, a subclass of `ndarray`, a two-dimensional or empty array, a float array as the timestamps, a `datetime64` array of another unit or with `NaT`, a `long double` array, and a string or tag column. A call can mix both: the numeric columns cross by address and the tag column goes as text. To get the fast path, build the timestamps and the numeric columns as NumPy arrays with these dtypes and pass the arrays as they are, without `.tolist()`. An array that is not already contiguous `int64` or `float64` is converted first, which copies it in NumPy.

The measurement is a laptop A/B on the `arcadedb` (native) wheel, one fresh process per run, Python 3.12 and NumPy 2.5.4. The workload loaded 2,000,000 points of 100 hosts in chunks of 100,000: an `int64` timestamp array, one list of host names (the tag), and three `float64` arrays, with one `wait_completion()` at the end. The figure is the whole ingest time in seconds, including that wait, as the ratio of the build with this change to the build without it (below 1 means faster): 0.370 (95% interval 0.362 to 0.390, median of 11 clean paired runs). A document insert in the same runs, which does not use this path, took 1.020 (95% interval 1.016 to 1.022) of the time on the build with this change. The figure holds for this shape: your gain depends on how many numeric columns a call carries and how large the chunks are.

`ARCADEDB_NATIVE_TS_BIN=0` in the environment turns the address path off and returns to the text path. A native library that is older than this change falls back to the text path by itself.

== Graph batches next to deletes (fixed in ArcadeDB 26.11.1)

NOTE: *Fixed in ArcadeDB 26.11.1.* Before 26.11.1, a graph batch did not reuse the space of deleted edges (https://github.com/ArcadeData/arcadedb/issues/10746[ArcadeData/arcadedb#10746]). The engine wrote the edge records of each `graph_batch()` flush onto new pages and never looked at the free space that deletes left on older pages, so the edge bucket gained one 64 KiB page per flush while the live data stayed flat. From 26.11.1 (https://github.com/ArcadeData/arcadedb/pull/10836[ArcadeData/arcadedb#10836]) each edge goes where an ordinary insert would put it while an existing page has room, and only the rest of a flush goes onto new pages, so a large import stays as fast as before. The 26.11.1 packages ship this fix. The `26.11.1.devN` pre-releases embed engine 26.10.2 and still show the growth described below.
Expand Down
85 changes: 78 additions & 7 deletions native/src/main/java/org/humemai/nativelib/NativeExtras.java
Original file line number Diff line number Diff line change
Expand Up @@ -590,8 +590,67 @@ private static boolean isVertexType(final Database db, final String typeName) {
* cb, err} | on_ok {cb} | on_error {cb} | append_samples {type, ts, cols} | insert_parallel {type, rows} | failures {fid}.
*/
@CEntryPoint(name = "arcadedb_n_async")
@SuppressWarnings("unchecked")
public static CCharPointer async(final IsolateThread thread, final long dbh, final CCharPointer op, final CCharPointer argsJson) {
return asyncImpl(dbh, op, argsJson, 0L, 0);
}

/**
* Same as arcadedb_n_async plus an address table (pointer and count of longs, TS_SLOTS per slot: data address, element count) that the
* append_samples arguments point into, as arcadedb_n_insert_colp reads its columns: "ts" and every entry of "cols" is either a JSON array
* of values, as for arcadedb_n_async, or the kind of a column in the table, "q" (int64) or "d" (float64), slot 0 being the timestamps and
* slot 1 + i the column i. "n" is the number of samples. The caller keeps every buffer alive and unchanged until the call returns.
*/
@CEntryPoint(name = "arcadedb_n_async_bin")
public static CCharPointer asyncBin(final IsolateThread thread, final long dbh, final CCharPointer op, final CCharPointer argsJson,
final CCharPointer table, final int nTable) {
return asyncImpl(dbh, op, argsJson, table.rawValue(), nTable);
}

/** Longs per slot of the address table of arcadedb_n_async_bin: the data address and the element count. */
private static final int TS_SLOTS = 2;

/** The data address of a table slot, checked to hold n elements of the kind: "q" for the timestamps, "q" or "d" for a column. */
private static long sampleAddress(final long table, final int nTable, final int slot, final Object kind, final long n, final boolean timestamps) {
final String what = timestamps ? "the timestamps" : "column " + (slot - 1);
if (!"q".equals(kind) && (timestamps || !"d".equals(kind)))
throw new IllegalArgumentException(what + " has the unknown kind " + kind);
if (n < 0 || n > Integer.MAX_VALUE)
throw new IllegalArgumentException("sample count out of range: " + n);
if (table == 0 || nTable < TS_SLOTS * (slot + 1))
throw new IllegalArgumentException(what + " is not in the address table of " + nTable + " entries");
final Pointer t = WordFactory.pointer(table);
final long address = t.readLong(WordFactory.signed(8L * TS_SLOTS * slot));
final long count = t.readLong(WordFactory.signed(8L * (TS_SLOTS * slot + 1)));
if (count != n || (n > 0 && address == 0))
throw new IllegalArgumentException(what + " has " + count + " values in the address table, " + n + " samples");
return address;
}

/** A column of the table as the boxed values the text path gives: a Long for "q", a Double for "d" (a NaN is the canonical NaN of the text). */
private static Object[] sampleColumn(final long address, final boolean doubles, final int n) {
final Pointer p = WordFactory.pointer(address);
final Object[] values = new Object[n];
for (int i = 0; i < n; i++) {
final long bits = p.readLong(WordFactory.signed(8L * i));
if (doubles) {
final double d = Double.longBitsToDouble(bits);
values[i] = d != d ? Double.NaN : d;
} else
values[i] = bits;
}
return values;
}

private static long[] sampleLongs(final long address, final int n) {
final Pointer p = WordFactory.pointer(address);
final long[] values = new long[n];
for (int i = 0; i < n; i++)
values[i] = p.readLong(WordFactory.signed(8L * i));
return values;
}

@SuppressWarnings("unchecked")
private static CCharPointer asyncImpl(final long dbh, final CCharPointer op, final CCharPointer argsJson, final long table, final int nTable) {
try {
final DatabaseInternal db = (DatabaseInternal) ArcadeNative.db(dbh);
final DatabaseAsyncExecutor ex = db.async();
Expand Down Expand Up @@ -742,14 +801,26 @@ else if (a.get("slot") instanceof Number slot)
event("scan_error", "\"rid\":\"" + rid + "\"," + errorBody(t))));
}
case "append_samples" -> {
final List<Object> ts = (List<Object>) a.get("ts");
final long[] stamps = new long[ts.size()];
for (int i = 0; i < stamps.length; i++)
stamps[i] = ((Number) ts.get(i)).longValue();
final Object tsArg = a.get("ts");
final long n = a.get("n") instanceof Number num ? num.longValue() : -1L;
final long[] stamps;
if (tsArg instanceof String kind)
stamps = sampleLongs(sampleAddress(table, nTable, 0, kind, n, true), (int) n);
else {
final List<Object> ts = (List<Object>) tsArg;
stamps = new long[ts.size()];
for (int i = 0; i < stamps.length; i++)
stamps[i] = ((Number) ts.get(i)).longValue();
}
final List<Object> cols = (List<Object>) a.get("cols");
final Object[][] columns = new Object[cols.size()][];
for (int i = 0; i < columns.length; i++)
columns[i] = ((List<Object>) cols.get(i)).toArray();
for (int i = 0; i < columns.length; i++) {
final Object col = cols.get(i);
if (col instanceof String kind)
columns[i] = sampleColumn(sampleAddress(table, nTable, i + 1, kind, n, false), "d".equals(kind), (int) n);
else
columns[i] = ((List<Object>) col).toArray();
}
ex.appendSamples((String) a.get("type"), stamps, columns);
}
case "insert_parallel" -> {
Expand Down
41 changes: 41 additions & 0 deletions src/arcadedb/_native/codec.py
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,47 @@ def encode_gb_args(args):
return text, buf, state[1]


_INT64_MAX = 2**63 - 1
_NS_DATETIME = None if _np is None else _np.dtype("datetime64[ns]")


def sample_array(values, timestamps=False):
"""The int64 or float64 array that stands for a column of ``append_samples`` in the address table of ``arcadedb_n_async_bin``, or
None when the column keeps the JSON text it has always had. It is the array whose ``.tolist()`` the text path writes, value for
value: an int, uint (uint64 only inside the int64 range) or bool array is int64, a float array narrower than long double is float64
(a float32 widens exactly), and, for the timestamps only, a ``datetime64[ns]`` array without NaT is its int64 count (what ``.tolist()``
gives for that unit). Anything else, a list, a subclass of ndarray, a 2-D or empty array, a float array as timestamps, other
``datetime64`` units (``int()`` refuses their datetimes on the text path), a ``datetime64[ns]`` of non-native byte order, returns None.
"""
if (
_np is None
or type(values) is not _np.ndarray
or values.ndim != 1
or not values.size
):
return None
dtype = values.dtype
kind = dtype.kind
if kind in "ib":
return _np.ascontiguousarray(values, dtype=_np.int64)
if kind == "u":
if dtype.itemsize == 8 and int(values.max()) > _INT64_MAX:
return None
return _np.ascontiguousarray(values, dtype=_np.int64)
if kind == "f":
if timestamps or dtype.itemsize > 8:
return None
return _np.ascontiguousarray(values, dtype=_np.float64)
if kind == "M" and timestamps and dtype == _NS_DATETIME:
counts = _np.ascontiguousarray(values.view(_np.int64))
if (
counts == _np.iinfo(_np.int64).min
).any(): # NaT: the text path raises on its None
return None
return counts
return None


def row_default(value):
"""json.dumps hook for the rows of a bulk load: a NumPy float array that is not float32 is the list it would have been
(a float64 array stored on an undeclared property keeps its doubles, as on the JPype path); the rest as :func:`_default`.
Expand Down
88 changes: 77 additions & 11 deletions src/arcadedb/_native/extras.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from ..exceptions import ArcadeDBError
from ..graph_batch import GraphBatch as _BaseGraphBatch
from . import interrupts, lib, not_yet
from .codec import _PLAIN, _default, _prep, encode_gb_args, loads
from .codec import _PLAIN, _default, _np, _prep, encode_gb_args, loads, sample_array

# -------------------------------------------------------------------------------------------------------- upcall

Expand Down Expand Up @@ -614,6 +614,23 @@ def command(
def append_samples(
self, type_name, timestamps, *column_values, primitive: bool = False
):
"""Columnar bulk append into a TIMESERIES type, the same call as on the JVM build.

Columns follow the type's declaration order (tags first, then fields); call wait_completion() before reading the samples back.

What to pass to get the fast path: NumPy arrays, not lists. A one-dimensional, non-empty int, uint (uint64 only inside the int64
range) or bool array, or a datetime64[ns] array without NaT, as the timestamps, and an int, uint, bool or float array (float32 and
float64; not long double) as a numeric column, each of the length of the timestamps, cross by address, with no JSON text (an array
that is not already contiguous int64 or float64 is converted first, which copies it). A list, a tuple, an ndarray subclass, a 2-D
or empty array, a float array as timestamps, another datetime64 unit, a string or tag column and a column of another length keep
the text path, which stores the same values. One call can mix both.
ARCADEDB_NATIVE_TS_BIN=0 turns the address path off.

Measured on this package (laptop A/B, 2,000,000 points of 100 hosts in chunks of 100,000: an int64 timestamp array, a list of host
names and three float64 arrays; whole ingest in seconds including wait_completion(), new build over old, below 1 is faster):
0.370 (95% interval 0.362 to 0.390, median of 11 clean paired runs). The figure is for that shape.
"""

def plain(values):
kind = getattr(getattr(values, "dtype", None), "kind", None)
if (
Expand All @@ -622,20 +639,69 @@ def plain(values):
values = values.astype("int64")
return values.tolist() if hasattr(values, "tolist") else list(values)

stamps = [int(t) for t in plain(timestamps)]
# numpy int and float columns (and an integer timestamp array) cross by address, the rest as the JSON text they always had
ts_array = sample_array(timestamps, True) if lib.TS_BIN else None
if ts_array is not None:
stamps, count = None, len(ts_array)
else:
stamps = [int(t) for t in plain(timestamps)]
count = len(stamps)
if primitive:
# the engine's primitive TimeSeriesBatch is a JVM-boundary optimisation (no boxing per value); here the rows cross
# as one JSON text either way, so the columns take the same path, with the primitive path's length check
# the engine's primitive TimeSeriesBatch is a JVM-boundary optimisation (no boxing per value); here the columns
# take the same path as without it, with the primitive path's length check
for index, values in enumerate(column_values):
if len(values) != len(stamps):
if len(values) != count:
raise ValueError(
f"column {index} has {len(values)} values for {len(stamps)} timestamps"
f"column {index} has {len(values)} values for {count} timestamps"
)
self._op(
"append_samples",
type=type_name,
ts=stamps,
cols=[plain(c) for c in column_values],
arrays = []
if lib.TS_BIN:
for values in column_values:
array = sample_array(values)
# a column of another length stays text: the engine pads or cuts it as it always did
arrays.append(
array if array is not None and len(array) == count else None
)
if ts_array is None and not any(a is not None for a in arrays):
self._op(
"append_samples",
type=type_name,
ts=stamps,
cols=[plain(c) for c in column_values],
)
return
# the address table: per slot (0 the timestamps, 1 + i the column i) the data address and the element count of an array that
# stays alive until the call returns; a column that is text has no entry (zeros) and its values are in the JSON
table = _np.zeros(2 * (1 + len(column_values)), dtype=_np.int64)
if ts_array is not None:
table[0], table[1] = ts_array.ctypes.data, count
cols = []
for index, (values, array) in enumerate(zip(column_values, arrays)):
if array is None:
cols.append(plain(values))
else:
table[2 * (index + 1)], table[2 * (index + 1) + 1] = (
array.ctypes.data,
count,
)
cols.append("d" if array.dtype.kind == "f" else "q")
_call(
"Async executor failed",
"arcadedb_n_async_bin",
self._db._h,
lib.b("append_samples"),
lib.b(
_enc(
{
"type": type_name,
"n": count,
"ts": "q" if stamps is None else stamps,
"cols": cols,
}
)
),
table.ctypes.data,
len(table),
)

def _edge_callback(self, callback):
Expand Down
Loading
Loading