Week 1 · Ingest anything › Day 3
Day 3. Landing files from folders and shared drives
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
- Discover: list candidate files (
glob), skip anything that isn't finished. - Fingerprint: compute a SHA-256 of the bytes.
- Check the manifest: have we already landed this exact content? If yes, skip and log
duplicate. - 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. - 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
- In
retail-ingest, createsrc/retail_ingest/sources/folder.pywithsha256,is_stable,land(usewait=0.2locally). - Run it over
data/landing/shared_drive/qsr. Count manifest rows; it should equal the number of files. - Run it again. Zero new rows. Write a test that proves this (
tests/test_folder_idempotent.py) usingtmp_path. - Simulate a correction: append a line to one source file and rerun. Confirm a second version lands with a new hash.
- 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 confirmis_stableholds it back. - 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?
Treat sources as read-only. Your copy plus the manifest is your evidence.
2. A file has the same name as yesterday's but a different hash. What is the safest default?
Raw keeps every version. Staging applies the "latest wins" rule, and you can audit what changed.
3. Why write to a temp name and then rename?
Your own downstream steps get the same protection you want from the source.