mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-11 17:10:40 +02:00
* lance worker: share the integration tests' scaffolding The recorder that keeps what a handler sent, the config builder and the storage-option fallback all lived inside compaction.rs, so a second test binary would have had to copy them. They move to tests/common. The fallback now reads AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_ENDPOINT_URL from the environment, defaulting to what it used before. A harness can then point these tests at a gateway that checks what it is given rather than one that accepts anything. * lance worker: maintain one named table, for a harness to drive Compacts and cleans up whatever WEED_LANCE_TABLE names, through the handlers' own detect-then-execute path: a proposal the worker would not have made is not one worth running. The existing tests seed the tables they check. This one deliberately does not, so a harness that has already written a table and knows what is in it can have the real handlers maintain it and then read it back. * test: take a table through its whole life, for Iceberg and Lance Created in the catalog, filled by a real client, maintained by the worker, read again, dropped. The step nothing was checking is the read after maintenance: compaction once rewrote every dictionary-encoded column onto a single value and shipped, because the maintenance tests were thorough about sequence numbers, manifest entries and metadata versions and none of them opened the parquet file the worker had just written. So the assertion is a tally - row count, the cardinality of each dictionary-encoded column, and an md5 over whole rows - taken before maintenance and again after, required to be equal. The cardinalities name the failure that happened; the digest catches a rewrite that keeps every column's cardinality and hands the values to the wrong rows. A compaction that merged nothing fails rather than passes, or the read afterwards is checking a file the worker never wrote. The Iceberg half runs two clients. DuckDB is the one the corruption was reported against and the only one here that writes the deprecated PLAIN_DICTIONARY encoding, which parquet-go normalizes away on write, so a Go writer cannot produce it. PyIceberg writes the modern spelling. Pinning parquet-go back to v0.30.1 fails the DuckDB half and passes the PyIceberg one, which is why both are here. Lance maintenance lives in the Rust worker, so it runs there where cargo is installed and through the two lance calls those handlers wrap where it is not. WEED_LANCE_MAINTENANCE picks one instead of letting the test guess. * ci: run the table lifecycle tests CI maintains the Lance table through the lance library rather than the worker: a cold build of the lance crate costs more than the glue it would be checking, and the worker's own tests cover its handlers. The suite drives the Iceberg maintenance worker, so a change to it now triggers this workflow too. * test: let the lifecycle harness fail instead of skipping Setup failures all exited zero, so a cluster that would not come up, or a port allocation that lost, reported a green run for code nothing had executed. That is the failure mode this whole directory exists to close, and it was in the harness itself. Only a checkout without a weed binary skips now, and it runs the tests so each one says so rather than the package quietly passing. Everything else fails. The filer existence probe gets a deadline while I am here: it ran without one, so an unresponsive filer would hang the suite past every timeout the clients have. * test: make the lifecycle checks check what they claim to Three of them could pass without having looked. The DuckDB skip matched "syntax error", "not implemented" and "Failed to load" anywhere in the output, in any phase. A parse error in the SQL this test generates, or a refusal from our own catalog, would have taken the only coverage of the PLAIN_DICTIONARY encoding out of CI and left it green. It now matches the extension failing to install, and only in the phase that installs it. Everything past LOAD is ours and fails. The digests covered id, category and value. Compaction rewrites the whole row, so a defect confined to ts, or to a Lance vector, changed nothing either side of maintenance. Every persisted column goes in now, ts as microseconds so no timezone sits between the two runs. The Lance drop check caught every exception as proof the dataset was gone. pylance turns credential and transport failures into the same ValueError, so it only accepts the message that means not found. * docs: say up front which maintenance path the Lance half takes The opening summary said the worker maintains both tables. It maintains the Iceberg one always and the Lance one only where cargo is installed, which is not what CI does.
169 lines
6.6 KiB
Python
169 lines
6.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Lance half of the table lifecycle, run one phase per invocation.
|
|
|
|
The Go test calls this for each step and maintains the table in between, so the
|
|
tally taken before maintenance and the tally taken after are directly
|
|
comparable. That comparison is the test: the Iceberg side of this suite exists
|
|
because compaction once rewrote a table's dictionary columns onto a single
|
|
value and every check we had still passed, and a Lance dataset is rewritten by
|
|
the same kind of job.
|
|
|
|
The maintain phase is a fallback. When the Rust worker can be built, the Go test
|
|
runs its handlers against this table instead and skips this phase - what runs
|
|
here are the same two lance calls the handlers make.
|
|
"""
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import sys
|
|
import warnings
|
|
from datetime import timedelta
|
|
|
|
warnings.filterwarnings("ignore")
|
|
|
|
import lance
|
|
import lance_namespace as ln
|
|
import pyarrow as pa
|
|
|
|
# Low enough cardinality that these two columns are dictionary-encoded.
|
|
CATEGORIES = 7
|
|
VALUES = 13
|
|
ROWS_PER_BATCH = 4000
|
|
BATCHES = 3
|
|
DIM = 8
|
|
|
|
|
|
def rows(start, count):
|
|
ids = list(range(start, start + count))
|
|
return pa.table(
|
|
{
|
|
"id": pa.array(ids, type=pa.int64()),
|
|
"category": pa.array([f"cat-{i % CATEGORIES}" for i in ids]),
|
|
"value": pa.array([f"v-{i % VALUES}" for i in ids]),
|
|
"vector": pa.array(
|
|
[[float(i % 97) + d for d in range(DIM)] for i in ids],
|
|
type=pa.list_(pa.float32(), DIM),
|
|
),
|
|
}
|
|
)
|
|
|
|
|
|
def tally(dataset):
|
|
"""What the table holds, in a form two runs can be compared by.
|
|
|
|
The cardinalities catch a column collapsed onto one value; the digest
|
|
catches a rewrite that keeps the values and moves them to the wrong rows.
|
|
Every column goes into the digest, the vectors included - compaction
|
|
rewrites whole fragments, so leaving a column out leaves a place for it to
|
|
go wrong unnoticed. Fragments come along because a compaction that merged
|
|
nothing would otherwise let this test pass without having tested anything.
|
|
"""
|
|
scanned = dataset.to_table(columns=["id", "category", "value", "vector"])
|
|
ids = scanned.column("id").to_pylist()
|
|
categories = scanned.column("category").to_pylist()
|
|
values = scanned.column("value").to_pylist()
|
|
vectors = scanned.column("vector").to_pylist()
|
|
serialized = (
|
|
f"{i}|{c}|{v}|{w}"
|
|
for i, c, v, w in zip(ids, categories, values, vectors, strict=True)
|
|
)
|
|
digest = hashlib.md5(
|
|
"\n".join(sorted(serialized)).encode(), usedforsecurity=False
|
|
).hexdigest()
|
|
return {
|
|
"rows": scanned.num_rows,
|
|
"categories": len(set(categories)),
|
|
"values": len(set(values)),
|
|
"digest": digest,
|
|
"fragments": len(dataset.get_fragments()),
|
|
"version": dataset.version,
|
|
}
|
|
|
|
|
|
def resolve(args):
|
|
"""Ask the namespace where the table lives, the way the worker does."""
|
|
namespace = ln.connect("rest", {"uri": args.namespace_url})
|
|
table_id = [args.bucket, args.namespace, args.table]
|
|
described = namespace.describe_table(ln.DescribeTableRequest(id=table_id))
|
|
return namespace, table_id, described.location, storage_options(args, described)
|
|
|
|
|
|
def storage_options(args, described=None):
|
|
# The namespace vends an endpoint correct for its own host; this container
|
|
# reaches the same gateway by another name. Credentials are filled in
|
|
# because a deployment without STS vends none.
|
|
options = dict((described.storage_options or {}) if described else {})
|
|
options["aws_endpoint"] = args.s3_endpoint
|
|
options["allow_http"] = "true"
|
|
options.setdefault("aws_access_key_id", args.access_key)
|
|
options.setdefault("aws_secret_access_key", args.secret_key)
|
|
options.setdefault("aws_region", "us-east-1")
|
|
return options
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--phase", required=True, choices=["write", "maintain", "verify", "drop"])
|
|
parser.add_argument("--namespace-url", required=True)
|
|
parser.add_argument("--s3-endpoint", required=True)
|
|
parser.add_argument("--bucket", required=True)
|
|
parser.add_argument("--namespace", required=True)
|
|
parser.add_argument("--table", required=True)
|
|
parser.add_argument("--access-key", default="any")
|
|
parser.add_argument("--secret-key", default="any")
|
|
args = parser.parse_args()
|
|
|
|
if args.phase == "write":
|
|
namespace = ln.connect("rest", {"uri": args.namespace_url})
|
|
for parent in ([args.bucket], [args.bucket, args.namespace]):
|
|
namespace.create_namespace(ln.CreateNamespaceRequest(id=parent, mode="EXIST_OK"))
|
|
table_id = [args.bucket, args.namespace, args.table]
|
|
declared = namespace.declare_table(ln.DeclareTableRequest(id=table_id))
|
|
options = storage_options(args)
|
|
# A fragment per append, so the compaction that follows has something
|
|
# to merge.
|
|
for i in range(BATCHES):
|
|
lance.write_dataset(
|
|
rows(i * ROWS_PER_BATCH + 1, ROWS_PER_BATCH),
|
|
declared.location,
|
|
storage_options=options,
|
|
mode="overwrite" if i == 0 else "append",
|
|
)
|
|
print(json.dumps(tally(lance.dataset(declared.location, storage_options=options))))
|
|
return 0
|
|
|
|
if args.phase == "maintain":
|
|
_, _, location, options = resolve(args)
|
|
dataset = lance.dataset(location, storage_options=options)
|
|
dataset.optimize.compact_files()
|
|
dataset = lance.dataset(location, storage_options=options)
|
|
dataset.cleanup_old_versions(older_than=timedelta(seconds=0), delete_unverified=True)
|
|
print(json.dumps(tally(lance.dataset(location, storage_options=options))))
|
|
return 0
|
|
|
|
if args.phase == "verify":
|
|
_, _, location, options = resolve(args)
|
|
print(json.dumps(tally(lance.dataset(location, storage_options=options))))
|
|
return 0
|
|
|
|
namespace, table_id, location, options = resolve(args)
|
|
namespace.drop_table(ln.DropTableRequest(id=table_id))
|
|
try:
|
|
lance.dataset(location, storage_options=options)
|
|
except ValueError as err:
|
|
# pylance turns every load failure into a ValueError, so the message is
|
|
# the only thing separating a dataset that is gone from credentials
|
|
# that stopped working halfway through the test.
|
|
if "was not found" in str(err):
|
|
return 0
|
|
print(f"FAIL: reading the dropped dataset failed for another reason: {err}",
|
|
file=sys.stderr)
|
|
return 1
|
|
print("FAIL: the dataset is still readable after a drop", file=sys.stderr)
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|