Files
duckdb/scripts/test_zero_initialize.py
ben fleis a92ec9673f test harness: remote temp-dir root, LOCAL_* dirs, --env-passthrough
Makes the --temp-dir-* family usable against a remote root (az://, s3://) and folds its knobs
into the standard test-config option table, so each also takes a `DUCKDB_TEST_<NAME>` env var
and a config-file key.

Local variants

`LOCAL_TEMP_DIR` is a guaranteed-local temp dir within tests; the machinery behind it is also
what lets HOME resolve locally when the temp root is remote.

- `--temp-dir-root` local  -> both local, and TEMP_DIR == LOCAL_TEMP_DIR
- `--temp-dir-root` remote -> LOCAL_ tree in the local fs (default duckdb_unittest_tempdir)
- every root can be named explicitly, but the `local-` variants only apply against a remote
  root -- against a differing local one they are an error
- DATA_DIR has the same LOCAL_ alternate (default data), for file-comparison checks

Reserved names

Every variable the runner resolves -- TEMP_DIR, DATA_DIR, the LOCAL_*/RUN_ID/TEST_* family and
the `{KEYWORD}` substitutions -- is reserved against every external input. Neither
`--env-passthrough` nor a config `test_env` entry may set one; naming a reserved variable fails
the invocation at startup. Options still configure the values (`--data-dir`, `--temp-dir-root`,
...); what is closed is reaching past the resolver to write the exposed variable, which also
removes any DUCKDB_TEST_LOCAL_* vs LOCAL_* precedence question -- one resolver, one write, no
overlay. Behaviour change: a config that previously set one of these via `test_env` now aborts.

Lifecycle

- A run reclaims only what it created, at every level -- the run root, the per-test leaf and the
  HOME sandbox. A level merely adopted (a reused `--run-id`, or `$ROOT` under
  `--temp-dir-run-id off`) is retained. Removal is recursive, so otherwise a successful run
  deletes a caller-supplied root outright, or a co-located batch's live tree.
- Co-location is therefore safe only when the driver creates the shared run root itself; a
  batch that created it still reaps it.
- Dropped `--temp-dir-create`. Nothing consumed it, and `never` never cohered: it could not
  create the [TEST_ID] leaf, could not be satisfied under a generated run-id, and still deleted
  the root it refused to create. Creation is unconditional mkdir -p; the created-levels
  bookkeeping is the property that actually mattered.
- A remote root is passthrough only -- unittest holds no credentials, so it never creates or
  reaps one.

`--env-passthrough NAME` pre-registers a caller's env var for `{NAME}` substitution in every
test, the treatment `TEMP_DIR`/`DATA_DIR` already get. A named-but-absent var fails the whole
invocation at startup, unlike `require-env`'s per-test skip.

Test events `begin` and `end` carry the temp dir, so a consumer identifies the emitting
invocation directly instead of inferring it from the subprocess it launched. It is snapshotted
at `begin`, so a body that rewrites `TEMP_DIR` cannot make the two disagree.

Renames

`--temp-dir-base` -> `--temp-dir-root`, `TEMP_DIR_BASE` -> `TEMP_DIR_ROOT`, and the `local-`
variants alongside. `root` is what the design docs already called this. Skipping an old alias
since UC is only consumer, and will migrate forward on its own.

Fixes

- `TEMP_DIR_ABSOLUTE` was wrong against a remote root, joining a scheme'd URI onto the cwd.
- HOME derived from `$ROOT` and hit the same join; it resolves against the local tree now,
  rather than crashing or leaking to the ambient `$HOME`.
- Two tests built absolute paths by prefixing the cwd onto `TestDirectoryPath()`, which breaks
  once the root itself is absolute -- newly reachable now that the root is env-settable.
- `PrepareTempDir` reports a failed mkdir/rmdir through its error out-param instead of letting
  the exception terminate the process.
- Roots are normalized when parsed (`.`/`..` fold, `TEMP_DIR_ROOT` reports the resolved form);
  an empty root is rejected.

Notes

- `TEMP_DIR_ROOT` can come from the ambient environment, e.g. to default to `/tmp/duckdb/`
  instead of `./duckdb_unittest_tempdir`.
- test/py/test_temp_dir_contract.py covers what no .test file can observe: what survives on
  disk after a run, and what a run may create on an error path. Standalone for now.
2026-07-31 15:07:39 +02:00

179 lines
6.6 KiB
Python

import os
import argparse
import subprocess
import shutil
parser = argparse.ArgumentParser(
description='''Runs storage tests both with explicit one-initialization and with explicit zero-initialization, and verifies that the final storage files are the same.
The purpose of this is to verify all memory is correctly initialized before writing to disk - which prevents leaking of in-memory data in storage files by writing uninitialized memory to disk.'''
)
parser.add_argument('--unittest', default='build/debug/test/unittest', help='path to unittest', dest='unittest')
parser.add_argument(
'--zero_init_dir',
default='test_zero_init_db',
help='directory to write zero-initialized databases to',
dest='zero_init_dir',
)
parser.add_argument(
'--standard_dir', default='test_standard_db', help='directory to write regular databases to', dest='standard_dir'
)
args = parser.parse_args()
test_list = [
'test/sql/index/art/storage/test_art_checkpoint.test',
'test/sql/storage/compression/simple_compression.test',
'test/sql/storage/delete/test_store_deletes.test',
'test/sql/storage/mix/test_update_delete_string.test',
'test/sql/storage/nested/struct_of_lists_unaligned.test',
'test/sql/storage/test_store_integers.test',
'test/sql/storage/test_store_nulls_strings.test',
'test/sql/storage/update/test_store_null_updates.test',
]
def run_test(args):
res = subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
stdout = res.stdout.decode('utf8').strip()
stderr = res.stderr.decode('utf8').strip()
if res.returncode != 0:
print("Failed to run test!")
print("----------COMMAND-----------")
print(' '.join(args))
print("----------STDOUT-----------")
print(stdout)
print("----------STDERR-----------")
print(stderr)
print("---------------------")
exit(1)
header_size = 4096 * 3
block_size = 262144
checksum_size = 8
def handle_error(i, standard_db, zero_init_db, standard_data, zero_data):
print("------------------------------------------------------------------")
print(f"FAIL - Mismatch between one-initialized and zero-initialized databases at byte position {i}")
print("------------------------------------------------------------------")
print(f"One-initialized database {standard_db} - byte value {standard_data}")
print(f"Zero-initialized database {zero_init_db} - byte value {zero_data}")
if i < header_size:
print("This byte is in the initial headers of the file")
else:
byte_pos = (i - header_size) % block_size
if byte_pos >= checksum_size:
print(
f"This byte is in block id {(i - header_size) // block_size} at byte position {byte_pos - checksum_size} (position {byte_pos} including the block checksum)"
)
else:
print(f"This byte is in block id {(i - header_size) // block_size} at byte position {byte_pos}")
print("This is in the checksum part of the block")
print("------------------------------------------------------------------")
print(
"This error likely means that memory was not correctly zero-initialized in a block before being written out to disk."
)
def compare_database(standard_db, zero_init_db):
with open(standard_db, 'rb') as f:
standard_data = f.read()
with open(zero_init_db, 'rb') as f:
zero_data = f.read()
if len(standard_data) != len(zero_data):
print(
f"FAIL - Length mismatch between database {standard_db} ({str(len(standard_data))}) and {zero_init_db} ({str(len(zero_data))})"
)
return False
found_error = None
for i in range(len(standard_data)):
if standard_data[i] != zero_data[i]:
if i > header_size:
byte_pos = (i - header_size) % block_size
if byte_pos <= 8:
# different checksum, skip because it does not tell us anything!
if found_error is None:
found_error = i
continue
handle_error(i, standard_db, zero_init_db, standard_data[i], zero_data[i])
return False
if found_error is not None:
i = found_error
handle_error(i, standard_db, zero_init_db, standard_data[i], zero_data[i])
return False
print("Success!")
return True
def compare_files(standard_dir, zero_init_dir):
standard_list = os.listdir(standard_dir)
zero_init_list = os.listdir(zero_init_dir)
standard_list.sort()
zero_init_list.sort()
if standard_list != zero_init_list:
print(
f"FAIL - Directories contain mismatching files (standard - {str(standard_list)}, zero init - {str(zero_init_list)})"
)
return False
if len(standard_list) == 0:
print("FAIL - Directory is empty!")
return False
success = True
for entry in standard_list:
if not compare_database(os.path.join(standard_dir, entry), os.path.join(zero_init_dir, entry)):
success = False
return success
def clear_directories(directories):
for dir in directories:
try:
shutil.rmtree(dir)
except FileNotFoundError as e:
pass
test_dirs = [args.standard_dir, args.zero_init_dir]
success = True
for test in test_list:
print(f"Running test {test}")
clear_directories(test_dirs)
# TEMP_DIR must BE the given dir exactly and survive for the post-run byte diff below,
# so pin the root with both levels off and destroy=never (the dirs are pre-rmtree'd
# above, so the default create=on-absent is fine). --database-destroy off keeps the
# generated DB files (otherwise the harness deletes loaded DBs under TEMP_DIR, leaving
# the retained dir empty and the compare with nothing to diff).
temp_dir_flags = lambda d: [
'--temp-dir-root',
d,
'--temp-dir-run-id',
'off',
'--temp-dir-test-id',
'off',
'--temp-dir-destroy',
'never',
'--database-destroy',
'off',
]
standard_args = [args.unittest, *temp_dir_flags(args.standard_dir), '--one-initialize', '--single-threaded', test]
zero_init_args = [
args.unittest,
*temp_dir_flags(args.zero_init_dir),
'--zero-initialize',
'--single-threaded',
test,
]
print(f"Running test in one-initialize mode")
run_test(standard_args)
print(f"Running test in zero-initialize mode")
run_test(zero_init_args)
if not compare_files(args.standard_dir, args.zero_init_dir):
success = False
clear_directories(test_dirs)
if not success:
exit(1)