Skip to content

Commit a3c2123

Browse files
committed
Stop two tests failing for reasons that are not about this client
Both have been red on main since the Rust 1.98 build change, which changed no code and is a coincidence of timing rather than a cause. The first is the appender against INSERT. It wants inserting to take more than five times what appending takes, which is the number that says the appender is still batching rather than doing a commit per row. It read three on a hosted runner and failed, and it fails on some jobs of a run and not others, which is what a threshold sitting in the middle of the measurement looks like. The row count was the problem. What an INSERT costs is what a commit costs, and that is a property of the disk rather than of the engine: 24 ms a row on a server with a real disk under contention, 80 us a row on a hosted runner where the commit is plainly not reaching a platter. Three hundred times apart from the same build. Two hundred rows is therefore four seconds of INSERT on the first machine and sixteen milliseconds on the second, and sixteen milliseconds is not enough work for the appender's one commit to have amortised, so the second machine was measuring what the appender costs to start rather than what it costs to run. So the row count is worked out on the machine instead of written down. Forty INSERTs say what one costs here, and the count is whatever spends about a second and a half on the INSERT side, floored at two hundred so the appender always has rows to amortise over and capped at forty thousand for memory. Measured on a real disk the ratio is 44 at two hundred rows, 81 at a thousand and 659 at five thousand, so the gate stays at five: the number that says the appender is batching, not the number any machine hits. The second is the test that counts how far the main thread gets while another one is inside the engine, which is how the released GIL is checked. It reads no clock, so it was not marked timing, so the sanitizer job runs it, and valgrind runs one thread at a time by design. The count is zero there whatever the binding does, and the test reports a held GIL that is not held. There is nothing to fix in the binding for that, so it takes the timing marker like the tests that read a clock, and the marker's description now covers both.
1 parent a8a8330 commit a3c2123

3 files changed

Lines changed: 78 additions & 21 deletions

File tree

pyproject.toml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,16 @@ pythonpath = ["tools", "."]
8989
# slower by a factor nobody controls and the two sides of a ratio
9090
# slower by different ones. The jobs that run under one deselect them
9191
# by this marker and the ordinary jobs run them all.
92-
markers = ["timing: asserts on elapsed time, so a sanitizer run has to skip it"]
92+
#
93+
# The marker covers one thing that reads no clock at all: counting how
94+
# far the main thread gets while another one is inside the engine, which
95+
# is how the GIL is checked to be released. Valgrind runs one thread at
96+
# a time, so that count is zero there no matter what the binding does.
97+
# It is the same problem for the same reason and it wants the same
98+
# marker.
99+
markers = [
100+
"timing: asserts on how fast or how concurrently something ran, so a sanitizer run has to skip it"
101+
]
93102

94103
[tool.ruff]
95104
target-version = "py311"

tests/test_appender.py

Lines changed: 59 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -425,41 +425,80 @@ def run() -> None:
425425
assert ticks > 1000, f"the main thread only got {ticks} turns"
426426

427427

428-
#: Rows for the comparison against `INSERT`, and few enough that the
429-
#: `INSERT` half finishes in a few seconds. It is the slow half by three
430-
#: orders of magnitude, and it gets slower as the table grows, because
431-
#: every row of it is a commit and a fold.
432-
COMPARED = 200
428+
#: What the `INSERT` half of the comparison is allowed to spend, and
429+
#: the row count is worked out from it rather than written down. A fixed
430+
#: row count cannot serve both kinds of machine this runs on, because
431+
#: what an `INSERT` costs is what a commit costs and that is a property
432+
#: of the disk and not of the engine. Measured per row: 24 ms on a
433+
#: server with a real disk under contention, 80 us on a hosted CI runner
434+
#: where a commit is not reaching a platter at all. Three hundred times
435+
#: apart, from the same engine. Two hundred rows is four seconds on the
436+
#: first and sixteen milliseconds on the second.
437+
BUDGET = 1.5
438+
439+
#: How many `INSERT`s are timed to learn what one costs here. Enough to
440+
#: average over, few enough to be quick on the slow machine, where this
441+
#: alone is a second.
442+
CALIBRATE = 40
443+
444+
#: The row count never goes outside this, whatever the calibration says.
445+
#: The floor is where the appender has enough rows to have amortised the
446+
#: one commit it does. The ceiling is memory and patience.
447+
FEWEST = 200
448+
MOST = 40_000
449+
450+
451+
def _rows(count: int) -> list[tuple[int, str]]:
452+
return [(uid, f"p{uid}") for uid in range(1, count)]
453+
454+
455+
def _time_inserting(conn: zudb.Connection, rows: list[tuple[int, str]]) -> float:
456+
started = time.perf_counter()
457+
for uid, name in rows:
458+
conn.execute("INSERT (p:person {uid: $u, name: $n})", {"u": uid, "n": name})
459+
return time.perf_counter() - started
433460

434461

435462
@pytest.mark.timing
436463
def test_appending_beats_inserting_by_the_margin_that_makes_it_worth_having(
437464
tmp_path: Path,
438465
) -> None:
439-
rows = [(uid, f"p{uid}") for uid in range(1, COMPARED)]
466+
# What one INSERT costs here, asked rather than assumed.
467+
with zudb.connect(tmp_path / "calibration.zu1") as conn:
468+
conn.execute("INSERT (p:person {uid: 0, name: 'seed'})")
469+
each = _time_inserting(conn, _rows(CALIBRATE)) / (CALIBRATE - 1)
470+
471+
compared = min(MOST, max(FEWEST, int(BUDGET / each)))
472+
rows = _rows(compared)
440473

441474
with zudb.connect(tmp_path / "inserted.zu1") as conn:
442475
conn.execute("INSERT (p:person {uid: 0, name: 'seed'})")
443-
started = time.perf_counter()
444-
for uid, name in rows:
445-
conn.execute("INSERT (p:person {uid: $u, name: $n})", {"u": uid, "n": name})
446-
inserting = time.perf_counter() - started
476+
inserting = _time_inserting(conn, rows)
447477

448478
with zudb.connect(tmp_path / "appended.zu1") as conn:
449479
conn.execute("INSERT (p:person {uid: 0, name: 'seed'})")
450480
started = time.perf_counter()
451481
with conn.appender("person") as app:
452482
app.append_rows(rows)
453483
appending = time.perf_counter() - started
454-
assert conn.execute("MATCH (p:person) RETURN count(p) AS n").fetchone() == (COMPARED,)
455-
456-
# Measured at about 150 times on this machine at this row count and
457-
# rising with it, since one commit is one commit however many rows
458-
# it carries. It is 18 on a shared CI runner, where a commit costs
459-
# 25 ms of somebody else's disk and the appender's single one is
460-
# most of what it spends, so the gate is 5: the number that says the
461-
# appender is still batching rather than the number either machine
462-
# hits.
484+
assert conn.execute("MATCH (p:person) RETURN count(p) AS n").fetchone() == (compared,)
485+
486+
# The appender does one commit however many rows it carries, so its
487+
# cost is nearly all fixed and the ratio grows with the row count.
488+
# That is the property being tested and it is why the row count is
489+
# chosen instead of fixed: at two hundred rows on a machine where a
490+
# commit is free, the appender is measured almost entirely on what
491+
# it costs to start, the ratio sits around three, and it passes or
492+
# fails on which way the runner was leaning that morning. It did
493+
# both. Spending the same wall clock on the INSERT side everywhere
494+
# puts enough rows on the appender for the answer to be about the
495+
# engine.
496+
#
497+
# Measured at 44 with 200 rows, 81 with 1000 and 659 with 5000 on a
498+
# server with a real disk. The gate is 5 because it is the number
499+
# that says the appender is still batching, not the number any
500+
# machine hits.
463501
assert inserting > 5 * appending, (
464-
f"{COMPARED} rows: {inserting * 1000:.0f} ms inserted, {appending * 1000:.0f} ms appended"
502+
f"{compared} rows at {each * 1e6:.0f} us an INSERT: "
503+
f"{inserting * 1000:.0f} ms inserted, {appending * 1000:.0f} ms appended"
465504
)

tests/test_threads.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import threading
1313
from pathlib import Path
1414

15+
import pytest
1516
import zudb
1617

1718
# Every pair of people, filtered, which is a statement that runs for
@@ -62,6 +63,14 @@ def run() -> None:
6263
assert answers == [("ada",)] * 4
6364

6465

66+
# Marked timing not because it reads a clock but because it counts how
67+
# far the main thread got while another one worked, and that number is
68+
# only meaningful on a real scheduler. Valgrind runs one thread at a
69+
# time by design, so the loop below gets zero turns there and the test
70+
# reports a held GIL that is not held. There is nothing to fix in the
71+
# binding for that, so the sanitizer run skips it the same way it skips
72+
# the ones that read a clock.
73+
@pytest.mark.timing
6574
def test_python_keeps_running_while_a_statement_does(crowd: zudb.Connection) -> None:
6675
ticks = 0
6776
done = threading.Event()

0 commit comments

Comments
 (0)