Skip to content
academia.sh

Lesson 04 / 10

Source Systems

Extracting the corruption profile of three source classes and counting after how many stages each corruption class is caught: with an intact delivery all three give 2400 rows and produce the same five served values, and no check fires. They part ways once corruption arrives: the application database carries three of the six classes with a total lag of 4 stages, the log carries five with a total lag of 8, and the external service carries all six with a total lag of again 8. Permissive parsing makes type drift invisible in both sources: 200 rows are corrupted, no check catches it, and the five served values stay exactly the same. The row count check at tolerance 0.05 catches none of the three classes because the largest row diff is 60, that is, 0.0250; lowering the tolerance to 0.0100 catches all three, but when its reference is the producer's own declaration, it catches none at any tolerance.

Published Updated

In this lesson

  • Defines a source class through its adapter, the corruption classes it can carry and the checks that have a reference.
  • Extracts the corruption profile of the three source classes and counts the detection lag of each corruption class in stages.
  • Shows how permissive parsing makes type drift invisible and why it never changes the served value.
  • Compares why a check whose reference comes from the producer's own declaration cannot catch anything at any tolerance.
Contents

The previous three lessons built the pipeline not from where the data comes but from where it stands: two thousand four hundred rows were sitting there, passing through five stages, and the rows each stage dropped were counted. Where those rows came from was not asked. Yet corruption is not born inside the pipeline; it is born on the producer side and delivered to the pipeline.

In terms of measurement, a source system (source system) is not one product but three things: the form in which it delivers the data, which corruption classes it can produce, and which check can find a reference to lean on. The three source classes are defined by this triple; for each, the number of corruption classes carried and after how many stages each class is caught are counted.

  • DL41. The code is fictional; no real store, engine or orchestrator is called. The source is the fictional meter readings of a measurement network: 200 subscribers, 12 periods, 2400 rows, seed 20260218.
  • DL42. A source class is not a product but a guarantee set; no product name is written, the classes are referred to by their kinds.
  • DL43. A corruption class a source class does not carry is not run; the carrying decision follows from the guarantee. A source with declared column types cannot produce type drift, and a source that enforces a primary key cannot produce duplicated rows.
  • DL44. The corruption classes are not attacks but producer-side accidents; each corrupts only the period 2026-07.
  • DL45. All seven checks are enabled on every source; the only thing left out is the check that has no reference.
  • DL46. Detection lag is written in stages; if nothing catches the corruption it is undefined and does not enter the total. The step of the row ratio is 1/2400, that is, 0.0004; no ratio is printed on serving.

Three Source Classes and the Intact Delivery

The block below sets up the shared definition, the fictional source, the five-stage pipeline, the six corruption classes and the seven checks, and adds three adapters on top. The application database carries a declared schema and column types and enforces the primary key. The log is append-only text lines: the parser supplies the field names and the type is read permissively. The external service delivers the numeric field as text.

# FICTIONAL. No real store, engine or orchestrator is called; the meter
# readings of the measurement network are fictional, seed 20260218.
SEED, M32 = 20260218, 0xFFFFFFFF


def rng(t):
    x = ((t ^ (t >> 16)) * 2246822507) & M32
    x = ((x ^ (x >> 13)) * 3266489909) & M32
    s = [(x ^ (x >> 16)) & M32]

    def next_value():
        s[0] = (s[0] * 1664525 + 1013904223) & M32
        return s[0] / 4294967296
    return next_value


REGION = ["east", "west", "north", "south", "central"]
TYPE = ["residential", "commercial"]
PERIODS = [f"2026-{a:02d}" for a in range(1, 13)]
SUBSCRIBERS = {}
for i in range(200):
    r = rng(SEED + 37 * i)
    SUBSCRIBERS[f"A{i + 1:03d}"] = {"region": REGION[int(r() * 5)], "type": TYPE[int(r() * 2)],
                                    "start": 10000 + int(r() * 60000)}


def generate_source():                              # one reading per subscriber per period
    records = []
    for i, (sub, a) in enumerate(SUBSCRIBERS.items()):
        e = a["start"]
        for p in PERIODS:
            r = rng(SEED + 101 * i + 7 * PERIODS.index(p))
            e += 4 + int(r() * 46)
            records.append({"subscriber": sub, "period": p, "region": a["region"], "type": a["type"],
                            "index": e, "unit": "m3"})
    return records


# ---- Six corruption classes. None of them is an attack; all are producer-side accidents.
CORRUPTIONS = ["late_arrival", "duplicated", "schema_changed", "unit_changed", "missing_partition", "type_drift"]


def corrupt(records, cls, period="2026-07"):
    """Corrupts a single period , the remaining periods stay intact."""
    out, counter = [], 0
    for k in records:
        if k["period"] != period:
            out.append(k)
            continue
        if cls == "late_arrival":
            if k["subscriber"] <= "A040":           # 40 subscribers never arrived this period
                counter += 1
                continue
            out.append(k)
        elif cls == "duplicated":
            out.append(k)
            if k["subscriber"] <= "A060":           # 60 records arrived twice
                out.append(dict(k))
                counter += 1
        elif cls == "schema_changed":
            y = dict(k)
            y["index_value"] = y.pop("index")       # field name changed
            counter += 1
            out.append(y)
        elif cls == "unit_changed":
            y = dict(k)
            y["index"] = k["index"] * 1000          # liters instead of m3
            y["unit"] = "m3"                        # LABEL DID NOT CHANGE , silent corruption
            counter += 1
            out.append(y)
        elif cls == "missing_partition":
            if k["region"] == "north":              # one region's partition was never written
                counter += 1
                continue
            out.append(k)
        elif cls == "type_drift":
            y = dict(k)
            y["index"] = str(k["index"])            # number turned into text
            counter += 1
            out.append(y)
    return out, counter


# ---- PIPELINE: five stages. Each stage counts the rows it drops.
def s1_screen(records):
    REQUIRED = ("subscriber", "period", "region", "index", "unit")
    keep = [k for k in records if all(a in k for a in REQUIRED)]
    return keep, len(records) - len(keep)


def s2_dedupe(records):
    seen, keep = set(), []
    for k in records:
        key = (k["subscriber"], k["period"])
        if key in seen:
            continue
        seen.add(key)
        keep.append(k)
    return keep, len(records) - len(keep)


def s3_consumption(records):
    table = {(k["subscriber"], k["period"]): k for k in records}
    out, dropped = [], 0
    for k in records:
        i = PERIODS.index(k["period"])
        if i == 0:
            continue
        prev = table.get((k["subscriber"], PERIODS[i - 1]))
        if prev is None:
            dropped += 1
            continue
        try:
            f = int(k["index"]) - int(prev["index"])
        except (TypeError, ValueError):
            dropped += 1
            continue
        out.append(dict(k, m3=f))
    return out, dropped


def s4_summary(records):
    totals = {}
    for k in records:
        key = (k["region"], k["period"])
        totals[key] = totals.get(key, 0) + k["m3"]
    return totals, 0


def s5_serve(summary):                              # per-region mean over the periods
    region = {}
    for (b, d), v in summary.items():
        region.setdefault(b, []).append(v)
    return {b: round(sum(v) / len(v), 2) for b, v in region.items()}, 0


STAGES = [("screen", s1_screen), ("dedupe", s2_dedupe), ("consumption", s3_consumption),
          ("summary", s4_summary), ("serve", s5_serve)]


# ---- Seven checks, each runs AFTER a specific stage.
def c_schema(data, fields=("subscriber", "period", "region", "index", "unit")):
    return any(any(a not in k for a in fields) for k in data)


def c_type(data):
    return any(not isinstance(k.get("index"), int) for k in data)


def c_uniqueness(data):
    key = [(k["subscriber"], k["period"]) for k in data]
    return len(key) != len(set(key))


def c_row_count(data, expected, tolerance=0.05):
    return abs(len(data) - expected) / expected > tolerance


def c_partition_completeness(data):
    """FLAWED CHECK: looks at the region set , does not see the period."""
    return set(k["region"] for k in data) != set(REGION)


def c_partition_completeness_period(data):
    seen = set((k["region"], k["period"]) for k in data)
    return seen != set((b, d) for b in REGION for d in PERIODS)


def c_distribution(summary, baseline, tolerance=0.25):
    for key, v in summary.items():
        t = baseline.get(key)
        if t and abs(v - t) / max(abs(t), 1) > tolerance:
            return True
    return False


CHECKS = [("schema", 0, lambda v, t: c_schema(v)),
          ("type", 0, lambda v, t: c_type(v)),
          ("uniqueness", 0, lambda v, t: c_uniqueness(v)),
          ("row_count", 0, lambda v, t: c_row_count(v, t["expected_rows"])),
          ("partition", 0, lambda v, t: c_partition_completeness(v)),
          ("partition_period", 0, lambda v, t: c_partition_completeness_period(v)),
          ("distribution", 4, lambda v, t: c_distribution(v, t["baseline_summary"]))]


def run_checks(records, baseline, enabled):
    """Returns the first detecting check and the detection lag (stage number)."""
    data, detector, lag = records, None, None
    for step in range(len(STAGES) + 1):
        if step > 0:
            data, _ = STAGES[step - 1][1](data)
        for cname, cstage, cf in CHECKS:
            if cname in enabled and cstage == step and cf(data, baseline) and detector is None:
                detector, lag = cname, step
    return data, detector, lag


def build_baseline(delivery):
    v, _ = s1_screen(delivery)
    v, _ = s2_dedupe(v)
    v, _ = s3_consumption(v)
    summ, _ = s4_summary(v)
    serve, _ = s5_serve(summ)
    return {"expected_rows": len(delivery), "baseline_summary": summ, "baseline_serving": serve}


# ---- Three source classes: adapter , set of classes carried , set of checks that have a reference.
FIELDS = ("subscriber", "period", "region", "type", "index", "unit")


def u_database(delivery):
    """Declared schema and column type ; the primary key is enforced."""
    unique = {}
    for k in delivery:
        unique[(k.get("subscriber"), k.get("period"))] = k
    return list(unique.values())


def u_log(delivery):
    """Append-only text line ; the parser supplies the field name , the type is read permissively."""
    lines = ["|".join(str(k.get(a, "")) for a in FIELDS) for k in delivery]
    parsed = []
    for ln in lines:
        p = dict(zip(FIELDS, ln.split("|")))
        p["index"] = int(p["index"]) if p["index"].isdigit() else p["index"]
        parsed.append(p)
    return parsed


def u_external_service(delivery):
    """The numeric field arrives as text ; the parser converts it permissively."""
    parsed = []
    for k in delivery:
        y = dict(k)
        if isinstance(y.get("index"), int):
            y["index"] = str(y["index"])            # the service's serialization format
        if isinstance(y.get("index"), str) and y["index"].isdigit():
            y["index"] = int(y["index"])
        parsed.append(y)
    return parsed


SEVEN = ["schema", "type", "uniqueness", "row_count", "partition", "partition_period", "distribution"]
SOURCES = {"app database": (u_database, ["schema_changed", "unit_changed", "missing_partition"], SEVEN),
           "log": (u_log, ["late_arrival", "duplicated", "unit_changed", "missing_partition",
                           "type_drift"], [a for a in SEVEN if a != "row_count"]),
           "external service": (u_external_service, CORRUPTIONS, SEVEN)}

raw = generate_source()
print("source", len(raw), "rows =", len(SUBSCRIBERS), "subscribers x", len(PERIODS), "periods")
for name, (u, carries, enabled) in SOURCES.items():
    delivery = u(raw)
    base = build_baseline(delivery)
    served, detector, lag = run_checks(delivery, base, enabled)
    print(f"{name:<16} delivered {len(delivery)} rows  serving {served == base['baseline_serving']}  "
          f"enabled checks {len(enabled)}  classes carried {len(carries)}  detector {detector}")
print("intact serving", build_baseline(raw)["baseline_serving"])
print("external service raw delivery type check",
      c_type([dict(k, index=str(k["index"])) for k in raw]))
source 2400 rows = 200 subscribers x 12 periods
app database     delivered 2400 rows  serving True  enabled checks 7  classes carried 3  detector None
log              delivered 2400 rows  serving True  enabled checks 6  classes carried 5  detector None
external service delivered 2400 rows  serving True  enabled checks 7  classes carried 6  detector None
intact serving {'central': 1321.45, 'north': 1212.55, 'east': 881.36, 'west': 833.09, 'south': 1065.55}
external service raw delivery type check True

With an intact delivery the three source classes are indistinguishable: all three give 2400 rows, produce the same five served values, and no check fires in any of them. The only visible difference is that the number of enabled checks in the log drops from seven to six, because there is no reference called an expected row count there.

The last line shows how the source class gets mixed into the check. Because the external service delivers the numeric field as text, the type check is red in all twelve periods while there is no corruption at all. A check that is permanently red is a check that is turned off. The only way to turn it green is permissive conversion in the parser, and the price of that is paid below.

Corruption Profile per Source Class

The block below runs, for each source class, only the corruptions that class can carry.

profile, summary = {}, {}
for name, (u, carries, enabled) in SOURCES.items():
    base = build_baseline(u(raw))
    total, missed, late, silent = 0, 0, 0, 0
    for cls in CORRUPTIONS:
        if cls not in carries:
            continue
        corrupted, n = corrupt(raw, cls)
        delivery = u(corrupted)
        served, detector, lag = run_checks(delivery, base, enabled)
        profile[(name, cls)] = (n, abs(len(delivery) - 2400), detector, lag, served == base["baseline_serving"])
        silent += served == base["baseline_serving"]
        if detector is None:
            missed += 1
        else:
            total, late = total + lag, late + (lag >= 4)
    summary[name] = (len(carries), total, missed, late, silent)

print("corruption                app database                 log    external service")
for cls in CORRUPTIONS:
    h = []
    for name in SOURCES:
        v = profile.get((name, cls))
        h.append("." if v is None else
                 ("not caught" if v[2] is None else f"{v[2]}/{v[3]}"))
    print(f"{cls:<18}{h[0]:>20}{h[1]:>20}{h[2]:>20}")

print("\nsource class      classes  corrupted rows  total lag  not caught  fell to stage 4  serving unchanged")
for name, v in summary.items():
    rows = sum(profile[(name, c)][0] for c in CORRUPTIONS if (name, c) in profile)
    print(f"{name:<16}{v[0]:>9}{rows:>16}{v[1]:>11}{v[2]:>12}{v[3]:>17}{v[4]:>19}")

print("\nrow count check   tolerance 0.0500 -> 120 rows")
print("class               row diff   ratio  tolerance 0.0500  tolerance 0.0100  reference producer")
for cls in ("late_arrival", "duplicated", "missing_partition"):
    t = corrupt(raw, cls)[0]
    f = abs(len(t) - 2400)
    d = ["misses", "catches"]
    print(f"{cls:<18}{f:>10}{f / 2400:>8.4f}"
          f"{d[c_row_count(t, 2400)]:>18}{d[c_row_count(t, 2400, 0.01)]:>18}"
          f"{d[c_row_count(t, len(t), 0.01)]:>20}")
corruption                app database                 log    external service
late_arrival                         .      distribution/4      distribution/4
duplicated                           .        uniqueness/0        uniqueness/0
schema_changed                schema/0                   .            schema/0
unit_changed            distribution/4      distribution/4      distribution/4
missing_partition   partition_period/0  partition_period/0  partition_period/0
type_drift                           .          not caught          not caught

source class      classes  corrupted rows  total lag  not caught  fell to stage 4  serving unchanged
app database            3             446          4           0                1                  1
log                     5             546          8           1                2                  3
external service        6             746          8           1                2                  3

row count check   tolerance 0.0500 -> 120 rows
class               row diff   ratio  tolerance 0.0500  tolerance 0.0100  reference producer
late_arrival              40  0.0167            misses           catches              misses
duplicated                60  0.0250            misses           catches              misses
missing_partition         46  0.0192            misses           catches              misses

Reading the Profile

The first table lets us read what a source class cannot produce. The three dots in the application database row are three guarantees: a consistent read makes the late-arriving row, the primary key makes the duplicated row, and the declared column type makes type drift impossible on the producer side. These guarantees are not free, but they halve the number of classes the pipeline has to measure: from six to three, and the total lag from 8 stages to 4.

The log and the external service make up the same total lag, 8 stages, from different classes. The log does not carry schema change, because the field names are given by the parser, not by the producer; the external service carries all six classes and has the schema change caught at stage 0. A single total number does not justify the choice of a source; the rows of the table do.

The last row is the quietest result of the lesson. Type drift is not caught in either permissive source: 200 rows are corrupted, the parser turns the text into a number, the type check stays green, and the five served values come out exactly the same. It leaves no trace, because the parser has erased the reference of the check that would have seen it. In these two sources the number of classes whose serving does not change is three: duplicated, unit change and type drift.

A Check Whose Reference Comes from the Producer

The third table tests the row count check on its own. The default tolerance is 0.05, that is, 120 rows. The largest of the three classes that change the row count moves only 60 rows, that is, 0.0250: the tolerance is twice the largest signal it wants to measure. This check catches none of the three and stays green through all twelve periods; a check being green does not mean that anything was measured.

Lowering the tolerance to 0.0100 has all three caught at stage 0 and pulls the lag of the late row from four to zero; there is no false-alarm cost either, because the intact delivery is exactly 2400 rows. Still, this fix can be applied to only one of the three sources: the log has no reference, and the external service has one but it is the producer’s own declaration; the service says how many rows it sent, that is what is received, and the ratio comes out 0.0000. The value of a check comes not from its threshold but from where its reference comes from.

Summary

  • A source class is defined not by a product name but by three things: its adapter, the corruption classes it can carry and the checks that have a reference. With an intact delivery all three give the same number; the price shows only when corruption arrives.
  • A guarantee on the producer side lowers the number of classes carried: a declared type, a primary key and a consistent read bring the application database from six classes to three, and its total lag is 4 instead of 8.
  • The log and the external service make up the same total lag from different classes; the choice of source is justified not by the total number but by the rows of the profile.
  • Permissive parsing erases the reference of the type check: 200 rows are corrupted, nothing catches it, and the five served values stay the same; the number of classes that do not change the serving rises to three.
  • The row count check at tolerance 0.05 catches no class, because the largest row diff moves 0.0250; if the reference is the producer’s declaration, no tolerance catches anything.

Next Step

The profile of this lesson was extracted with all twelve periods at hand: the corrupted period was sitting inside the pipeline and the checks could compare it with the remaining eleven periods. A pipeline does not work like that; it is tied to a schedule, and the schedule determines when the corrupted period will become visible. The next lesson runs the same pipeline in two modes: batch mode, which processes the twelve periods in one go, and stream mode, which processes a period when it arrives. The trade-off to be measured is this: in batch mode the corruption becomes visible one period later, in stream mode in the same period; in return, stream mode grows the pipeline’s number of stages and the carried state.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close