Content

Table of Contents

  1. 1. Background
    1. 1.1 Overview
    2. 1.2 Why metadata-driven
  2. 2. The quality problem
  3. 3. Technical solution
    1. 3.1 Check 1 — file completeness against metadata (count + bytes)
    2. 3.2 Check 2 — schema conformance (no silent field drops)
    3. 3.3 Recording the outcome in run_control
  4. 4. The full flow
  5. 5. Design decisions worth calling out
  6. 6. Test focus
  7. 7. Other issues

1. Background

1.1 Overview

When ingesting event data (JSON) into the bronze layer, “did the load succeed?” is not a yes/no question. A run can finish without throwing an exception and still be silently wrong: files can go missing in transit, and Spark’s from_json will quietly drop any field that is not in the schema you hand it. Neither shows up as an error — the job goes green while data is lost.

So we made quality checks metadata-driven and first-class: the source system ships metadata alongside the data, we compare the actual data against that metadata before trusting it, and we record the outcome of every object into a run_control table. That table becomes the single source of truth for “what loaded, how much, and why it failed”.

There are two independent checks, run at two different stages:

  1. File completeness — did every file the source says it sent actually arrive, intact? (count + bytes)
  2. Schema conformance — does every field present in the JSON payload exist in our declared event schema? (no silent field drops)

Every object’s result — successful, failed, or skipped — plus row counts and an error message, is written to run_control.

1.2 Why metadata-driven

The source system writes, next to each batch of data files, a set of metadata sidecar files listing every file it produced and each file’s expected size in bytes. That metadata is the contract. Instead of guessing whether a batch is complete, we validate the delivered files against the contract, and we validate the delivered fields against our own schema definitions. Nothing is assumed — everything is compared.

2. The quality problem

Two failure modes are invisible without explicit checks:

  1. Missing or truncated files. A batch of thousands of files can lose a few in transit, or a file can be partially written. Spark will happily read whatever is there and report success on a partial dataset.

  2. Silent field drops. from_json(col, schema) only keeps fields that are declared in schema. If the source adds a new field (schema drift) and we haven’t updated our event schema, that field is dropped without warning. The row count looks fine; the data is incomplete.

Both must be turned into loud, recorded failures instead of silent data loss.

3. Technical solution

3.1 Check 1 — file completeness against metadata (count + bytes)

Each batch ships metadata sidecar files that list every delivered file and its sizeBytes. We read that metadata, list the files actually present in storage, join the two, and flag anything missing or with a size mismatch. If either exists, we fail the run before any data is written.

# expected files (from metadata sidecars): destination path + expected size in bytes
metadata_df = (
    spark.read.option("multiline", "true").json(metadata_folder_path)
    .withColumn("metadata_file_path", F.col("_metadata.file_path"))
    .select(F.col("metadata_file_path"), F.explode("files").alias("file"))
    .select(
        F.col("metadata_file_path"),
        F.col("file.destinationPath").alias("destination_path"),
        F.col("file.sizeBytes").cast("long").alias("expected_size"),
    )
    .withColumn("relative_path", F.regexp_extract("destination_path", r".*/Raw/(.*)", 1))
    .withColumn("full_path", F.concat(F.lit(raw_folder_path), F.col("relative_path")))
)

# actual files present in storage + their real size on disk
actual_files_df = (
    spark.read.format("binaryFile").option("recursiveFileLookup", "true").load(raw_folder_path)
    .select(F.col("path").alias("full_path"), F.col("length").cast("long").alias("actual_size"))
)

# compare expected vs actual
comparison_df   = metadata_df.join(actual_files_df, "full_path", "left")
missing_files   = comparison_df.filter(F.col("actual_size").isNull())
size_mismatches = comparison_df.filter(
    F.col("actual_size").isNotNull() & (F.col("expected_size") != F.col("actual_size"))
)

if missing_files.count() > 0 or size_mismatches.count() > 0:
    errors  = [f"File not found: {r.full_path} (metadata {r.metadata_file_path})"
               for r in missing_files.collect()]
    errors += [f"Size mismatch for {r.full_path}: expected {r.expected_size}, got {r.actual_size}"
               for r in size_mismatches.collect()]
    raise ValueError("File completeness check failed:\n" + "\n".join(errors))

Why bytes, not just count? A file can exist but be half-written (truncated upload). Comparing sizeBytes catches partial files that a pure count check would miss. This check is a hard gate: if it fails, we stop — there is no point loading a known-incomplete batch.

3.2 Check 2 — schema conformance (no silent field drops)

The subtle one. from_json silently drops fields not in the schema, so before parsing we compare the fields actually present in the JSON payload against the fields declared in our event schema. Any field in the data but not in the schema is an unknown_field — a signal of source-side schema drift that would otherwise be lost.

We infer the real payload schema across all rows (serverless-safe: no RDD, no spark.read.json) using schema_of_variant_agg, then flatten both the inferred schema and the declared schema to dotted field paths and take the set difference.

def flatten_field_paths(data_type, prefix=""):
    # collect all (nested) field paths of a Struct/Array as a set of dotted names
    if isinstance(data_type, ArrayType):
        data_type = data_type.elementType
    paths = set()
    if isinstance(data_type, StructType):
        for field in data_type.fields:
            path = f"{prefix}{field.name}"
            paths.add(path)
            paths |= flatten_field_paths(field.dataType, f"{path}.")
    return paths

def get_payload_field_paths(df):
    # infer the actual payload schema merged across ALL rows (serverless-safe)
    inferred_ddl = df.select(F.expr("schema_of_variant_agg(parse_json(Payload))").alias("s")).first()["s"]
    if inferred_ddl is None:                      # no rows / all-null payloads
        return set()
    inferred_ddl = inferred_ddl.replace("OBJECT<", "STRUCT<")   # make it castable
    inferred_type = spark.sql(f"SELECT CAST(NULL AS {inferred_ddl}) AS s").schema.fields[0].dataType
    return flatten_field_paths(inferred_type)

# the actual check: fields in the JSON but NOT in our event schema
unknown_fields = get_payload_field_paths(df) - flatten_field_paths(schema)
if unknown_fields:
    # record as FAILED with the offending field list -> nothing silently lost
    record_failure(object_name, f"Fields in json but not in event schema: {sorted(unknown_fields)}")
else:
    record = process_one_object(object_name, df, schema)

This deliberately checks one direction (data ⊄ schema). Fields that are in the schema but absent from the data are fine — they just come through as null. What we cannot tolerate is a field arriving that we would silently throw away.

3.3 Recording the outcome in run_control

Every object produces a record regardless of outcome, and each record maps to one row in the run_control table. Three terminal states:

Status Meaning
successful Passed both checks and was written to bronze.
failed A check failed (unknown fields) or the write raised. error_message explains why.
skipped No event schema exists for this topic; nothing was written.

We also capture input_row_count / insert_row_count, timing (load_start, load_end, duration_seconds), and a last_successful_run_flag so downstream consumers can always find the latest good load per object.

def process_one_object(object_name, df, schema):
    load_start = datetime.now(AMSTERDAM_TZ)
    record = {"run_control_id": str(uuid.uuid4()), "object_name": object_name, "load_start": load_start}
    try:
        root_count = process_topic(df=df, schema=schema, topic=object_name)
        record.update(status="successful",
                      input_row_count=root_count, insert_row_count=root_count, ...)
    except Exception as e:
        record.update(status="failed", error_message=str(e), ...)
    return record

The rows are collected in memory during processing and written to run_control once, sequentially, afterwards. Before marking a new success as the latest, we demote the previous one:

for rec in run_control_records:
    row = penu.RunControlModel(
        run_control_id=rec["run_control_id"], source_name=source_name,
        input_object=rec["object_name"], output_object=rec["object_name"],
        output_layer=target_layer, status=rec["status"],
        load_start=rec["load_start"], load_end=rec["load_end"],
        duration_seconds=rec["duration_seconds"],
        last_successful_run_flag=(rec["status"] == "successful"),
        error_message=rec.get("error_message"),
        input_row_count=rec.get("input_row_count"),
        insert_row_count=rec.get("insert_row_count"),
        ...
    )
    if row.last_successful_run_flag:
        # only one "latest success" per (source, object, layer)
        run_control_agent.reset_last_successful_run_flag_false(source_name, rec["object_name"], target_layer)
    run_control_agent.insert_row(row)

4. The full flow

flowchart TD
    A[Batch arrives: data files + metadata sidecars] --> B[Check 1: file completeness]
    B -->|missing file or size mismatch| B1[raise ValueError - hard stop]
    B -->|all files present and intact| C[Read + flatten payloads per topic]
    C --> D{Event schema exists for topic?}
    D -->|no| S[status = skipped]
    D -->|yes| E[Check 2: payload fields vs event schema]
    E -->|unknown fields found| F[status = failed  + error_message]
    E -->|conformant| G[Write to bronze Delta table]
    G -->|write error| F
    G -->|ok| H[status = successful  + row counts]
    S --> R[(run_control table)]
    F --> R
    H --> R
    R --> Z[Latest good load per object via last_successful_run_flag]

And how a single object moves through the two gates:

sequenceDiagram
    participant M as Metadata sidecar
    participant S as Storage
    participant J as Job
    participant RC as run_control
    M->>J: expected files + sizeBytes
    S->>J: actual files + length
    J->>J: Check 1 - completeness (count + bytes)
    Note over J: fail -> hard stop for the whole batch
    J->>J: infer payload fields (schema_of_variant_agg)
    J->>J: Check 2 - payload fields - event schema
    alt unknown fields
        J->>RC: status = failed (+ offending fields)
    else conformant
        J->>J: write to bronze
        J->>RC: status = successful (+ row counts)
    end

5. Design decisions worth calling out

  • Two gates, two blast radii. File completeness is a batch-level hard stop (a ValueError that aborts the whole run) because a missing file poisons everything. Schema conformance is per-object: one drifting topic is recorded as failed while the other topics still load, so one bad topic doesn’t block the rest.
  • Serverless-safe inference. schema_of_variant_agg(parse_json(...)) merges the payload schema across all rows without RDDs or spark.read.json, both of which are constrained on serverless. The OBJECT<...>STRUCT<...> rewrite makes the inferred DDL castable so we can turn it back into a real DataType.
  • Everything is recorded, nothing is guessed. Even a skipped topic (no schema) gets a run_control row, so an operator can see exactly which topics were ignored and why.
  • last_successful_run_flag gives downstream consumers a reliable pointer to the newest good load per object without scanning history.

6. Test focus

  • Completeness gate: delete one delivered file and truncate another; confirm both are reported and the run hard-stops with a clear message.
  • Schema drift: add a field to the JSON payload that is not in the event schema; confirm the object is failed with the exact unknown-field list, and other topics still succeed.
  • Skipped path: send a topic with no matching event schema; confirm a skipped row with a reason.
  • run_control integrity: verify counts, timings, error_message, and that exactly one row per object carries last_successful_run_flag = true after a successful reload.
  • Empty / all-null payloads: confirm get_payload_field_paths returns an empty set and does not false-positive.

7. Other issues

  • Should schema drift always fail, or should additive-only drift (new nullable field) be allowed and just logged? Currently any unknown field fails — pending discussion with the source supplier.
  • Consider promoting the field-path comparison into a shared, recursive get_schema_diff utility so raw, bronze, and metadata checks all use one implementation.
  • Surface run_control failures to alerting so a failed/skipped object pages someone instead of waiting for a consumer to notice.