Pipeline to Dashboardnihalsulaiman.com

Week 1 · Ingest anything › Day 3

Day 3. Landing files from folders and shared drives

⏱ 4h · learn 60m · lab 140m · ship 25m · review 15m🏷 QSR (shared drive POS exports)Before: Day 2

By the end of today you can

  • Build an idempotent file landing step with a manifest table
  • Use content hashes to detect duplicates and changed re-sends
  • Avoid reading half-written files
  • Explain why you never move or delete files in someone else's folder

1. Why this matters

The most common data source in medium-sized companies is still a folder: a network share, a OneDrive/SharePoint library synced to a server, a vendor's export directory. Folder ingestion looks trivial and is where most silent errors live: the file read twice, the file read while still being written, the file replaced with a corrected version under the same name.

2. The landing pattern

  1. Discover: list candidate files (glob), skip anything that isn't finished.
  2. Fingerprint: compute a SHA-256 of the bytes.
  3. Check the manifest: have we already landed this exact content? If yes, skip and log duplicate.
  4. Copy into the raw zone at a path that encodes source and business date: raw/qsr/business_date=2026-09-14/pos_Q204__<sha8>.csv.
  5. Record a manifest row: source, original path, size, mtime, sha256, received_at, raw_path, status.

You copy, never move. The source folder belongs to someone else; moving files breaks their process and destroys your ability to re-check.

import hashlib, shutil, sqlite3, time
from pathlib import Path

def sha256(path: Path, chunk=1 << 20) -> str:
    h = hashlib.sha256()
    with path.open("rb") as f:
        for block in iter(lambda: f.read(chunk), b""):
            h.update(block)
    return h.hexdigest()

def is_stable(path: Path, wait=2.0) -> bool:
    """A file still being written changes size. Skip partials and temp names."""
    if path.suffix in {".partial", ".tmp", ".crdownload"} or path.name.startswith("~$"):
        return False
    s1 = path.stat().st_size; time.sleep(wait); s2 = path.stat().st_size
    return s1 == s2 and s1 > 0

def land(src_dir: Path, raw_dir: Path, db: sqlite3.Connection, source: str):
    db.execute("""create table if not exists manifest(
        sha256 text primary key, source text, src_path text, size int, mtime real,
        received_at text, raw_path text)""")
    for p in sorted(src_dir.rglob("*.csv")):
        if not is_stable(p):
            continue
        digest = sha256(p)
        if db.execute("select 1 from manifest where sha256=?", (digest,)).fetchone():
            continue                                  # exact same bytes already landed
        business_date = p.parent.name                 # folder is the date here
        dest = raw_dir / source / f"business_date={business_date}" / f"{p.stem}__{digest[:8]}{p.suffix}"
        dest.parent.mkdir(parents=True, exist_ok=True)
        tmp = dest.with_suffix(dest.suffix + ".tmp")
        shutil.copy2(p, tmp); tmp.replace(dest)       # atomic rename: readers never see half a file
        db.execute("insert into manifest values (?,?,?,?,?,datetime('now'),?)",
                   (digest, source, str(p), p.stat().st_size, p.stat().st_mtime, str(dest)))
        db.commit()
Maths and principle

Principle: an operation is idempotent if doing it twice has the same effect as doing it once. Landing keyed by content hash is idempotent.

Equation: \(f(f(x)) = f(x)\). With a manifest \(M\): \(\text{land}(M, F) = M \cup \{h(F)\}\), and \(M \cup \{h\} \cup \{h\} = M \cup \{h\}\) because a set ignores repeats.

Analogy: a set union in maths, or pressing an elevator button twice. Where it breaks: if the same business data arrives with different bytes (a re-export with a new timestamp column), the hash differs. Then you need a second, business-level key (source + store + business date) and a rule: "latest version wins".

Try it: a folder has 18 files; 2 are byte-identical re-sends of yesterday's. How many manifest rows after two runs today? (16 new from today, total grows by 16; reruns add 0.)

3. Same name, new content

Vendors often fix a file and re-drop it under the same name. Your manifest will see a new hash for an existing (source, src_path). Decide the rule explicitly and write it down:

Situation Hash Name Rule
Exact re-send same any skip
Correction new same land as new version; staging takes the latest by received_at
New day new new land

The 5-year lens

Never trust mtime alone. Copy tools, sync clients (OneDrive, rsync without -t) and unzip all rewrite it. Never trust file name dates alone either: managers rename files. Record all three (name, mtime, received_at) and choose one business date rule per source, written in the README. When numbers disagree six months later, the manifest is how you prove which version fed which report.

Mapped drives and service accounts

A scheduled job usually runs as a service account, not as you. Z:\ mapped in your session doesn't exist for it. Use UNC paths (\\fileserver\exports\qsr) and make sure the service account has read-only access. Read-only is a feature: it physically prevents you from deleting the source.

4. Watching vs polling

You can poll (list the folder every N minutes) or watch (OS events via the watchdog library). Polling is simpler, works on network shares where events are unreliable, and is what Dagster sensors do in Week 3. Use watching only for local disks with latency needs under a minute.

Lab: land the QSR shared drive

  1. In retail-ingest, create src/retail_ingest/sources/folder.py with sha256, is_stable, land (use wait=0.2 locally).
  2. Run it over data/landing/shared_drive/qsr. Count manifest rows; it should equal the number of files.
  3. Run it again. Zero new rows. Write a test that proves this (tests/test_folder_idempotent.py) using tmp_path.
  4. Simulate a correction: append a line to one source file and rerun. Confirm a second version lands with a new hash.
  5. Simulate a half-written file: create pos_Q999.csv.partial; confirm it is skipped. Then generate a file while your landing loop runs (write slowly in a loop) and confirm is_stable holds it back.
  6. Switch the manifest from SQLite to DuckDB (duckdb.connect("data/manifest.duckdb")) and query: files per day, total bytes, any day with fewer than 18 files.

Ship

feat(ingest): idempotent folder landing with sha256 manifest with the two tests passing.

Check yourself

1. Why copy files to raw instead of moving them out of the shared folder?

2. A file has the same name as yesterday's but a different hash. What is the safest default?

3. Why write to a temp name and then rename?

My notes