- Overview
- API Resources
Load large volumes of historical data into the v2 Supply Chain & Retail Solutions Data Ingestion API.
This page describes how to load large volumes of historical data (millions to billions of rows) into the v2 Data Ingestion API. It provides a reference Python script that loads every table in your solution in a single run and handles streaming, batching, rate limiting, retries, and resume — along with the warehouse-specific configuration you'll need to give it.
Scope. This script is for one-shot historical loads — a single, possibly multi-hour load performed once during onboarding. For ongoing incremental data after that initial load, post new rows directly to POST /api/v2/objects/{objectName} using the contracts in the API Guide. The script's checkpoint is a crash-recovery aid within a single load, not a state machine across separate runs over time.
For everyday low-volume ingestion, the API contracts described in Getting Started and the API Guide are sufficient. Use this page when you have a one-time bulk load to perform.
Before you start
- Your solution's tables have been rolled out (see Schema lifecycle).
- You have a Personal Access Token (see Getting Started).
- Each table you're loading is available as CSV — either a single file, or a folder of CSV part-files (as produced by chunked warehouse exports).
- Every CSV file includes a header row, and the column names match the rolled-out schema (case-insensitive). When the source is a folder, every file in it must share the same header.
- You know whether your tenant is on the hub (
https://ingestion.peak.ai) or on a spoke cluster (https://ingestion.<cluster-identifier>.peak.ai— for examplehttps://ingestion.peaksydney.peak.ai). Your account manager provides the cluster identifier for your tenant. See the warning below.
Spoke tenants must set base_url to their spoke host before running the script. The script defaults base_url to the hub host https://ingestion.peak.ai. A spoke tenant that leaves the default can violate the tenant's data-residency requirements.
Source file format
The script reads CSV files. Headers are required — every CSV file must include a header row with the column names from the rolled-out schema (case-insensitive). The script matches columns by name, so the order of columns in your SELECT doesn't matter and there's no risk of accidentally writing quoted_price values into the discount_pct column. If csv_path is a folder of part-files, every file in it must share the same header.
Beyond headers, two things about your unload can vary, and the script handles both:
- File naming pattern — set
file_globinCONFIGwhencsv_pathis a folder (default*.csv). - Gzip compression — auto-detected from the
.gzextension; no flag needed.
File or folder?
csv_path accepts either form:
- A file path — e.g.
./customers.csv.file_globis ignored. - A folder path — e.g.
./customers/. Every file in the folder matchingfile_globis loaded as if it were one logical CSV. Use this for chunked warehouse exports.
Folder-load file order. When csv_path is a folder, files are loaded in lexicographic (byte-by-byte ASCII) order of their filenames. This is the right order when the names already encode order as zero-padded numeric or ISO-style date prefixes — the form that warehouse exports produce by default.
| Filenames | Lexicographic order | Right? |
|---|---|---|
part-0001.csv, part-0002.csv, …, part-9999.csv | part-0001, part-0002, …, part-9999 | ✓ matches numeric order |
2024-01-15.csv, 2024-02-10.csv, 2024-06-01.csv | 2024-01-15, 2024-02-10, 2024-06-01 | ✓ matches chronological order |
part-1.csv, part-2.csv, …, part-10.csv | part-1, part-10, part-2, … | ✗ 10 sorts before 2 (not zero-padded) |
15-01-2023.csv, 10-02-2024.csv, 01-06-2024.csv (DD-MM-YYYY) | 01-06-2024, 10-02-2024, 15-01-2023 | ✗ day-first puts June 2024 before Jan 2023 |
01-15-2023.csv, 02-10-2024.csv, 06-01-2024.csv (MM-DD-YYYY) | 01-15-2023, 02-10-2024, 06-01-2024 | ✗ year is last, so 2023 and 2024 interleave |
January_report.csv, February_report.csv, March_report.csv | February, January, March | ✗ alphabetical, not chronological |
If file order doesn't matter (every file is an independent batch and rows have no cross-file dependency), this isn't a concern. If file order does matter — for example, you're using APPEND and the row sequence is meaningful, or UPSERT and later files supersede earlier ones — make sure your filenames sort lexicographically into the order you intend. The safe pattern is a zero-padded numeric or ISO-date prefix (e.g., part-0001.csv, 2024-06-01.csv); rename or re-export if your source uses something else.
Fully qualified object_name
object_name must be the exact table name as rolled out — including any prefix or suffix that was applied at rollout. For example, if your rollout applied a prefix acme_ and suffix _v1, the configured object_name is acme_customers_v1, not customers. If your rollout supplied neither a prefix nor a suffix, object_name is just the table name itself (e.g., customers).
To look up the exact value for any table, call GET /api/v2/schema?solutionName=<your-solution> and read the objectName field for each entry — that's the exact string to use in object_name.
From Snowflake
Snowflake COPY INTO @stage defaults to no headers, gzip compression, and \N as the null marker. Add HEADER = TRUE so columns match by name, and NULL_IF = ('') so nulls are written as empty strings instead of \N. (The script handles either marker, but empty strings give a cleaner CSV.)
COPY INTO @your_stage/orders/
FROM (SELECT custcode, addcode, postcode, streetcode, basketvalue FROM orders)
FILE_FORMAT = (TYPE = CSV HEADER = TRUE NULL_IF = (''));
COPY INTO @your_stage/orders/
FROM (SELECT custcode, addcode, postcode, streetcode, basketvalue FROM orders)
FILE_FORMAT = (TYPE = CSV HEADER = TRUE NULL_IF = (''));
Once the unloaded files are downloaded locally, add an entry to CONFIG["tables"] with csv_path set to that folder. Use "*.csv" for file_glob instead if you disabled gzip with COMPRESSION = NONE:
{"object_name": "acme_orders_v1", "csv_path": "./orders/", "file_glob": "*.csv.gz"},
{"object_name": "acme_orders_v1", "csv_path": "./orders/", "file_glob": "*.csv.gz"},
From Redshift
Redshift UNLOAD defaults to no headers, no compression, and no file extension on the part-files. Add HEADER so columns match by name, and EXTENSION 'csv' so files are easy to glob:
UNLOAD ('SELECT custcode, addcode, postcode, streetcode, basketvalue FROM orders')
TO 's3://your-bucket/orders/'
HEADER
FORMAT CSV
EXTENSION 'csv';
UNLOAD ('SELECT custcode, addcode, postcode, streetcode, basketvalue FROM orders')
TO 's3://your-bucket/orders/'
HEADER
FORMAT CSV
EXTENSION 'csv';
Once the unloaded files are downloaded locally, add an entry to CONFIG["tables"] with csv_path set to that folder. "*.csv" is the default file_glob; use "*" if you kept Redshift's extensionless part-files, or "*.gz" if you enabled GZIP:
{"object_name": "acme_orders_v1", "csv_path": "./orders/", "file_glob": "*.csv"},
{"object_name": "acme_orders_v1", "csv_path": "./orders/", "file_glob": "*.csv"},
Null values and bad cells
The script treats a cell as null when, after trimming whitespace, it matches one of these markers (case-insensitively): an empty string, \N (the Redshift UNLOAD and Snowflake COPY INTO defaults), NULL, NA, or N/A. This covers the null representations found in most real-world warehouse exports.
NA and N/A are treated as null. That's convenient for most data, but wrong if a column can legitimately hold those strings — for example, NA is Namibia's ISO country code, and N/A is a valid category label in some datasets. If any column in your load can contain them as real values, edit the NULL_MARKERS set near the top of the script and remove the entries you don't want — whatever you remove is then sent through as that exact string.
The full set of null markers lives in one place so you can adjust it:
NULL_MARKERS = {"", "\\N", "NULL", "NA", "N/A"}
NULL_MARKERS = {"", "\\N", "NULL", "NA", "N/A"}
Uncastable cells are passed through as raw strings rather than crashing the load. If "abc" lands in an integer column, the API returns that single row as a failed row with the appropriate error code (see Validation behavior for the failed-row response shape) — one bad cell becomes one rejected row, not a stopped load.
Character encoding
Customer exports aren't always UTF-8. Before reading each file the script detects its encoding, trying utf-8-sig (UTF-8, with any byte-order mark stripped), then cp1252 (common for Windows-generated files), then latin-1 as a catch-all that decodes any byte sequence.
When a file is decoded as anything other than utf-8-sig, the script logs which encoding it fell back to, so you can spot a mojibake risk early. No configuration is needed.
Run modes and options
Behavior is controlled with command-line flags — you don't edit the CONFIG block to switch modes:
| Flag | Effect |
|---|---|
--mode historical | Default. APPEND operation, batches sent in parallel. |
--mode daily | UPSERT operation, batches sent serially (one at a time, in order). For smaller recurring reloads. |
--table OBJECT_NAME | Load only this one table from CONFIG["tables"] instead of all of them. |
--dry-run | Parse, validate, and batch every table but send nothing to the API. |
--archive | After each table loads successfully, move its source files into archive/<date>/<table>/. |
--mode historical (APPEND, parallel)
For a one-time bulk load, historical mode is the right choice — APPEND is significantly faster than UPSERT when you don't need update-by-primary-key semantics, and parallel workers maximise throughput.
One thing to note about APPEND: if a batch partially succeeds and you re-send the same batch, the rows that already landed will fail with a duplicate-primary-key error. The script's checkpointing handles this for you — a successful batch is never re-sent on a subsequent run.
--mode daily (UPSERT, serial)
Daily mode uses UPSERT, for recurring incremental reloads where the same primary key can reappear and later rows should supersede earlier ones. Batches are sent serially — one at a time, in order — so the last write for a key wins deterministically. (Parallel workers finish in unpredictable order, so a parallel UPSERT of duplicated keys could leave either version in the warehouse; serial avoids that.)
--dry-run
A dry run streams, parses, casts, and batches every table exactly as a real load would — surfacing malformed CSVs, too-wide rows, and encoding problems — but sends nothing and writes no checkpoint or failed-row files. It logs the row and batch count each table would produce.
Use it to validate a fresh set of exports before committing to a multi-hour load. (It still reads your schema, so it needs the token and network access; it makes no writes.)
Loading multiple tables
The script loads every table in CONFIG["tables"] in a single run — you don't invoke it once per table. List one entry per table:
"tables": [
{"object_name": "acme_products_v1", "csv_path": "./products/"},
{"object_name": "acme_orders_v1", "csv_path": "./orders/", "file_glob": "*.csv.gz"},
{"object_name": "acme_order_items_v1", "csv_path": "./order_items/"},
],
"tables": [
{"object_name": "acme_products_v1", "csv_path": "./products/"},
{"object_name": "acme_orders_v1", "csv_path": "./orders/", "file_glob": "*.csv.gz"},
{"object_name": "acme_order_items_v1", "csv_path": "./order_items/"},
],
List order is load order — put parent tables before their children (foreign-key dependency order). In the example above, if order_items references orders and orders references products, listing them products → orders → order_items loads them in the right order.
Loading them out of order causes foreign-key violations in the asynchronous validation stage. Tables load sequentially (one finishes before the next starts); batches within a table run in parallel in historical mode.
To load a single table from the list — for a re-run, or to load them one at a time — pass --table <object_name>.
Reference script
The script is self-contained — paste it into a single Python file, edit the CONFIG block at the top, and run it. It requires only the requests library (pip install requests).
#!/usr/bin/env python3
"""
Bulk-ingest historical CSV data into the Peak Data Ingestion v2 API.
- One run loads every table in CONFIG["tables"], in list order.
- Streams each source row-by-row (no full in-memory load).
- Dynamically sizes batches to <=2000 rows AND <=1 MB serialized.
- Concurrent workers share a token-bucket rate limiter.
- Exponential backoff on 5xx / 429; never retries 4xx.
- Checkpoint lets multi-hour runs resume cleanly after a crash.
- Failed rows written to a JSONL log and a consolidated JSON report.
- Run mode, single-table, dry-run, and archive are chosen with CLI flags.
"""
from __future__ import annotations
import argparse
import csv
import gzip
import json
import logging
import os
import shutil
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any, Iterable
import requests
CONFIG: dict[str, Any] = {
"base_url": "https://ingestion.peak.ai",
"auth_token": os.environ["PEAK_AUTH_TOKEN"],
"solution_name": "your-solution-name",
"tables": [
{"object_name": "acme_customers_v1", "csv_path": "./customers.csv"},
{"object_name": "acme_orders_v1", "csv_path": "./orders/", "file_glob": "*.csv.gz"},
],
}
MAX_ROWS_PER_BATCH = 2000
MAX_BYTES_PER_BATCH = 1_048_576
RATE_LIMIT_RPS = 30
WORKERS = 6
MAX_RETRIES = 5
INITIAL_BACKOFF_SECONDS = 1.0
REQUEST_TIMEOUT_SECONDS = 60
CHECKPOINT_DIR = "./checkpoints"
FAILED_ROWS_DIR = "./failed_rows"
ARCHIVE_DIR = "./archive"
MODES = {
"historical": {"operation_type": "APPEND", "parallel": True},
"daily": {"operation_type": "UPSERT", "parallel": False},
}
NULL_MARKERS = {"", "\\N", "NULL", "NA", "N/A"}
_NULL_MARKERS_LOWER = {m.lower() for m in NULL_MARKERS}
ENCODINGS = ("utf-8-sig", "cp1252", "latin-1")
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("bulk_ingest")
@dataclass
class Run:
"""Everything a single load run needs beyond the per-table config."""
base_url: str
auth_token: str
solution_name: str
operation_type: str
workers: int
dry_run: bool
archive: bool
rate_limiter: "RateLimiter"
class RateLimiter:
def __init__(self, rps: int) -> None:
self.rps = rps
self.tokens = float(rps)
self.last_refill = time.monotonic()
self.lock = threading.Lock()
def acquire(self) -> None:
while True:
with self.lock:
now = time.monotonic()
self.tokens = min(self.rps, self.tokens + (now - self.last_refill) * self.rps)
self.last_refill = now
if self.tokens >= 1:
self.tokens -= 1
return
wait = (1 - self.tokens) / self.rps
time.sleep(wait)
def fetch_schema_types(base_url: str, auth_token: str, solution_name: str) -> dict[str, dict[str, str]]:
url = f"{base_url}/api/v2/schema"
headers = {"Authorization": auth_token}
resp = requests.get(url, params={"solutionName": solution_name}, headers=headers, timeout=30)
resp.raise_for_status()
schema = resp.json()
type_map: dict[str, dict[str, str]] = {}
for table in schema.get("schema", []):
name = table["objectName"].upper()
type_map[name] = {
col["columnName"]: col.get("dataType") or col.get("peakDataType") or "string"
for col in table.get("columns", [])
}
return type_map
_CASTERS = {
"integer": int,
"numeric": float,
"float": float,
"boolean": lambda s: s.lower() in ("true", "1", "t", "yes", "y"),
"json": json.loads,
}
def cast_value(value: str, data_type: str) -> Any:
if value is None:
return None
stripped = value.strip()
if stripped.lower() in _NULL_MARKERS_LOWER:
return None
caster = _CASTERS.get((data_type or "string").lower())
return caster(stripped) if caster else value
def cast_row(row: dict[str, str], column_types: dict[str, str]) -> dict[str, Any]:
"""Cast each cell to its schema type.
If a cell cannot be cast (for example, "abc" in an integer column), the raw
string is passed through unchanged. The API will then surface it as a
structured failed-row error with the right code, rather than the worker
thread crashing.
"""
out: dict[str, Any] = {}
for col, raw in row.items():
try:
out[col] = cast_value(raw, column_types.get(col, "string"))
except (ValueError, TypeError, json.JSONDecodeError):
out[col] = raw
return out
def resolve_csv_paths(csv_path: str, file_glob: str = "*.csv") -> list[str]:
p = Path(csv_path)
if p.is_dir():
files = sorted(str(f) for f in p.glob(file_glob))
if not files:
raise FileNotFoundError(f"no files matching '{file_glob}' in folder: {csv_path}")
return files
return [csv_path]
def detect_encoding(csv_path: str) -> str:
opener = gzip.open if csv_path.endswith(".gz") else open
for enc in ENCODINGS:
try:
with opener(csv_path, "rt", encoding=enc, newline="") as f:
for _ in f:
pass
return enc
except UnicodeDecodeError:
continue
return ENCODINGS[-1]
def iter_batches(
csv_paths: list[str],
column_types: dict[str, str],
max_rows: int,
max_bytes: int,
) -> Iterable[list[dict[str, Any]]]:
batch: list[dict[str, Any]] = []
batch_bytes = 0
for csv_path in csv_paths:
opener = gzip.open if csv_path.endswith(".gz") else open
encoding = detect_encoding(csv_path)
if encoding != ENCODINGS[0]:
log.info("%s: decoded as %s (not %s)", csv_path, encoding, ENCODINGS[0])
with opener(csv_path, "rt", newline="", encoding=encoding) as f:
reader = csv.DictReader(f)
for raw_row in reader:
if None in raw_row:
extras = raw_row[None]
raise RuntimeError(
f"{csv_path}: row has more columns than the header. "
f"Extra cells: {extras!r}. "
f"Check that every row matches the header width."
)
row = cast_row(raw_row, column_types)
row_bytes = len(json.dumps(row, default=str).encode("utf-8"))
if batch and (len(batch) >= max_rows or batch_bytes + row_bytes >= max_bytes):
yield batch
batch, batch_bytes = [], 0
batch.append(row)
batch_bytes += row_bytes
if batch:
yield batch
def ingest_batch(run: Run, object_name: str, batch: list[dict[str, Any]]) -> dict[str, Any]:
url = f"{run.base_url}/api/v2/objects/{object_name}"
headers = {
"Authorization": run.auth_token,
"Content-Type": "application/json",
"accept": "application/json",
}
body = {"solutionName": run.solution_name, "data": batch, "operationType": run.operation_type}
backoff = INITIAL_BACKOFF_SECONDS
for attempt in range(1, MAX_RETRIES + 1):
run.rate_limiter.acquire()
try:
resp = requests.post(url, headers=headers, json=body, timeout=REQUEST_TIMEOUT_SECONDS)
except requests.RequestException as exc:
if attempt == MAX_RETRIES:
raise
log.warning("network error: %s; retry in %.1fs", exc, backoff)
time.sleep(backoff)
backoff *= 2
continue
if resp.status_code in (200, 207, 400):
return resp.json()
if resp.status_code == 429 or 500 <= resp.status_code < 600:
if attempt == MAX_RETRIES:
raise RuntimeError(f"max retries reached ({resp.status_code}): {resp.text[:200]}")
log.warning("status %s; retry in %.1fs (attempt %d/%d)", resp.status_code, backoff, attempt, MAX_RETRIES)
time.sleep(backoff)
backoff *= 2
continue
raise RuntimeError(f"{resp.status_code}: {resp.text[:200]}")
raise RuntimeError("unreachable")
def write_failed_report(failed_jsonl: Path, report_path: Path) -> int:
rows = [json.loads(line) for line in failed_jsonl.read_text().splitlines() if line.strip()]
report_path.write_text(json.dumps(rows, indent=2, default=str))
return len(rows)
def archive_source(csv_paths: list[str], object_name: str) -> Path:
stamp = datetime.now().strftime("%Y-%m-%d")
dest = Path(ARCHIVE_DIR) / stamp / object_name
dest.mkdir(parents=True, exist_ok=True)
for path in csv_paths:
shutil.move(path, str(dest / Path(path).name))
return dest
def load_table(run: Run, table: dict[str, Any], type_map: dict[str, dict[str, str]]) -> None:
object_name = table["object_name"]
file_glob = table.get("file_glob", "*.csv")
csv_paths = resolve_csv_paths(table["csv_path"], file_glob)
log.info("[%s] %d source file(s), glob=%s", object_name, len(csv_paths), file_glob)
column_types = type_map.get(object_name.upper(), {})
if not column_types:
log.warning("[%s] no schema columns found; values pass through as strings", object_name)
if run.dry_run:
rows = batches = 0
for batch in iter_batches(csv_paths, column_types, MAX_ROWS_PER_BATCH, MAX_BYTES_PER_BATCH):
batches += 1
rows += len(batch)
log.info("[%s] DRY RUN: %d rows would be sent in %d batches — nothing written", object_name, rows, batches)
return
Path(CHECKPOINT_DIR).mkdir(parents=True, exist_ok=True)
Path(FAILED_ROWS_DIR).mkdir(parents=True, exist_ok=True)
ckpt_path = Path(CHECKPOINT_DIR) / f"{object_name}.json"
failed_path = Path(FAILED_ROWS_DIR) / f"{object_name}.jsonl"
next_batch = 0
if ckpt_path.exists():
next_batch = json.loads(ckpt_path.read_text()).get("next_batch", 0)
if next_batch > 0:
log.info("[%s] resuming from batch #%d", object_name, next_batch)
pending = [
(i, b) for i, b in enumerate(
iter_batches(csv_paths, column_types, MAX_ROWS_PER_BATCH, MAX_BYTES_PER_BATCH)
) if i >= next_batch
]
if not pending:
log.info("[%s] already complete", object_name)
if run.archive:
log.info("[%s] archived to %s", object_name, archive_source(csv_paths, object_name))
return
log.info("[%s] %d batches remaining", object_name, len(pending))
total_ok = total_failed = 0
ckpt_lock = threading.Lock()
completed: set[int] = set()
to_persist = next_batch
def submit(idx_batch):
idx, batch = idx_batch
return idx, ingest_batch(run, object_name, batch)
with ThreadPoolExecutor(max_workers=run.workers) as pool:
futures = {pool.submit(submit, ib): ib for ib in pending}
for fut in as_completed(futures):
idx, result = fut.result()
ok = result.get("successRows", 0)
failed = result.get("failedRows", 0)
total_ok += ok
total_failed += failed
if result.get("failed"):
with failed_path.open("a") as f:
for row in result["failed"]:
f.write(json.dumps({"batch": idx, **row}) + "\n")
with ckpt_lock:
completed.add(idx)
while to_persist in completed:
completed.remove(to_persist)
to_persist += 1
ckpt_path.write_text(json.dumps({"next_batch": to_persist}))
log.info(
"[%s] batch #%d: %d ok / %d failed (running: %d ok / %d failed)",
object_name, idx, ok, failed, total_ok, total_failed,
)
if failed_path.exists():
report_path = Path(FAILED_ROWS_DIR) / f"{object_name}_failed.json"
count = write_failed_report(failed_path, report_path)
log.info("[%s] %d failed row(s) written to %s", object_name, count, report_path)
log.info("[%s] complete: %d rows ingested, %d failed", object_name, total_ok, total_failed)
if run.archive:
log.info("[%s] archived to %s", object_name, archive_source(csv_paths, object_name))
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Bulk-load historical CSV data into the Peak Data Ingestion v2 API.")
parser.add_argument(
"--mode", choices=sorted(MODES), default="historical",
help="historical = APPEND + parallel (default); daily = UPSERT + serial.",
)
parser.add_argument(
"--table", metavar="OBJECT_NAME",
help="Load only this table from CONFIG['tables']. Default: load them all, in order.",
)
parser.add_argument(
"--dry-run", action="store_true",
help="Parse, validate and batch every table but send nothing to the API.",
)
parser.add_argument(
"--archive", action="store_true",
help="After a table loads successfully, move its source files into archive/<date>/<table>/.",
)
return parser.parse_args()
def main() -> None:
args = parse_args()
cfg = CONFIG
mode = MODES[args.mode]
tables = cfg["tables"]
if args.table:
tables = [t for t in tables if t["object_name"] == args.table]
if not tables:
names = ", ".join(t["object_name"] for t in cfg["tables"])
raise SystemExit(f"--table {args.table!r} not found in CONFIG['tables'] (have: {names})")
log.info("Fetching schema column types for solution %s", cfg["solution_name"])
type_map = fetch_schema_types(cfg["base_url"], cfg["auth_token"], cfg["solution_name"])
log.info("Schema fetched: %d tables", len(type_map))
workers = WORKERS if mode["parallel"] else 1
run = Run(
base_url=cfg["base_url"],
auth_token=cfg["auth_token"],
solution_name=cfg["solution_name"],
operation_type=mode["operation_type"],
workers=workers,
dry_run=args.dry_run,
archive=args.archive,
rate_limiter=RateLimiter(RATE_LIMIT_RPS),
)
log.info(
"Mode: %s (%s, %s)%s",
args.mode,
run.operation_type,
f"{workers} workers" if workers > 1 else "serial — batches applied in order",
" [DRY RUN]" if run.dry_run else "",
)
for table in tables:
log.info("=" * 70)
log.info("Loading %s from %s", table["object_name"], table["csv_path"])
log.info("=" * 70)
load_table(run, table, type_map)
log.info("Done.")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Bulk-ingest historical CSV data into the Peak Data Ingestion v2 API.
- One run loads every table in CONFIG["tables"], in list order.
- Streams each source row-by-row (no full in-memory load).
- Dynamically sizes batches to <=2000 rows AND <=1 MB serialized.
- Concurrent workers share a token-bucket rate limiter.
- Exponential backoff on 5xx / 429; never retries 4xx.
- Checkpoint lets multi-hour runs resume cleanly after a crash.
- Failed rows written to a JSONL log and a consolidated JSON report.
- Run mode, single-table, dry-run, and archive are chosen with CLI flags.
"""
from __future__ import annotations
import argparse
import csv
import gzip
import json
import logging
import os
import shutil
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any, Iterable
import requests
CONFIG: dict[str, Any] = {
"base_url": "https://ingestion.peak.ai",
"auth_token": os.environ["PEAK_AUTH_TOKEN"],
"solution_name": "your-solution-name",
"tables": [
{"object_name": "acme_customers_v1", "csv_path": "./customers.csv"},
{"object_name": "acme_orders_v1", "csv_path": "./orders/", "file_glob": "*.csv.gz"},
],
}
MAX_ROWS_PER_BATCH = 2000
MAX_BYTES_PER_BATCH = 1_048_576
RATE_LIMIT_RPS = 30
WORKERS = 6
MAX_RETRIES = 5
INITIAL_BACKOFF_SECONDS = 1.0
REQUEST_TIMEOUT_SECONDS = 60
CHECKPOINT_DIR = "./checkpoints"
FAILED_ROWS_DIR = "./failed_rows"
ARCHIVE_DIR = "./archive"
MODES = {
"historical": {"operation_type": "APPEND", "parallel": True},
"daily": {"operation_type": "UPSERT", "parallel": False},
}
NULL_MARKERS = {"", "\\N", "NULL", "NA", "N/A"}
_NULL_MARKERS_LOWER = {m.lower() for m in NULL_MARKERS}
ENCODINGS = ("utf-8-sig", "cp1252", "latin-1")
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("bulk_ingest")
@dataclass
class Run:
"""Everything a single load run needs beyond the per-table config."""
base_url: str
auth_token: str
solution_name: str
operation_type: str
workers: int
dry_run: bool
archive: bool
rate_limiter: "RateLimiter"
class RateLimiter:
def __init__(self, rps: int) -> None:
self.rps = rps
self.tokens = float(rps)
self.last_refill = time.monotonic()
self.lock = threading.Lock()
def acquire(self) -> None:
while True:
with self.lock:
now = time.monotonic()
self.tokens = min(self.rps, self.tokens + (now - self.last_refill) * self.rps)
self.last_refill = now
if self.tokens >= 1:
self.tokens -= 1
return
wait = (1 - self.tokens) / self.rps
time.sleep(wait)
def fetch_schema_types(base_url: str, auth_token: str, solution_name: str) -> dict[str, dict[str, str]]:
url = f"{base_url}/api/v2/schema"
headers = {"Authorization": auth_token}
resp = requests.get(url, params={"solutionName": solution_name}, headers=headers, timeout=30)
resp.raise_for_status()
schema = resp.json()
type_map: dict[str, dict[str, str]] = {}
for table in schema.get("schema", []):
name = table["objectName"].upper()
type_map[name] = {
col["columnName"]: col.get("dataType") or col.get("peakDataType") or "string"
for col in table.get("columns", [])
}
return type_map
_CASTERS = {
"integer": int,
"numeric": float,
"float": float,
"boolean": lambda s: s.lower() in ("true", "1", "t", "yes", "y"),
"json": json.loads,
}
def cast_value(value: str, data_type: str) -> Any:
if value is None:
return None
stripped = value.strip()
if stripped.lower() in _NULL_MARKERS_LOWER:
return None
caster = _CASTERS.get((data_type or "string").lower())
return caster(stripped) if caster else value
def cast_row(row: dict[str, str], column_types: dict[str, str]) -> dict[str, Any]:
"""Cast each cell to its schema type.
If a cell cannot be cast (for example, "abc" in an integer column), the raw
string is passed through unchanged. The API will then surface it as a
structured failed-row error with the right code, rather than the worker
thread crashing.
"""
out: dict[str, Any] = {}
for col, raw in row.items():
try:
out[col] = cast_value(raw, column_types.get(col, "string"))
except (ValueError, TypeError, json.JSONDecodeError):
out[col] = raw
return out
def resolve_csv_paths(csv_path: str, file_glob: str = "*.csv") -> list[str]:
p = Path(csv_path)
if p.is_dir():
files = sorted(str(f) for f in p.glob(file_glob))
if not files:
raise FileNotFoundError(f"no files matching '{file_glob}' in folder: {csv_path}")
return files
return [csv_path]
def detect_encoding(csv_path: str) -> str:
opener = gzip.open if csv_path.endswith(".gz") else open
for enc in ENCODINGS:
try:
with opener(csv_path, "rt", encoding=enc, newline="") as f:
for _ in f:
pass
return enc
except UnicodeDecodeError:
continue
return ENCODINGS[-1]
def iter_batches(
csv_paths: list[str],
column_types: dict[str, str],
max_rows: int,
max_bytes: int,
) -> Iterable[list[dict[str, Any]]]:
batch: list[dict[str, Any]] = []
batch_bytes = 0
for csv_path in csv_paths:
opener = gzip.open if csv_path.endswith(".gz") else open
encoding = detect_encoding(csv_path)
if encoding != ENCODINGS[0]:
log.info("%s: decoded as %s (not %s)", csv_path, encoding, ENCODINGS[0])
with opener(csv_path, "rt", newline="", encoding=encoding) as f:
reader = csv.DictReader(f)
for raw_row in reader:
if None in raw_row:
extras = raw_row[None]
raise RuntimeError(
f"{csv_path}: row has more columns than the header. "
f"Extra cells: {extras!r}. "
f"Check that every row matches the header width."
)
row = cast_row(raw_row, column_types)
row_bytes = len(json.dumps(row, default=str).encode("utf-8"))
if batch and (len(batch) >= max_rows or batch_bytes + row_bytes >= max_bytes):
yield batch
batch, batch_bytes = [], 0
batch.append(row)
batch_bytes += row_bytes
if batch:
yield batch
def ingest_batch(run: Run, object_name: str, batch: list[dict[str, Any]]) -> dict[str, Any]:
url = f"{run.base_url}/api/v2/objects/{object_name}"
headers = {
"Authorization": run.auth_token,
"Content-Type": "application/json",
"accept": "application/json",
}
body = {"solutionName": run.solution_name, "data": batch, "operationType": run.operation_type}
backoff = INITIAL_BACKOFF_SECONDS
for attempt in range(1, MAX_RETRIES + 1):
run.rate_limiter.acquire()
try:
resp = requests.post(url, headers=headers, json=body, timeout=REQUEST_TIMEOUT_SECONDS)
except requests.RequestException as exc:
if attempt == MAX_RETRIES:
raise
log.warning("network error: %s; retry in %.1fs", exc, backoff)
time.sleep(backoff)
backoff *= 2
continue
if resp.status_code in (200, 207, 400):
return resp.json()
if resp.status_code == 429 or 500 <= resp.status_code < 600:
if attempt == MAX_RETRIES:
raise RuntimeError(f"max retries reached ({resp.status_code}): {resp.text[:200]}")
log.warning("status %s; retry in %.1fs (attempt %d/%d)", resp.status_code, backoff, attempt, MAX_RETRIES)
time.sleep(backoff)
backoff *= 2
continue
raise RuntimeError(f"{resp.status_code}: {resp.text[:200]}")
raise RuntimeError("unreachable")
def write_failed_report(failed_jsonl: Path, report_path: Path) -> int:
rows = [json.loads(line) for line in failed_jsonl.read_text().splitlines() if line.strip()]
report_path.write_text(json.dumps(rows, indent=2, default=str))
return len(rows)
def archive_source(csv_paths: list[str], object_name: str) -> Path:
stamp = datetime.now().strftime("%Y-%m-%d")
dest = Path(ARCHIVE_DIR) / stamp / object_name
dest.mkdir(parents=True, exist_ok=True)
for path in csv_paths:
shutil.move(path, str(dest / Path(path).name))
return dest
def load_table(run: Run, table: dict[str, Any], type_map: dict[str, dict[str, str]]) -> None:
object_name = table["object_name"]
file_glob = table.get("file_glob", "*.csv")
csv_paths = resolve_csv_paths(table["csv_path"], file_glob)
log.info("[%s] %d source file(s), glob=%s", object_name, len(csv_paths), file_glob)
column_types = type_map.get(object_name.upper(), {})
if not column_types:
log.warning("[%s] no schema columns found; values pass through as strings", object_name)
if run.dry_run:
rows = batches = 0
for batch in iter_batches(csv_paths, column_types, MAX_ROWS_PER_BATCH, MAX_BYTES_PER_BATCH):
batches += 1
rows += len(batch)
log.info("[%s] DRY RUN: %d rows would be sent in %d batches — nothing written", object_name, rows, batches)
return
Path(CHECKPOINT_DIR).mkdir(parents=True, exist_ok=True)
Path(FAILED_ROWS_DIR).mkdir(parents=True, exist_ok=True)
ckpt_path = Path(CHECKPOINT_DIR) / f"{object_name}.json"
failed_path = Path(FAILED_ROWS_DIR) / f"{object_name}.jsonl"
next_batch = 0
if ckpt_path.exists():
next_batch = json.loads(ckpt_path.read_text()).get("next_batch", 0)
if next_batch > 0:
log.info("[%s] resuming from batch #%d", object_name, next_batch)
pending = [
(i, b) for i, b in enumerate(
iter_batches(csv_paths, column_types, MAX_ROWS_PER_BATCH, MAX_BYTES_PER_BATCH)
) if i >= next_batch
]
if not pending:
log.info("[%s] already complete", object_name)
if run.archive:
log.info("[%s] archived to %s", object_name, archive_source(csv_paths, object_name))
return
log.info("[%s] %d batches remaining", object_name, len(pending))
total_ok = total_failed = 0
ckpt_lock = threading.Lock()
completed: set[int] = set()
to_persist = next_batch
def submit(idx_batch):
idx, batch = idx_batch
return idx, ingest_batch(run, object_name, batch)
with ThreadPoolExecutor(max_workers=run.workers) as pool:
futures = {pool.submit(submit, ib): ib for ib in pending}
for fut in as_completed(futures):
idx, result = fut.result()
ok = result.get("successRows", 0)
failed = result.get("failedRows", 0)
total_ok += ok
total_failed += failed
if result.get("failed"):
with failed_path.open("a") as f:
for row in result["failed"]:
f.write(json.dumps({"batch": idx, **row}) + "\n")
with ckpt_lock:
completed.add(idx)
while to_persist in completed:
completed.remove(to_persist)
to_persist += 1
ckpt_path.write_text(json.dumps({"next_batch": to_persist}))
log.info(
"[%s] batch #%d: %d ok / %d failed (running: %d ok / %d failed)",
object_name, idx, ok, failed, total_ok, total_failed,
)
if failed_path.exists():
report_path = Path(FAILED_ROWS_DIR) / f"{object_name}_failed.json"
count = write_failed_report(failed_path, report_path)
log.info("[%s] %d failed row(s) written to %s", object_name, count, report_path)
log.info("[%s] complete: %d rows ingested, %d failed", object_name, total_ok, total_failed)
if run.archive:
log.info("[%s] archived to %s", object_name, archive_source(csv_paths, object_name))
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Bulk-load historical CSV data into the Peak Data Ingestion v2 API.")
parser.add_argument(
"--mode", choices=sorted(MODES), default="historical",
help="historical = APPEND + parallel (default); daily = UPSERT + serial.",
)
parser.add_argument(
"--table", metavar="OBJECT_NAME",
help="Load only this table from CONFIG['tables']. Default: load them all, in order.",
)
parser.add_argument(
"--dry-run", action="store_true",
help="Parse, validate and batch every table but send nothing to the API.",
)
parser.add_argument(
"--archive", action="store_true",
help="After a table loads successfully, move its source files into archive/<date>/<table>/.",
)
return parser.parse_args()
def main() -> None:
args = parse_args()
cfg = CONFIG
mode = MODES[args.mode]
tables = cfg["tables"]
if args.table:
tables = [t for t in tables if t["object_name"] == args.table]
if not tables:
names = ", ".join(t["object_name"] for t in cfg["tables"])
raise SystemExit(f"--table {args.table!r} not found in CONFIG['tables'] (have: {names})")
log.info("Fetching schema column types for solution %s", cfg["solution_name"])
type_map = fetch_schema_types(cfg["base_url"], cfg["auth_token"], cfg["solution_name"])
log.info("Schema fetched: %d tables", len(type_map))
workers = WORKERS if mode["parallel"] else 1
run = Run(
base_url=cfg["base_url"],
auth_token=cfg["auth_token"],
solution_name=cfg["solution_name"],
operation_type=mode["operation_type"],
workers=workers,
dry_run=args.dry_run,
archive=args.archive,
rate_limiter=RateLimiter(RATE_LIMIT_RPS),
)
log.info(
"Mode: %s (%s, %s)%s",
args.mode,
run.operation_type,
f"{workers} workers" if workers > 1 else "serial — batches applied in order",
" [DRY RUN]" if run.dry_run else "",
)
for table in tables:
log.info("=" * 70)
log.info("Loading %s from %s", table["object_name"], table["csv_path"])
log.info("=" * 70)
load_table(run, table, type_map)
log.info("Done.")
if __name__ == "__main__":
main()
Running the script
- Save the script as
bulk_ingest.py. - Install the dependency:
pip install requests. - Export your Personal Access Token:
export PEAK_AUTH_TOKEN=.... - Edit the
CONFIGblock:- Set
solution_nameand thetableslist (one entry per table, in foreign-key dependency order). - Set
file_globon an entry if that table's source is a folder with non-default file names. - If your tenant is on a spoke cluster, set
base_urlto your spoke host (https://ingestion.<cluster-identifier>.peak.ai) — see the spoke base-URL warning above.
- Set
- Validate first with a dry run:
python bulk_ingest.py --dry-run. - Run it:
python bulk_ingest.py(add--mode daily,--table <name>, or--archiveas needed — see Run modes and options).
Result
The script logs progress per batch, table by table. You can stop it (Ctrl-C) and resume it later — it picks up from the last successfully-persisted batch index.
Rows the API rejects during the load are captured two ways, per table, in the failed_rows/ directory:
failed_rows/<table>.jsonl— the durable, crash-safe log, appended one line per rejected row as the load runs.failed_rows/<table>_failed.json— a consolidated JSON array written when the table finishes, for quickly eyeballing everything that failed and why.
Asynchronous validation failures (foreign-key violations, primary-key collisions against pre-existing data) don't appear in either file — they show up in the Data Quality Dashboard instead.
With --archive, each table's source files are moved into archive/<date>/<table>/ once it loads successfully, leaving you an empty source folder to drop the next batch of files into and a dated record of what was already ingested. (Re-running an archived table reports "no files matching" — that's expected; its files have moved.)
Resuming after a crash
If the script stops mid-load — a network blip, a laptop reboot, anything — just run it again and it picks up from where it left off. No rows are re-sent and none are dropped.
Two files persist between runs to make that work:
checkpoints/<table>.json— tracks how far the load got, so the script knows where to resume.failed_rows/<table>.jsonl— appended to each time the API rejects a row.
On resume, the script reads the checkpoint, skips the batches it already completed, and continues from there. You'll see [<table>] resuming from batch #N in the logs at startup.
If you want a clean restart — for example, you've truncated the warehouse table and want to load from scratch — delete the state files first. Replace <table> with the object_name from your CONFIG (e.g., acme_customers_v1):
rm checkpoints/<table>.json failed_rows/<table>.jsonl failed_rows/<table>_failed.json
python bulk_ingest.py
rm checkpoints/<table>.json failed_rows/<table>.jsonl failed_rows/<table>_failed.json
python bulk_ingest.py
Or wipe all tables' state at once if you're re-loading several:
rm -rf checkpoints failed_rows
rm -rf checkpoints failed_rows
Editing the source CSV between runs is not supported. The checkpoint tracks batch indices, not row content — so if you add or modify rows in the source file and re-run, the changes can be silently skipped. For example, adding one row to a CSV that previously fit in a single batch leaves the new row in batch #0, which the checkpoint marks "skip." If you need to add or modify rows after a load has completed, post them directly to POST /api/v2/objects/{objectName} rather than re-running the script with the edited file.
Tips for multi-day runs
- Run from a stable host. A laptop that sleeps or moves between networks will interrupt the run; the script will resume cleanly, but you'll lose elapsed time. If you're running on a remote host over SSH, use
nohupor a terminal multiplexer (tmux,screen) so the script survives the session dropping. - Monitor disk space. Failed-row files grow with the volume of validation failures. For a dataset with 1% failure rate across 100M rows, that's 1M JSONL entries.
- Investigate sustained errors early. If you see persistent
5xxresponses or sudden throughput drops, stop the run and submit a support ticket before exhausting retries. A dataset of this size is easier to debug at hour 2 than at hour 20. - Treat the failed-row files as a second-pass workload. After the bulk load completes, fix the data issues surfaced in the
failed_rows/directory and re-submit those rows in a smaller load. Read<table>_failed.jsonto triage what went wrong; each entry captures the row as the API saw it — extract the row payload, fix the offending field(s), and submit a small batch viaPOST /api/v2/objects/{objectName}directly (see the API Guide for the request shape). - Watch for encoding fallbacks. If the logs show a file being
decoded as cp1252orlatin-1rather than UTF-8, spot-check a few rows of that table in the warehouse for garbled characters — a mis-detected legacy encoding is the most likely cause of mojibake.
- Before you start
- Source file format
- File or folder?
- Fully qualified
object_name - From Snowflake
- From Redshift
- Null values and bad cells
- Character encoding
- Run modes and options
--mode historical(APPEND, parallel)--mode daily(UPSERT, serial)--dry-run- Loading multiple tables
- Reference script
- Running the script
- Result
- Resuming after a crash
- Tips for multi-day runs