Skip to content

Normalize nested order events without losing rejected records

Read JSONL files, validate complete orders, then use Spark SQL and DataFrame code on Sail to separate order headers from line items. Every rejected occurrence keeps its original bytes, source filename, source SHA-256 and physical line number. This makes an invalid child's effect on its whole order visible.

Run it

Follow the repository setup, then run all commands below from the repository root (sail-examples/):

uv run pipelines/normalize-order-events/main.py

The input is an authored 15-record JSONL snapshot. The program copies it into temporary input files, reads those actual files inside mapInArrow, writes typed Parquet outputs and reads them back. The fixture needs no data download, key or GPU. uv installs the pinned packages on first run. The default run prints the orders, lines and rejects tables: three clean orders, three clean line items and twelve rejected source occurrences.

For a compact count check:

uv run pipelines/normalize-order-events/main.py --json

Expected output:

{"lines": 3, "orders": 3, "rejects": 12}

For a larger local check, create independent copies with different order IDs:

uv run pipelines/normalize-order-events/main.py --copies 100 --json

This processes 1,500 physical records and returns counts of 300 orders, 300 line items and 1,200 rejects. The limit is 1,000 copies. This exercises repeated file ingestion and normalization, not production throughput or arbitrary event data.

Save your JSONL files as pipelines/normalize-order-events/orders.jsonl and pipelines/normalize-order-events/more-orders.jsonl, then run:

uv run pipelines/normalize-order-events/main.py \
  --input pipelines/normalize-order-events/orders.jsonl \
  --input pipelines/normalize-order-events/more-orders.jsonl \
  --output-dir pipelines/normalize-order-events/.data/normalized-orders \
  --json

These files must follow the supported contract below. --input cannot be combined with --copies. --output-dir retains the orders/, lines/, and rejects/ Parquet datasets under pipelines/normalize-order-events/.data/normalized-orders/ after process exit. That output directory must not already exist. Writes are not an atomic publication protocol: failure can leave partial output, so inspect it and choose a new directory when retrying. Without this option, output datasets are temporary and removed at process exit.

Output

The newest capture appears first. Older output stays here when a result changes.

Current output · Sail 0.7.3

Captured on Sail 0.7.3. Latest capture: 2026-11-05, Python 3.12.14.

orders
+----------------+----------------------------------------------------------------+-----------+------------+-----------+--------+----------+-----------+--------------------------------------------------------------------------------+--------------------------------------------------------------------------------+
|       source_id|                                                   source_sha256|line_number|    order_id|customer_id|currency|item_count|gross_minor|                                                               raw_record_base64|                                                                        raw_json|
+----------------+----------------------------------------------------------------+-----------+------------+-----------+--------+----------+-----------+--------------------------------------------------------------------------------+--------------------------------------------------------------------------------+
|orders-000.jsonl|baffe6dcc7fc6ae30fa2c561e323c1daa1de9180f5c4b1a13a5277a4ce166783|          1|ORDER-A-0000| CUSTOMER-1|     USD|         2|       3000|eyJzY2hlbWFfdmVyc2lvbiI6MSwib3JkZXJfaWQiOiJPUkRFUi1BLTAwMDAiLCJjdXN0b21lcl9pZ...|{"schema_version":1,"order_id":"ORDER-A-0000","customer_id":"CUSTOMER-1","cur...|
|orders-000.jsonl|baffe6dcc7fc6ae30fa2c561e323c1daa1de9180f5c4b1a13a5277a4ce166783|          2|ORDER-B-0000| CUSTOMER-2|     GBP|         0|          0|eyJzY2hlbWFfdmVyc2lvbiI6MSwib3JkZXJfaWQiOiJPUkRFUi1CLTAwMDAiLCJjdXN0b21lcl9pZ...|{"schema_version":1,"order_id":"ORDER-B-0000","customer_id":"CUSTOMER-2","cur...|
|orders-000.jsonl|baffe6dcc7fc6ae30fa2c561e323c1daa1de9180f5c4b1a13a5277a4ce166783|         15|ORDER-Z-0000| CUSTOMER-1|     USD|         1|          0|eyJzY2hlbWFfdmVyc2lvbiI6MSwib3JkZXJfaWQiOiJPUkRFUi1aLTAwMDAiLCJjdXN0b21lcl9pZ...|{"schema_version":1,"order_id":"ORDER-Z-0000","customer_id":"CUSTOMER-1","cur...|
+----------------+----------------------------------------------------------------+-----------+------------+-----------+--------+----------+-----------+--------------------------------------------------------------------------------+--------------------------------------------------------------------------------+

lines
+----------------+----------------------------------------------------------------+-----------+------------+-------+------+--------+----------------+-----------+
|       source_id|                                                   source_sha256|line_number|    order_id|line_id|   sku|quantity|unit_price_minor|gross_minor|
+----------------+----------------------------------------------------------------+-----------+------------+-------+------+--------+----------------+-----------+
|orders-000.jsonl|baffe6dcc7fc6ae30fa2c561e323c1daa1de9180f5c4b1a13a5277a4ce166783|          1|ORDER-A-0000|     L1|WIDGET|       2|            1250|       2500|
|orders-000.jsonl|baffe6dcc7fc6ae30fa2c561e323c1daa1de9180f5c4b1a13a5277a4ce166783|          1|ORDER-A-0000|     L2|   BAG|       1|             500|        500|
|orders-000.jsonl|baffe6dcc7fc6ae30fa2c561e323c1daa1de9180f5c4b1a13a5277a4ce166783|         15|ORDER-Z-0000|   FREE|SAMPLE|       1|               0|          0|
+----------------+----------------------------------------------------------------+-----------+------------+-------+------+--------+----------------+-----------+

rejects
+----------------+----------------------------------------------------------------+-----------+--------------------------------------------------------------------------------+--------------------------------------------------------------------------------+-----------------------------------+
|       source_id|                                                   source_sha256|line_number|                                                               raw_record_base64|                                                                        raw_json|                            reasons|
+----------------+----------------------------------------------------------------+-----------+--------------------------------------------------------------------------------+--------------------------------------------------------------------------------+-----------------------------------+
|orders-000.jsonl|baffe6dcc7fc6ae30fa2c561e323c1daa1de9180f5c4b1a13a5277a4ce166783|          3|eyJzY2hlbWFfdmVyc2lvbiI6MSwib3JkZXJfaWQiOiJPUkRFUi1DLTAwMDAiLCJjdXN0b21lcl9pZ...|{"schema_version":1,"order_id":"ORDER-C-0000","customer_id":"CUSTOMER-1","cur...|        [items[0].invalid_quantity]|
|orders-000.jsonl|baffe6dcc7fc6ae30fa2c561e323c1daa1de9180f5c4b1a13a5277a4ce166783|          4|eyJzY2hlbWFfdmVyc2lvbiI6MSwib3JkZXJfaWQiOiJPUkRFUi1ELTAwMDAiLCJjdXN0b21lcl9pZ...|{"schema_version":1,"order_id":"ORDER-D-0000","customer_id":"CUSTOMER-1","cur...|       [items[1].duplicate_line_id]|
|orders-000.jsonl|baffe6dcc7fc6ae30fa2c561e323c1daa1de9180f5c4b1a13a5277a4ce166783|          5|eyJzY2hlbWFfdmVyc2lvbiI6MSwib3JkZXJfaWQiOiJPUkRFUi1FLTAwMDAiLCJjdXN0b21lcl9pZ...|{"schema_version":1,"order_id":"ORDER-E-0000","customer_id":"CUSTOMER-1","cur...|                    [missing_items]|
|orders-000.jsonl|baffe6dcc7fc6ae30fa2c561e323c1daa1de9180f5c4b1a13a5277a4ce166783|          6|eyJzY2hlbWFfdmVyc2lvbiI6MSwib3JkZXJfaWQiOiJPUkRFUi1GLTAwMDAiLCJjdXN0b21lcl9pZ...|{"schema_version":1,"order_id":"ORDER-F-0000","customer_id":"CUSTOMER-1","cur...|                       [null_items]|
|orders-000.jsonl|baffe6dcc7fc6ae30fa2c561e323c1daa1de9180f5c4b1a13a5277a4ce166783|          7|eyJzY2hlbWFfdmVyc2lvbiI6Miwib3JkZXJfaWQiOiJPUkRFUi1HLTAwMDAiLCJjdXN0b21lcl9pZ...|{"schema_version":2,"order_id":"ORDER-G-0000","customer_id":"CUSTOMER-1","cur...|       [unsupported_schema_version]|
|orders-000.jsonl|baffe6dcc7fc6ae30fa2c561e323c1daa1de9180f5c4b1a13a5277a4ce166783|          8|                eyJzY2hlbWFfdmVyc2lvbiI6MSwib3JkZXJfaWQiOiJPUkRFUi1ILTAwMDAiLAo=|                                {"schema_version":1,"order_id":"ORDER-H-0000",\n|                   [malformed_json]|
|orders-000.jsonl|baffe6dcc7fc6ae30fa2c561e323c1daa1de9180f5c4b1a13a5277a4ce166783|          9|eyJzY2hlbWFfdmVyc2lvbiI6MSwib3JkZXJfaWQiOiJPUkRFUi1JLTAwMDAiLCJvcmRlcl9pZCI6I...|{"schema_version":1,"order_id":"ORDER-I-0000","order_id":"OTHER-0000","custom...|            [duplicate_json_member]|
|orders-000.jsonl|baffe6dcc7fc6ae30fa2c561e323c1daa1de9180f5c4b1a13a5277a4ce166783|         10|eyJzY2hlbWFfdmVyc2lvbiI6MSwib3JkZXJfaWQiOiJPUkRFUi1KLTAwMDAiLCJjdXN0b21lcl9pZ...|{"schema_version":1,"order_id":"ORDER-J-0000","customer_id":"CUSTOMER-1","cur...|[items[0].invalid_unit_price_minor]|
|orders-000.jsonl|baffe6dcc7fc6ae30fa2c561e323c1daa1de9180f5c4b1a13a5277a4ce166783|         11|eyJzY2hlbWFfdmVyc2lvbiI6MSwib3JkZXJfaWQiOiJPUkRFUi1YLTAwMDAiLCJjdXN0b21lcl9pZ...|{"schema_version":1,"order_id":"ORDER-X-0000","customer_id":"CUSTOMER-1","cur...|               [duplicate_order_id]|
|orders-000.jsonl|baffe6dcc7fc6ae30fa2c561e323c1daa1de9180f5c4b1a13a5277a4ce166783|         12|eyJzY2hlbWFfdmVyc2lvbiI6MSwib3JkZXJfaWQiOiJPUkRFUi1YLTAwMDAiLCJjdXN0b21lcl9pZ...|{"schema_version":1,"order_id":"ORDER-X-0000","customer_id":"CUSTOMER-1","cur...|               [duplicate_order_id]|
|orders-000.jsonl|baffe6dcc7fc6ae30fa2c561e323c1daa1de9180f5c4b1a13a5277a4ce166783|         13|                                                                            W10K|                                                                            []\n|             [top_level_not_object]|
|orders-000.jsonl|baffe6dcc7fc6ae30fa2c561e323c1daa1de9180f5c4b1a13a5277a4ce166783|         14|                                                                            Cg==|                                                                              \n|                     [empty_record]|
+----------------+----------------------------------------------------------------+-----------+--------------------------------------------------------------------------------+--------------------------------------------------------------------------------+-----------------------------------+

Python code

main.py405 lines
# /// script
# requires-python = ">=3.12"
# dependencies = [
#   "pysail==0.7.3",
#   "pyspark-client==4.1.3",
#   "pyarrow==26.0.0",
#   "pandas==3.0.6",
# ]
# ///
"""Normalize a bounded JSONL snapshot into orders, lines and original rejected records."""

import argparse
import atexit
import base64
import hashlib
import json
import re
import shutil
import tempfile
from collections import Counter
from decimal import Decimal, InvalidOperation
from pathlib import Path

MAX_FILE_BYTES = 1_048_576
MAX_RECORD_BYTES = 65_536
MAX_RECORDS_PER_FILE = 10_000
MAX_INTEGER_DIGITS = 4300
MAX_ITEMS = 100
MAX_QUANTITY = 1_000_000
MAX_PRICE = 1_000_000_000
MAX_FILES = 100
ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]{0,63}\Z")
SOURCE = "source_id string, source_sha256 string, line_number long"
EVIDENCE = "raw_record_base64 string, raw_json string"
ITEM = "struct<line_id:string,sku:string,quantity:bigint,unit_price_minor:bigint>"
PARSED_SCHEMA = (
    SOURCE
    + ", "
    + EVIDENCE
    + ", order_id string, customer_id string, currency string, "
    + "items array<"
    + ITEM
    + ">, reasons array<string>"
)
ORDER_SCHEMA = (
    SOURCE
    + ", order_id string, customer_id string, currency string, item_count long, gross_minor long, "
    + EVIDENCE
)
LINE_SCHEMA = (
    SOURCE
    + ", order_id string, line_id string, sku string, quantity long, unit_price_minor long, gross_minor long"
)
REJECT_SCHEMA = SOURCE + ", " + EVIDENCE + ", reasons array<string>"


class DuplicateMember(ValueError):
    """JSON object members must be unique, including inside unknown additive fields."""


class NumericLimit(ValueError):
    """A valid JSON numeric token exceeds the supported representation."""


def bounded_integer(token):
    if len(token.lstrip("-")) > MAX_INTEGER_DIGITS:
        raise NumericLimit("numeric_representation_limit")
    try:
        return int(token)
    except ValueError:
        raise NumericLimit("numeric_representation_limit") from None


def unique_object(pairs):
    result = {}
    for key, value in pairs:
        if key in result:
            raise DuplicateMember("duplicate_json_member")
        result[key] = value
    return result


def reject_constant(value):
    raise ValueError("nonfinite_json_number")


def valid_id(value):
    return isinstance(value, str) and ID.fullmatch(value) is not None


def parse_record(raw):
    """Validate a whole physical record before any child can enter a clean table."""
    result = {
        "raw_record_base64": base64.b64encode(raw).decode("ascii"),
        "raw_json": None,
        "order_id": None,
        "customer_id": None,
        "currency": None,
        "items": None,
        "reasons": [],
    }
    if len(raw) > MAX_RECORD_BYTES:
        result["reasons"] = ["record_byte_limit"]
        return result
    try:
        text = raw.decode("utf-8")
    except UnicodeDecodeError:
        result["reasons"] = ["invalid_utf8"]
        return result
    result["raw_json"] = text
    if not text.strip():
        result["reasons"] = ["empty_record"]
        return result
    try:
        value = json.loads(
            text,
            object_pairs_hook=unique_object,
            parse_constant=reject_constant,
            parse_float=Decimal,
            parse_int=bounded_integer,
        )
    except DuplicateMember:
        result["reasons"] = ["duplicate_json_member"]
        return result
    except (InvalidOperation, NumericLimit):
        result["reasons"] = ["numeric_representation_limit"]
        return result
    except (ValueError, RecursionError) as error:
        result["reasons"] = [
            "nonfinite_json_number" if str(error) == "nonfinite_json_number" else "malformed_json"
        ]
        return result
    if not isinstance(value, dict):
        result["reasons"] = ["top_level_not_object"]
        return result
    reasons = result["reasons"]
    if type(value.get("schema_version")) is not int or value["schema_version"] != 1:
        reasons.append("unsupported_schema_version")
    for name in ("order_id", "customer_id"):
        if valid_id(value.get(name)):
            result[name] = value[name]
        else:
            reasons.append("invalid_" + name)
    if value.get("currency") not in ("USD", "GBP"):
        reasons.append("unsupported_currency")
    else:
        result["currency"] = value["currency"]
    if "items" not in value:
        reasons.append("missing_items")
    elif value["items"] is None:
        reasons.append("null_items")
    elif not isinstance(value["items"], list):
        reasons.append("items_not_array")
    elif len(value["items"]) > MAX_ITEMS:
        reasons.append("item_count_limit")
    else:
        items, seen = [], set()
        for position, item in enumerate(value["items"]):
            prefix = f"items[{position}]."
            if not isinstance(item, dict):
                reasons.append(prefix + "not_object")
                continue
            for name in ("line_id", "sku"):
                if not valid_id(item.get(name)):
                    reasons.append(prefix + "invalid_" + name)
            line_id = item.get("line_id")
            if valid_id(line_id):
                if line_id in seen:
                    reasons.append(prefix + "duplicate_line_id")
                seen.add(line_id)
            quantity, price = item.get("quantity"), item.get("unit_price_minor")
            if type(quantity) is not int or not 1 <= quantity <= MAX_QUANTITY:
                reasons.append(prefix + "invalid_quantity")
            if type(price) is not int or not 0 <= price <= MAX_PRICE:
                reasons.append(prefix + "invalid_unit_price_minor")
            items.append(
                {name: item.get(name) for name in ("line_id", "sku", "quantity", "unit_price_minor")}
            )
        if not reasons:
            result["items"] = items
    return result


def arrow_schema():
    import pyarrow as pa

    item = pa.struct(
        [
            ("line_id", pa.string()),
            ("sku", pa.string()),
            ("quantity", pa.int64()),
            ("unit_price_minor", pa.int64()),
        ]
    )
    return pa.schema(
        [
            ("source_id", pa.string()),
            ("source_sha256", pa.string()),
            ("line_number", pa.int64()),
            ("raw_record_base64", pa.string()),
            ("raw_json", pa.string()),
            ("order_id", pa.string()),
            ("customer_id", pa.string()),
            ("currency", pa.string()),
            ("items", pa.list_(item)),
            ("reasons", pa.list_(pa.string())),
        ]
    )


def read_sources(batches):
    import pyarrow as pa

    schema = arrow_schema()
    for batch in batches:
        for source in batch.to_pylist():
            with Path(source["path"]).open("rb") as stream:
                content = stream.read(MAX_FILE_BYTES + 1)
            if len(content) > MAX_FILE_BYTES:
                raise ValueError("source_file_byte_limit")
            if hashlib.sha256(content).hexdigest() != source["source_sha256"]:
                raise ValueError("source_file_sha256_mismatch")
            # splitlines would treat embedded Unicode/control separators as extra records.
            records = content.split(b"\n")
            if records[-1] == b"":
                records.pop()  # the final LF terminates its record, not an extra empty record
            if len(records) > MAX_RECORDS_PER_FILE:
                raise ValueError("source_file_record_limit")
            rows = []
            for index, record in enumerate(records, 1):
                raw = record + (b"\n" if index < len(records) or content.endswith(b"\n") else b"")
                rows.append(
                    {
                        "source_id": source["source_id"],
                        "source_sha256": source["source_sha256"],
                        "line_number": index,
                        **parse_record(raw),
                    }
                )
            if rows:
                yield pa.RecordBatch.from_pylist(rows, schema=schema)


def manifest(paths):
    paths = [Path(path) for path in paths]
    if len(paths) > MAX_FILES or len({path.name for path in paths}) != len(paths):
        raise ValueError("at most 100 source files with unique basenames are supported")
    records = []
    for path in paths:
        with path.open("rb") as stream:
            content = stream.read(MAX_FILE_BYTES + 1)
        if len(content) > MAX_FILE_BYTES:
            raise ValueError("source_file_byte_limit")
        record_count = content.count(b"\n") + int(bool(content) and not content.endswith(b"\n"))
        if record_count > MAX_RECORDS_PER_FILE:
            raise ValueError("source_file_record_limit")
        records.append(
            {
                "source_id": path.name,
                "source_sha256": hashlib.sha256(content).hexdigest(),
                "path": str(path.resolve()),
            }
        )
    return records


def normalize(spark, parsed):
    from pyspark.sql import functions as F

    valid = parsed.where(F.size("reasons") == 0)
    counts = valid.groupBy("order_id").count().withColumnRenamed("count", "order_copies")
    checked = valid.join(counts, "order_id", "inner")
    accepted = checked.where("order_copies = 1")
    rejects = parsed.where(F.size("reasons") > 0).selectExpr(
        *[part.strip().split()[0] for part in REJECT_SCHEMA.split(",")]
    )
    repeated = checked.where("order_copies > 1").withColumn("reasons", F.array(F.lit("duplicate_order_id")))
    rejects = rejects.unionByName(repeated.select(*rejects.columns))
    source_columns = ["source_id", "source_sha256", "line_number", "order_id"]
    lines = (
        accepted.select(*source_columns, F.explode("items").alias("item"))
        .select(*source_columns, "item.*")
        .withColumn("gross_minor", F.col("quantity") * F.col("unit_price_minor"))
    )
    totals = lines.groupBy(*source_columns).agg(F.sum("gross_minor").alias("gross_minor"))
    orders = accepted.join(totals, source_columns, "left").withColumn(
        "item_count", F.size("items").cast("long")
    )
    orders = orders.withColumn("gross_minor", F.coalesce("gross_minor", F.lit(0).cast("long")))
    return {
        "orders": orders.selectExpr(*[part.strip().split()[0] for part in ORDER_SCHEMA.split(",")]),
        "lines": lines.selectExpr(*[part.strip().split()[0] for part in LINE_SCHEMA.split(",")]),
        "rejects": rejects,
    }


def temp_dir():
    path = Path(tempfile.mkdtemp(prefix="order-events-"))
    atexit.register(shutil.rmtree, path, ignore_errors=True)
    return path


def readback(spark, frame, path, schema):
    import pyarrow.parquet as pq

    frame.write.mode("error").parquet(str(path))
    if not list(path.glob("*.parquet")):
        # Every output is a physically readable typed dataset, including all-reject/empty input.
        path.mkdir(parents=True, exist_ok=True)
        pq.write_table(frame.limit(0).toArrow(), path / "empty.parquet")
    return spark.read.schema(schema).parquet(str(path))


def demo_files(directory, copies=1):
    if type(copies) is not int or not 1 <= copies <= 1000:
        raise ValueError("copies must be an integer from 1 to 1000")
    directory = Path(directory)
    directory.mkdir(parents=True, exist_ok=True)
    records = (Path(__file__).parent / "inputs/orders.jsonl").read_bytes().splitlines(keepends=True)
    paths = []
    for group in range((copies + 49) // 50):
        path = directory / f"orders-{group:03d}.jsonl"
        with path.open("wb") as stream:
            for copy in range(group * 50, min(copies, (group + 1) * 50)):
                for raw in records:
                    # Replace only the explicit placeholder in this authored input, retaining malformed records.
                    stream.write(raw.replace(b"COPY", f"{copy:04d}".encode()))
        paths.append(path)
    return paths


def run(spark, paths=None, *, copies=1, output_dir=None):
    root = temp_dir()
    destination = root
    if output_dir is not None:
        destination = Path(output_dir)
        destination.mkdir(parents=True, exist_ok=False)
    if paths is None:
        paths = demo_files(root / "inputs", copies)
    sources = manifest(paths)
    inputs = spark.createDataFrame(sources, "source_id string, source_sha256 string, path string")
    parsed = inputs.repartition(2).mapInArrow(read_sources, PARSED_SCHEMA)
    parsed = readback(spark, parsed, root / "parsed", PARSED_SCHEMA)
    outputs = normalize(spark, parsed)
    schemas = {"orders": ORDER_SCHEMA, "lines": LINE_SCHEMA, "rejects": REJECT_SCHEMA}
    outputs = {
        name: readback(spark, frame, destination / name, schemas[name]) for name, frame in outputs.items()
    }
    input_keys = Counter(
        tuple(row) for row in parsed.select("source_id", "source_sha256", "line_number").collect()
    )
    output_keys = Counter()
    for name in ("orders", "rejects"):
        output_keys.update(
            tuple(row) for row in outputs[name].select("source_id", "source_sha256", "line_number").collect()
        )
    if input_keys != output_keys or any(count != 1 for count in input_keys.values()):
        raise AssertionError("each physical source record must appear in exactly one order or reject row")
    return {
        name: frame.orderBy("source_id", "line_number", *(["line_id"] if name == "lines" else []))
        for name, frame in outputs.items()
    }


def parse_cli(argv=None):
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument(
        "--copies", type=int, help="independent copies of the authored snapshot, 1..1000 (default: 1)"
    )
    parser.add_argument(
        "--input", type=Path, action="append", help="read a supplied JSONL file; repeat for more files"
    )
    parser.add_argument("--output-dir", type=Path, help="retain final Parquet datasets in a new directory")
    parser.add_argument("--json", action="store_true", help="print output row counts")
    args = parser.parse_args(argv)
    if args.input is not None and args.copies is not None:
        parser.error("--input and --copies cannot be combined")
    args.copies = 1 if args.copies is None else args.copies
    if not 1 <= args.copies <= 1000:
        parser.error("--copies must be from 1 to 1000")
    return args


if __name__ == "__main__":
    args = parse_cli()
    from pysail.spark import SparkConnectServer
    from pyspark.sql import SparkSession

    server = SparkConnectServer()
    server.start()
    spark = None
    try:
        _, port = server.listening_address
        spark = SparkSession.builder.remote(f"sc://localhost:{port}").getOrCreate()
        outputs = run(spark, paths=args.input, copies=args.copies, output_dir=args.output_dir)
        if args.json:
            print(json.dumps({name: frame.count() for name, frame in outputs.items()}, sort_keys=True))
        else:
            for name, frame in outputs.items():
                print(name)
                frame.show(truncate=80)
    finally:
        if spark is not None:
            spark.stop()
        server.stop()

The supported contract

Only integer schema_version: 1 is supported. Missing, null, boolean, string and other versions are rejected. The required header fields are order_id, customer_id, currency and items. IDs use 1 to 64 ASCII letters, digits, underscores or hyphens, beginning with a letter or digit. Currency is explicitly USD or GBP; no exchange rates or cross-currency totals are computed.

Each child requires line_id, sku, integer quantity from 1 to 1,000,000 and integer unit_price_minor from 0 to 1,000,000,000. Booleans, numeric strings and fractional numbers are not coerced to integers. Line IDs must be unique inside an order; the same SKU on different line IDs is allowed. There are at most 100 children. These limits keep exact integer multiplication and per-order sums within signed 64-bit range. Zero-price items are accepted.

Missing items, null items, non-array items, invalid children and repeated child IDs reject the whole order. An empty array is deliberately accepted as an order with zero children and gross total zero. This is a normalization contract, not a claim that an order was paid, fulfilled or valid under a business policy.

Duplicate object members at any nesting level, nonstandard NaN/Infinity constants, malformed JSON, non-object roots, blank records and invalid UTF-8 are rejected. Numbers beyond the parser's decimal representation produce an explicit numeric_representation_limit reject, including in unknown fields. Integer tokens are limited to 4,300 digits before field-specific limits are applied. Validation emits deterministic ordered reasons for the fields it can inspect. Syntax, encoding and structural failures produce a single boundary reason. Unknown additive fields are allowed and retained in the original evidence; they do not silently become modeled columns. Adding another schema version requires a new explicit contract.

Separate parent and child tables

Start with normalize() in pipelines/normalize-order-events/main.py for the DataFrame transformation. run() handles file ingestion and output readback; parse_record() defines the strict record-validation policy.

  1. A manifest has a logical filename, SHA-256 and worker-readable path per file. Each physical LF-delimited record keeps its 1-based line number. LF or CRLF terminators remain in raw_record_base64, including malformed records. raw_json contains the original UTF-8 text when decoding was attempted and succeeded. Base64 remains the authoritative byte evidence.
  2. A bounded Python parser validates the complete order inside mapInArrow. Parsed rows are persisted before later actions. No invalid order contributes a child to the clean tables.
  3. Under this fixed-snapshot policy, every repeated order_id among otherwise valid records is quarantined, including exact repeats and repeats across files. Invalid records retain their validation reasons and do not reserve an order ID. This is not replay handling or change-data capture.
  4. The DataFrame transform explodes accepted children once. It aggregates line totals to source-occurrence/order keys before joining them to headers. The left join preserves accepted empty orders without inventing a child. Gross is quantity times unit price only; taxes, shipping and discounts are not included by this contract.
  5. Orders, lines and rejects are written to separate Parquet datasets and read back with explicit schemas. Typed empty datasets are created when required. The program checks that every physical source occurrence appears exactly once in orders or rejects. Lines reference their accepted parent occurrence.

The source manifest supports at most 100 files with unique basenames, each at most 1 MiB and 10,000 physical records. An unreadable file, file above either limit or changed source SHA fails the job; the program does not pretend it ingested a partial file. Individual records above 64 KiB, including their LF or CRLF terminator when present, are rejected with complete byte evidence, within the file limit. Input paths must be readable by workers. The generated demonstration uses temporary local paths and removes them when the process exits.

This is bounded snapshot normalization, not a general schema-evolution system, transactional publication protocol or distributed storage example. Source data, including unknown fields, is retained in clean-order evidence and reject evidence; apply appropriate access controls and retention to real inputs.

Verification

Maintenance checks compare both engines against an independently assembled reference: authored literal accept/reject classifications, literal expected order and line counts, and separate Python integer arithmetic. They check exact original bytes, additive fields, duplicate barriers, invalid child types, malformed JSON, empty inputs and typed output readback. The reference does not call the production parser to decide whether an input is accepted.