“The most effective debugging tool is still careful thought, coupled with judiciously placed print statements.”
— Brian Kernighan, Unix for Beginners (1979)
Summary
Bash automation is the Linux-first layer between orchestration and raw command execution in data platforms.
Use these scripts to validate inbound data, reshape extracts, call APIs, and guard scheduled jobs against common failure modes.
Expect explicit checks for schema drift, nulls, duplicate keys, checksum mismatches, lagging consumers, and low-disk conditions.
Treat this page as Bash-first reference material; use the PowerShell companion page when the runtime is Windows-native.
Glossary
Bash script (.sh)
A plain-text file containing commands executed by the Bash interpreter, usually launched through bash script.sh or directly when the file is executable.
The standard unit of Linux and WSL automation for file handling, job wrappers, API calls, and operational glue.
Shebang (#!/usr/bin/env bash)
The first line that selects the interpreter which should execute the script.
env resolves bash from PATH, which is more portable than hard-coding /bin/bash.
set -euo pipefail
A strict-mode header that stops on unhandled command failures, treats unset variables as errors, and propagates failure from any stage in a pipeline.
It is the baseline safety rail for production shell automation because it prevents silent continuation on bad state.
$?
The exit code from the most recently completed command.
Use it or immediate if command; then ... fi checks when the next step must react to native process success or failure.
trap
A shell mechanism for running cleanup logic when the script exits or receives a signal.
It is the Bash equivalent of guaranteed cleanup blocks and is the right place to remove temp files, unlock state, or stop helper processes.
curl
A command-line HTTP client used for REST calls, downloads, bearer-token requests, and webhook posts.
In Bash automation it usually provides transport, while Python or other tools handle JSON parsing when the payload is non-trivial.
NDJSON
Newline-delimited JSON, where each line is an independent JSON document.
It is useful for streaming, append-only logs, and ingestion formats that do not require the entire dataset to be materialized as one array.
Exponential backoff
A retry strategy that increases the wait time after each failed attempt.
It reduces pressure on unstable upstream systems and is the standard defensive pattern for transient API and network failures.
flock
A kernel-backed file lock utility from util-linux.
It prevents overlapping runs of the same scheduled job without the stale-lock problems of ad hoc PID files.
sqlcmd / SQLCMD.EXE
Microsoft’s command-line SQL Server client.
In this WSL environment the live examples call the Windows SQLCMD.EXE binary because native Linux sqlcmd is not installed.
cron
The standard Unix scheduler for time-based unattended execution.
It runs with a minimal environment, so scripts that work interactively can still fail under cron if they assume profile state, PATH, or working-directory defaults.
Bash is one of the four core languages of the data engineer alongside SQL, Python, and a JVM language. These scripts automate the repetitive, error-prone tasks that sit between pipeline orchestration and raw shell commands: validating incoming files, transforming formats, querying APIs, checking database health, managing cloud resources, parsing logs, and wiring up scheduling.
Every script in this page follows the defensive scripting patterns documented in defensive-scripting and uses the command chaining operators explained in command-chaining. The PowerShell equivalent of every script exists at powershell-automation.
These Bash examples were executed from WSL against live local and cloud resources. GCP sections export CLOUDSDK_CONFIG='/mnt/c/Users/aperi/AppData/Roaming/gcloud' so WSL reuses the Windows Cloud SDK profile, and SQL Server sections call /mnt/c/Program Files/Microsoft SQL Server/Client SDK/ODBC/180/Tools/Binn/SQLCMD.EXE because that is the installed client available to WSL on this host.
The catalog below follows the same path most data jobs do: validate the input, reshape it, call external systems, verify the load, and then harden the runtime around retries and scheduling.
flowchart LR
A[File Intake<br>& Validation] --> B[Data<br>Transformation]
B --> C[API<br>Interaction]
C --> D[Database<br>Operations]
D --> E[GCP Cloud<br>Operations]
E --> F[Log Parsing<br>& Monitoring]
F --> G[Environment<br>& Pre-flight]
G --> H[Scheduling<br>& Orchestration]
style A fill:#292e42,stroke:#7aa2f7
style B fill:#292e42,stroke:#7aa2f7
style C fill:#292e42,stroke:#7aa2f7
style D fill:#292e42,stroke:#7aa2f7
style E fill:#292e42,stroke:#9ece6a
style F fill:#292e42,stroke:#9ece6a
style G fill:#292e42,stroke:#9ece6a
style H fill:#292e42,stroke:#9ece6a
File intake and validation
Incoming data is the single largest source of pipeline failures. A file that arrives with missing columns, null values in mandatory fields, or duplicate keys will propagate errors silently through every downstream transformation. These scripts catch problems at the gate, before any processing begins.
Validation scripts
These examples validate the real fixture files under C:\Users\aperi\My Drive\VAULT\data\powershell-automation\incoming and ...\landing. The commands run in WSL, but they operate on the same shared vault data the PowerShell note uses.
CSV header validator
Use this before any transform or load accepts a new file. It is typically triggered when an incoming CSV must prove that its column contract still matches the expected schema before downstream processing continues. This script compares the header row of the sampled signals_daily file against the golden schema file and stops immediately on any mismatch.
Header parsing must be schema-aware
This check is trustworthy only when the parser understands CSV quoting and encoding. A byte-level split can misclassify a valid file as drifted, or miss a malformed first column.
Naive comma split
This ignores BOM handling and quoted delimiters in the header row.
header=$(head -n 1 "$CSV_FILE")IFS=',' read -r -a columns <<< "$header"
CSV-aware header read
Read the header with csv.reader and newline='' so the validator compares parsed column names rather than raw bytes.
Compare the sampled signals_daily CSV header in incoming against the golden schema file in schemas.
#!/usr/bin/env bashset -euo pipefailDATA_ROOT="/mnt/c/Users/aperi/My Drive/VAULT/data/powershell-automation"SCHEMA_FILE="$DATA_ROOT/schemas/signals_daily_header.csv"CSV_FILE="$DATA_ROOT/incoming/signals_daily_sample.csv"expected=$(python3 - "$SCHEMA_FILE" <<'PY'import csv, syswith open(sys.argv[1], newline='', encoding='utf-8') as handle: print(",".join(next(csv.reader(handle))))PY)actual=$(python3 - "$CSV_FILE" <<'PY'import csv, syswith open(sys.argv[1], newline='', encoding='utf-8') as handle: print(",".join(next(csv.reader(handle))))PY)if [[ "$expected" != "$actual" ]]; then echo "HEADER MISMATCH in $(basename "$CSV_FILE")" exit 1fiecho "OK - headers match schema for $(basename "$CSV_FILE")"
OK - headers match schema for signals_daily_sample.csv
Null and empty field scanner
Use this before any transform or load accepts a new file. It is typically triggered when an incoming file must prove row-level completeness before downstream processing continues. The script scans the intentionally broken signals_daily_missing.csv fixture and reports every row where symbol or recommendation_mean is blank.
Scan the broken signals_daily_missing.csv fixture for empty symbol and recommendation_mean fields.
#!/usr/bin/env bashset -euo pipefailDATA_ROOT="/mnt/c/Users/aperi/My Drive/VAULT/data/powershell-automation"CSV_FILE="$DATA_ROOT/incoming/signals_daily_missing.csv"python3 - "$CSV_FILE" <<'PY'import csv, syscsv_file = sys.argv[1]mandatory = ("symbol", "recommendation_mean")violations = 0with open(csv_file, newline='', encoding='utf-8') as handle: for row_num, row in enumerate(csv.DictReader(handle), start=2): for column in mandatory: if not (row.get(column) or "").strip(): print(f"Row {row_num}: column '{column}' is empty") violations += 1if violations: raise SystemExit(1)print("OK - no null values in mandatory columns")PY
Row 5: column 'recommendation_mean' is emptyRow 9: column 'symbol' is empty
Duplicate key detector
Use this before any transform or load accepts a new file. It is typically triggered when the target table expects a unique business key and duplicates must be rejected early. This script reads the duplicate-symbol fixture and reports any repeated symbol values before a database or warehouse load is attempted.
Group the duplicate-symbol fixture and fail when a signals_daily symbol appears more than once.
#!/usr/bin/env bashset -euo pipefailDATA_ROOT="/mnt/c/Users/aperi/My Drive/VAULT/data/powershell-automation"CSV_FILE="$DATA_ROOT/incoming/signals_daily_duplicate.csv"python3 - "$CSV_FILE" <<'PY'import csv, sysfrom collections import Counterwith open(sys.argv[1], newline='', encoding='utf-8') as handle: counts = Counter(row["symbol"] for row in csv.DictReader(handle))dupes = sorted((symbol, count) for symbol, count in counts.items() if count > 1)if dupes: print("DUPLICATE KEYS in column 'symbol':") for symbol, count in dupes: print(f" {symbol} ({count} occurrences)") print(f"Total duplicated values: {len(dupes)}") raise SystemExit(1)print("OK - no duplicate keys in column 'symbol'")PY
Use this before any transform or load depends on a landing-zone drop. It is typically triggered when a scheduled ingest needs to prove that the expected file has arrived recently enough to satisfy the upstream SLA. This script refreshes the sample landing file timestamp, searches for signals_daily_*.csv, and reports the newest matching file.
Check that the landing folder contains a fresh signals_daily_*.csv drop within the last 60 minutes.
OK - 1 file(s) found, newest: signals_daily_20260414.csv
Data transformation
Once a file passes validation, it often needs reshaping before it can be loaded into a target system. These scripts handle the most common format conversions and structural changes that data engineers perform daily: selecting columns, splitting oversized files, and converting between CSV and JSON-oriented formats.
Transformation scripts
These examples operate on the live CSV and JSON fixtures in the vault data directory. Python handles the structured parsing because the runtime here does not have jq, and the goal is a reliable WSL workflow rather than a contrived pure-awk parser for quoted CSV.
CSV column extractor and reorderer
Use this after validation and before the target load step. It is typically triggered when a validated dataset must be reshaped into the subset and order that the next system expects. This script projects four warehouse-facing columns from the sampled signals_daily extract into a new CSV under transformed.
Project four destination columns from the sampled signals_daily extract into signals_daily_projection.csv.
#!/usr/bin/env bashset -euo pipefailDATA_ROOT="/mnt/c/Users/aperi/My Drive/VAULT/data/powershell-automation"SOURCE_FILE="$DATA_ROOT/incoming/signals_daily_sample.csv"OUTPUT_FILE="$DATA_ROOT/transformed/signals_daily_projection.csv"python3 - "$SOURCE_FILE" "$OUTPUT_FILE" <<'PY'import csv, syssource_file, output_file = sys.argv[1:3]columns = ["symbol", "signal_date", "current_price", "upside_potential"]with open(source_file, newline='', encoding='utf-8') as source_handle: rows = list(csv.DictReader(source_handle))with open(output_file, "w", newline='', encoding='utf-8') as output_handle: writer = csv.DictWriter(output_handle, fieldnames=columns) writer.writeheader() writer.writerows({column: row[column] for column in columns} for row in rows)print(f"OK - wrote {len(rows)} rows with {len(columns)} columns to {output_file.rsplit('/', 1)[-1]}")PY
OK - wrote 12 rows with 4 columns to signals_daily_projection.csv
Large CSV splitter
Use this after validation and before the target load step. It is typically triggered when a validated dataset is too large to load comfortably as one file or when retryable chunking is required. This script splits the full data/signals_daily.csv extract into 200-row chunks and preserves the header row in every chunk under split.
Split the full data/signals_daily.csv extract into 200-row chunks under data/powershell-automation/split.
#!/usr/bin/env bashset -euo pipefailVAULT_DATA="/mnt/c/Users/aperi/My Drive/VAULT/data"DATA_ROOT="$VAULT_DATA/powershell-automation"SOURCE_FILE="$VAULT_DATA/signals_daily.csv"SPLIT_DIR="$DATA_ROOT/split"CHUNK_SIZE=200rm -f "$SPLIT_DIR"/signals_daily_*.csvheader=$(head -n 1 "$SOURCE_FILE")tail -n +2 "$SOURCE_FILE" | split -l "$CHUNK_SIZE" -d --additional-suffix=.csv - "$SPLIT_DIR/signals_daily_"for chunk in "$SPLIT_DIR"/signals_daily_*.csv; do tmp_file=$(mktemp) printf '%s\n' "$header" > "$tmp_file" cat "$chunk" >> "$tmp_file" mv "$tmp_file" "$chunk"donechunk_count=$(find "$SPLIT_DIR" -maxdepth 1 -type f -name 'signals_daily_*.csv' | wc -l | tr -d ' ')echo "OK - split into $chunk_count chunks of up to $CHUNK_SIZE rows each"
OK - split into 3 chunks of up to 200 rows each
JSON to CSV flattener
Use this after validation and before the target load step. It is typically triggered when the source is a local JSON array but the next load step expects CSV. This script converts the dim_country_sample.json fixture into a flat CSV with the same two fields used later in the Firestore example.
Flatten the local country JSON array into transformed/dim_country_sample.csv.
#!/usr/bin/env bashset -euo pipefailDATA_ROOT="/mnt/c/Users/aperi/My Drive/VAULT/data/powershell-automation"JSON_FILE="$DATA_ROOT/json/dim_country_sample.json"OUTPUT_FILE="$DATA_ROOT/transformed/dim_country_sample.csv"python3 - "$JSON_FILE" "$OUTPUT_FILE" <<'PY'import csv, json, sysjson_file, output_file = sys.argv[1:3]with open(json_file, encoding='utf-8') as handle: rows = json.load(handle)columns = list(rows[0].keys())with open(output_file, "w", newline='', encoding='utf-8') as output_handle: writer = csv.DictWriter(output_handle, fieldnames=columns) writer.writeheader() writer.writerows(rows)print(f"OK - wrote {len(rows)} rows with {len(columns)} columns to {output_file.rsplit('/', 1)[-1]}")PY
OK - wrote 8 rows with 2 columns to dim_country_sample.csv
CSV to NDJSON converter
Use this after validation and before a consumer expects line-delimited JSON. It is typically triggered when a CSV extract must be turned into a streaming-friendly interchange format for downstream tooling. This script converts the sampled signals_daily CSV into one JSON document per line under transformed.
Convert the sampled CSV into signals_daily_sample.ndjson with one object per line.
#!/usr/bin/env bashset -euo pipefailDATA_ROOT="/mnt/c/Users/aperi/My Drive/VAULT/data/powershell-automation"CSV_FILE="$DATA_ROOT/incoming/signals_daily_sample.csv"OUTPUT_FILE="$DATA_ROOT/transformed/signals_daily_sample.ndjson"python3 - "$CSV_FILE" "$OUTPUT_FILE" <<'PY'import csv, json, syscsv_file, output_file = sys.argv[1:3]with open(csv_file, newline='', encoding='utf-8') as input_handle, open(output_file, "w", encoding='utf-8') as output_handle: rows = list(csv.DictReader(input_handle)) for row in rows: output_handle.write(json.dumps(row, separators=(",", ":")) + "\n")print(f"OK - wrote {len(rows)} NDJSON records to {output_file.rsplit('/', 1)[-1]}")PY
OK - wrote 12 NDJSON records to signals_daily_sample.ndjson
API interaction
Data pipelines frequently pull data from REST APIs: warehouse metadata endpoints, cloud-control APIs, SaaS services, and internal application surfaces. These scripts handle the recurring mechanics around those calls: retries, pagination, bearer-token lifecycle, and checksum validation.
API scripts
The live API examples here use Google Cloud endpoints because they are already available in the target environment. WSL reuses the Windows Cloud SDK credentials, and Python handles the JSON decoding that would normally be delegated to jq on a Linux host where jq is installed.
REST GET with retry and backoff
Use this when a script needs one read-only API response but cannot afford to fail on the first transient HTTP issue. It is typically triggered when metadata or control-plane state must be fetched before the next step can continue. This example calls the BigQuery table metadata endpoint for stoxx_silver.signals_daily, retries on non-2xx responses, and saves the response body locally.
Retry scope must stay idempotent
This wrapper is appropriate for read-only metadata calls. Once the same pattern is copied to state-changing endpoints, retries can duplicate writes, webhook effects, or load submissions.
Blind POST retries
A timeout after the server commits the write still looks like a local failure and can trigger a duplicate submission.
for attempt in 1 2 3; do curl -sS -X POST "$URL" -d "$payload" && breakdone
Bounded GET retries
Keep automatic retries on idempotent GET calls and stop after a defined retry budget.
Fetch live BigQuery table metadata with retry logic and save the response to signals_daily_table.json.
#!/usr/bin/env bashset -euo pipefailexport CLOUDSDK_CONFIG='/mnt/c/Users/aperi/AppData/Roaming/gcloud'DATA_ROOT="/mnt/c/Users/aperi/My Drive/VAULT/data/powershell-automation"OUTPUT_FILE="$DATA_ROOT/api/signals_daily_table.json"TOKEN="$(gcloud auth print-access-token)"URL='https://bigquery.googleapis.com/bigquery/v2/projects/bq-wh-nb/datasets/stoxx_silver/tables/signals_daily'MAX_RETRIES=3attempt=0delay=1while (( attempt < MAX_RETRIES )); do http_code=$(curl -sS -o "$OUTPUT_FILE" -w '%{http_code}' -H "Authorization: Bearer $TOKEN" "$URL") || http_code=000 if [[ "$http_code" == 2* ]]; then echo "OK - HTTP $http_code after $((attempt + 1)) attempt(s)" echo "Saved response to $(basename "$OUTPUT_FILE")" exit 0 fi attempt=$((attempt + 1)) echo "Attempt $attempt/$MAX_RETRIES failed (HTTP $http_code), retrying in ${delay}s..." sleep "$delay" delay=$((delay * 2))doneecho "FAILED - all $MAX_RETRIES attempts exhausted, last HTTP $http_code"exit 1
OK - HTTP 200 after 1 attempt(s)Saved response to signals_daily_table.json
Paginated API fetcher
Use this when the API returns only part of the result set in each response. It is typically triggered when table lists, audit logs, or catalog endpoints page through a large collection that must be collected before downstream logic can reason about the whole dataset. This example walks the BigQuery tables list endpoint for stoxx_silver with maxResults=2, follows nextPageToken, and merges all pages into one local JSON file.
Fetch the stoxx_silver BigQuery table list across multiple pages and write the merged result to stoxx_silver_tables.json.
#!/usr/bin/env bashset -euo pipefailexport CLOUDSDK_CONFIG='/mnt/c/Users/aperi/AppData/Roaming/gcloud'DATA_ROOT="/mnt/c/Users/aperi/My Drive/VAULT/data/powershell-automation"OUTPUT_FILE="$DATA_ROOT/api/stoxx_silver_tables.json"TOKEN="$(gcloud auth print-access-token)"BASE_URL='https://bigquery.googleapis.com/bigquery/v2/projects/bq-wh-nb/datasets/stoxx_silver/tables?maxResults=2'page_token=''page=0tmp_dir=$(mktemp -d)trap 'rm -rf "$tmp_dir"' EXITwhile true; do page=$((page + 1)) url="$BASE_URL" if [[ -n "$page_token" ]]; then url="${url}&pageToken=${page_token}" fi response_file="$tmp_dir/page_${page}.json" curl -sS -H "Authorization: Bearer $TOKEN" "$url" -o "$response_file" readarray -t parsed < <(python3 - "$response_file" <<'PY'import json, syswith open(sys.argv[1], encoding='utf-8') as handle: payload = json.load(handle)tables = payload.get("tables", [])print(len(tables))print(payload.get("nextPageToken", ""))PY ) table_count="${parsed[0]}" page_token="${parsed[1]}" if [[ -n "$page_token" ]]; then echo "Page $page fetched, $table_count table(s), nextPageToken returned" else echo "Page $page fetched, $table_count table(s)" fi [[ -z "$page_token" ]] && breakdonepython3 - "$tmp_dir" "$OUTPUT_FILE" <<'PY'import glob, json, os, systmp_dir, output_file = sys.argv[1:3]rows = []for path in sorted(glob.glob(os.path.join(tmp_dir, "page_*.json"))): with open(path, encoding='utf-8') as handle: payload = json.load(handle) rows.extend(payload.get("tables", []))with open(output_file, "w", encoding='utf-8') as handle: json.dump(rows, handle, indent=2)PYtotal=$(python3 - "$OUTPUT_FILE" <<'PY'import json, syswith open(sys.argv[1], encoding='utf-8') as handle: print(len(json.load(handle)))PY)echo "OK - fetched $page page(s), $total total records to $(basename "$OUTPUT_FILE")"
Page 1 fetched, 2 table(s), nextPageToken returnedPage 2 fetched, 2 table(s), nextPageToken returnedPage 3 fetched, 2 table(s)OK - fetched 3 page(s), 6 total records to stoxx_silver_tables.json
Bearer token refresh wrapper
Use this when an API client must survive token expiry across scheduled runs. It is typically triggered when a wrapper script needs cached credentials for repeat calls but still has to refresh before the token becomes invalid. This example keeps a token cache file under api, refreshes it from gcloud auth print-access-token when missing or near expiry, and then calls the BigQuery dataset list endpoint.
Refresh the local bearer-token cache if needed and list the available BigQuery datasets in bq-wh-nb.
Use this when a remote artifact is required locally and corruption must be detected before any consumer touches the file. It is typically triggered when a dataset, model artifact, or export must be downloaded and verified as a byte-for-byte match against an expected digest. This example downloads the live GCS export through the storage media API, decompresses it into a CSV, and verifies the resulting SHA-256 checksum against the saved digest file under api.
Download the exported eurostoxx50_ohlcv object, decompress it locally, and verify the CSV checksum.
OK - downloaded eurostoxx50_ohlcv.csv (4682 bytes), checksum verified
Database operations
Every data pipeline eventually touches a database: running health checks, exporting a result set, or confirming that a load landed exactly as expected. In this environment the live database is the stoxx SQL Server instance running in the stoxx-db container, and the Bash examples call Windows SQLCMD.EXE from WSL.
Database scripts
These scripts use the real SQL files under data/powershell-automation/sql and the live SQL Server listener on localhost,1434. They are read-only except where the later data-movement section intentionally creates or truncates demo load tables.
Database connectivity health check
Use this before a job depends on SQL Server for export or validation. It is typically triggered when the runtime must prove that the target database is reachable before spending time on upstream work. This example times a trivial query against stoxx and reports the round-trip latency.
Execute a one-row health query against the live stoxx SQL Server instance and time the response.
Use this when SQL Server is the source system and the next step expects a portable file rather than an interactive result set. It is typically triggered by an extract, handoff, or validation workflow that needs the query results as CSV on disk. This example runs the saved stoxx_eurostoxx_latest.sql query, cleans the sqlcmd text output, and writes a real CSV under exports.
Export switches can alter the data contract
sqlcmd formatting flags are useful only when the exported values allow them. -W changes trailing-space semantics, so the safest export shape depends on the downstream contract.
Trim by default
This is unsafe for fixed-width extracts or CHAR columns where right-padding still carries meaning.
Drop -W when padding matters and validate field widths explicitly after the export.
"$SQLCMD" -s"," -i "$SQL_FILE_WIN" > "$OUTPUT_FILE"python3 - "$OUTPUT_FILE" <<'PY'import csv, syswith open(sys.argv[1], newline='', encoding='utf-8') as handle: print(max(len(row[0]) for row in csv.reader(handle)))PY
Run the saved stoxx_eurostoxx_latest.sql query against stoxx and export the result set to CSV.
#!/usr/bin/env bashset -euo pipefailDATA_ROOT="/mnt/c/Users/aperi/My Drive/VAULT/data/powershell-automation"SQLCMD="/mnt/c/Program Files/Microsoft SQL Server/Client SDK/ODBC/180/Tools/Binn/SQLCMD.EXE"SQL_FILE_WIN='C:\Users\aperi\My Drive\VAULT\data\powershell-automation\sql\stoxx_eurostoxx_latest.sql'OUTPUT_FILE="$DATA_ROOT/exports/stoxx_eurostoxx_latest.csv""$SQLCMD" -S localhost,1434 -d stoxx -U sa -P 'EsgDev2026Pass1' -C -W -s"," -i "$SQL_FILE_WIN" | \python3 - "$OUTPUT_FILE" <<'PY'import csv, sysoutput_file = sys.argv[1]lines = [line.rstrip("\r\n") for line in sys.stdin if line.strip()]rows = []for line in lines: parts = line.split(",") if all(part and set(part) <= {"-"} for part in parts): continue rows.append(parts)with open(output_file, "w", newline="", encoding="utf-8") as handle: writer = csv.writer(handle) writer.writerows(rows)data_rows = max(len(rows) - 1, 0)column_count = len(rows[0]) if rows else 0print(f"OK - exported {data_rows} rows with {column_count} columns to {output_file.rsplit('/', 1)[-1]}")PY
OK - exported 12 rows with 4 columns to stoxx_eurostoxx_latest.csv
Row count reconciliation
Use this immediately after an export or load when row preservation matters more than raw task completion. It is typically triggered when the workflow must prove that the file on disk and the SQL query used to validate it still agree on row count. This example compares the CSV exported above with the saved count query under sql.
Matching counts can still hide drift
Count parity proves only that both sides have the same number of rows. It does not prove that keys, dates, or measures still match.
Count-only approval
This passes even when duplicated keys or shifted measures keep the row count unchanged.
[[ "$file_rows" == "$db_rows" ]]
Count plus control totals
Pair the count check with a control total or key-level reconciliation over business columns.
file_total=$(python3 - "$CSV_FILE" <<'PY'import csv, syswith open(sys.argv[1], newline='', encoding='utf-8') as handle: print(sum(float(row["current_price"]) for row in csv.DictReader(handle)))PY)db_total=$("$SQLCMD" -S localhost,1434 -d stoxx -U sa -P 'EsgDev2026Pass1' -C -h -1 -W -Q "SET NOCOUNT ON; SELECT SUM(current_price) FROM silver.eurostoxx50_ohlcv WHERE signal_date = '2026-03-04';" | tr -d '\r' | awk 'NF {print $1; exit}')[[ "$file_rows" == "$db_rows" && "$file_total" == "$db_total" ]]
Compare the exported CSV row count to the saved SQL count query for the same silver.eurostoxx50_ohlcv slice.
#!/usr/bin/env bashset -euo pipefailDATA_ROOT="/mnt/c/Users/aperi/My Drive/VAULT/data/powershell-automation"SQLCMD="/mnt/c/Program Files/Microsoft SQL Server/Client SDK/ODBC/180/Tools/Binn/SQLCMD.EXE"CSV_FILE="$DATA_ROOT/exports/stoxx_eurostoxx_latest.csv"COUNT_SQL_WIN='C:\Users\aperi\My Drive\VAULT\data\powershell-automation\sql\stoxx_eurostoxx_latest_count.sql'file_rows=$(python3 - "$CSV_FILE" <<'PY'import csv, syswith open(sys.argv[1], newline='', encoding='utf-8') as handle: print(sum(1 for _ in csv.DictReader(handle)))PY)db_rows=$("$SQLCMD" -S localhost,1434 -d stoxx -U sa -P 'EsgDev2026Pass1' -C -h -1 -W -i "$COUNT_SQL_WIN" | tr -d '\r' | awk 'NF {print $1; exit}')if [[ "$file_rows" != "$db_rows" ]]; then echo "ROW COUNT MISMATCH" echo "Source file: $file_rows rows" echo "Target query: $db_rows rows" exit 1fiecho "OK - $file_rows rows in stoxx_eurostoxx_latest.csv match $db_rows rows returned by stoxx_eurostoxx_latest_count.sql"
OK - 12 rows in stoxx_eurostoxx_latest.csv match 12 rows returned by stoxx_eurostoxx_latest_count.sql
GCP cloud operations
These scripts automate the most common Google Cloud Platform tasks that data engineers perform outside of orchestration tools. In this WSL environment the Google CLI tools come from the Windows Cloud SDK installation, so every example exports the Windows SDK config path before making live calls into bq-wh-nb.
Cloud automation scripts
These examples use the actual project resources available to the vault: stoxx-stage-bucket, stoxx-bq-bucket, the stoxx_* BigQuery datasets, and the Eventarc-created Pub/Sub subscription. The outputs below are not placeholders; they were captured from live commands running against those resources.
GCS stale object reporter
Use this when a bucket needs a retention or hygiene check before more data is staged into it. It is typically triggered when a project bucket accumulates exports or intermediate objects and operators need a fast view of which ones are older than policy allows. This script lists objects in gs://stoxx-bq-bucket/export that are more than one day old.
List GCS export objects older than one day in gs://stoxx-bq-bucket/export.
gs://stoxx-bq-bucket/export/eurostoxx50_ohlcv-000000000000.csv.gz 1548 bytes 2 days oldgs://stoxx-bq-bucket/export/eurostoxx50_ohlcv-000000000000.parquet 7155 bytes 2 days old--- Objects older than 1 day(s) listed above ---
GCS stage and promote with checksum verification
Use this before a file leaves the landing zone and becomes visible to downstream BigQuery loads or other consumers. It is typically triggered when a local extract or transformed file is ready to publish into the project buckets but must be verified before promotion. This script uploads the sample CSV to stoxx-stage-bucket, compares the local and remote MD5 digests, then copies the verified object into stoxx-bq-bucket.
Upload the sample CSV to the stage bucket, verify the checksum, and promote the verified object into the production bucket.
Local MD5: ed8c817608799befe9121aae5a40e7b1Stage object: powershell-automation/signals_daily_sample.csv generation 1776201035250237 md5 ed8c817608799befe9121aae5a40e7b1Promote object: powershell-automation/signals_daily_sample.csv generation 1776201081528618 md5 ed8c817608799befe9121aae5a40e7b1Checksum verified across stage and promoted copies.
BigQuery dry-run cost estimator
Use this before any non-trivial BigQuery statement runs in a scheduled or operator-driven workflow. It is typically triggered when a query touches a production dataset and cost or partition discipline must be validated before execution. This example dry-runs the saved bq_signals_latest.sql statement and calculates the on-demand scan estimate.
Dry-run the saved BigQuery statement in sql/bq_signals_latest.sql and estimate the bytes scanned before execution.
BigQuery load job with polling and row-count verification
Use this after a staged object has passed checksum verification and is ready to enter a BigQuery dataset. It is typically triggered when a batch file is present in GCS and the next workflow step is to load it into BigQuery without guessing whether the job finished cleanly. This example starts an asynchronous load into stoxx_bronze.powershell_automation_signals_load, polls the job state, and then verifies row count and date range with the saved SQL file.
Production loads need a pinned schema
--autodetect is useful for ad hoc or lab loads. Production feeds should fail on contract change, not reinterpret the file shape during ingestion.
Autodetect the contract
A producer-side type change or extra column can silently alter the loaded schema.
Poll 1 - state DONEJobId: bqjob_r2d0b164c0bc35f72_0000019d8dd39bbf_1Loaded rows: 12Signal date range: 2026-03-04 to 2026-03-04Distinct symbols: 12
BigQuery schema drift checker
Use this immediately before a load job or schema-sensitive transform that expects a stable file contract. It is typically triggered when a producer changes a header row or a target table evolves in BigQuery. This example compares the drifted local header file against the live schema for bq-wh-nb:stoxx_silver.signals_daily and reports missing or extra columns explicitly.
Compare a drifted local header file to the live stoxx_silver.signals_daily schema and emit a drift result.
#!/usr/bin/env bashset -euo pipefailexport CLOUDSDK_CONFIG='/mnt/c/Users/aperi/AppData/Roaming/gcloud'HEADER_FILE='/mnt/c/Users/aperi/My Drive/VAULT/data/powershell-automation/schemas/signals_daily_drift_header.csv'TABLE_ID='bq-wh-nb:stoxx_silver.signals_daily'python3 - "$HEADER_FILE" "$(bq show --project_id=bq-wh-nb --format=json "$TABLE_ID")" <<'PY'import csv, io, json, syswith open(sys.argv[1], newline='', encoding='utf-8') as handle: file_columns = next(csv.reader(handle))table_columns = [field["name"] for field in json.loads(sys.argv[2])["schema"]["fields"]]missing_in_file = [column for column in table_columns if column not in file_columns]extra_in_file = [column for column in file_columns if column not in table_columns]if not missing_in_file and not extra_in_file: print("OK - schema matches bq-wh-nb:stoxx_silver.signals_daily") raise SystemExit(0)print("DRIFT - schema mismatch against bq-wh-nb:stoxx_silver.signals_daily")print("Missing in file: " + (", ".join(missing_in_file) if missing_in_file else "<none>"))print("Extra in file: " + (", ".join(extra_in_file) if extra_in_file else "<none>"))raise SystemExit(1)PY
DRIFT - schema mismatch against bq-wh-nb:stoxx_silver.signals_dailyMissing in file: <none>Extra in file: ingested_at
BigQuery table freshness checker
Use this on a schedule after ingestion windows close or before dependent marts assume the latest business date is available. It is typically triggered when data readiness is defined by date lag rather than by raw job completion. This example runs the saved freshness query against stoxx_silver.signals_daily, compares the lag to a seven-day threshold, and emits a pass/fail status.
Evaluate the saved freshness query and fail only when the live lag exceeds the configured SLA threshold.
OK - stoxx_silver.signals_daily latest signal_date 2026-04-08 is 6 day(s) old (threshold: 7)Rows monitored: 635
Pub/Sub backlog monitor
Use this when a single current backlog value is enough to decide whether a subscriber is healthy. It is typically triggered by an operational check that needs to know whether a consumer is currently behind before the pipeline continues. This example reads the live num_undelivered_messages metric for the Eventarc subscription through the Cloud Monitoring API.
Read the live Pub/Sub backlog metric for the Eventarc subscription from Cloud Monitoring.
OK - eventarc-europe-west1-stoxx-firestore-control-written-sub-850: 0 undelivered messages
Pub/Sub backlog trend monitor
Use this when one backlog point is not enough and the operator needs to know whether the subscription is building debt over time. It is typically triggered when transient spikes are common and the check should alert only on sustained lag. This example reads a six-hour aligned history from Cloud Monitoring, summarizes the sample count, max backlog, average backlog, and non-zero samples, and only alerts on a persistent pattern.
Read the aligned backlog history for the Eventarc subscription and summarize whether the backlog is sustained or transient.
#!/usr/bin/env bashset -euo pipefailexport CLOUDSDK_CONFIG='/mnt/c/Users/aperi/AppData/Roaming/gcloud'PROJECT_ID='bq-wh-nb'SUBSCRIPTION_ID='eventarc-europe-west1-stoxx-firestore-control-written-sub-850'WINDOW_HOURS=6THRESHOLD=10SUSTAINED_SAMPLES=3TOKEN="$(gcloud auth print-access-token)"start_time=$(date -u -d "${WINDOW_HOURS} hours ago" +%Y-%m-%dT%H:%M:%SZ)end_time=$(date -u +%Y-%m-%dT%H:%M:%SZ)filter="metric.type=\"pubsub.googleapis.com/subscription/num_undelivered_messages\" AND resource.labels.subscription_id=\"${SUBSCRIPTION_ID}\""escaped_filter=$(python3 - <<'PY' "$filter"import sys, urllib.parseprint(urllib.parse.quote(sys.argv[1], safe=''))PY)uri="https://monitoring.googleapis.com/v3/projects/${PROJECT_ID}/timeSeries?filter=${escaped_filter}&interval.startTime=${start_time}&interval.endTime=${end_time}&view=FULL&pageSize=1&aggregation.alignmentPeriod=300s&aggregation.perSeriesAligner=ALIGN_MAX"response_json=$(curl -sS -H "Authorization: Bearer $TOKEN" "$uri")readarray -t metrics < <(python3 - <<'PY' "$response_json"import json, syspayload = json.loads(sys.argv[1])points = payload.get("timeSeries", [{}])[0].get("points", [])points = sorted(points, key=lambda point: point["interval"]["endTime"])values = [int(point["value"]["int64Value"]) for point in points] or [0]sample_count = len(points) if points else 1latest_point = points[-1]["interval"]["endTime"] if points else "1970-01-01T00:00:00Z"latest_value = values[-1]max_value = max(values)avg_value = sum(values) / len(values)non_zero = sum(1 for value in values if value > 0)print(sample_count)print(latest_point)print(latest_value)print(max_value)print(int(avg_value) if avg_value.is_integer() else round(avg_value, 2))print(non_zero)PY)sample_count="${metrics[0]}"latest_point="${metrics[1]}"latest_value="${metrics[2]}"max_backlog="${metrics[3]}"avg_backlog="${metrics[4]}"non_zero_samples="${metrics[5]}"echo "Window: $WINDOW_HOURS hour(s), samples: $sample_count"echo "Latest point: $latest_point backlog $latest_value"echo "Max backlog: $max_backlog, average backlog: $avg_backlog, non-zero samples: $non_zero_samples"if (( max_backlog > THRESHOLD && non_zero_samples >= SUSTAINED_SAMPLES )); then echo "ALERT - sustained backlog detected for $SUBSCRIPTION_ID" exit 1fiecho "OK - no sustained backlog detected for $SUBSCRIPTION_ID"
Window: 6 hour(s), samples: 1Latest point: 2026-04-14T21:13:16Z backlog 0Max backlog: 0, average backlog: 0, non-zero samples: 0OK - no sustained backlog detected for eventarc-europe-west1-stoxx-firestore-control-written-sub-850
Service account key age checker
Use this when the project needs a quick credential-rotation audit. It is typically triggered by a periodic security check or by troubleshooting a service account with long-lived user-managed keys. This example lists the keys on bq-wh-sa@bq-wh-nb.iam.gserviceaccount.com, compares their creation times to a 20-day threshold, and prints whether each key should be rotated.
Keys should be the exception
User-managed keys technically work, but they create a credential that can be copied outside IAM controls and linger in scripts, workstations, or CI caches.
Create and export a key file
This moves the credential boundary from IAM into filesystem hygiene and secret-distribution discipline.
gcloud iam service-accounts keys create sa-key.json --iam-account="$SERVICE_ACCOUNT"export GOOGLE_APPLICATION_CREDENTIALS=sa-key.json
Impersonate at runtime
Prefer ephemeral credentials that are minted when needed and never written as reusable key files.
Inspect the user-managed keys on the project service account and flag keys older than 20 days.
#!/usr/bin/env bashset -euo pipefailexport CLOUDSDK_CONFIG='/mnt/c/Users/aperi/AppData/Roaming/gcloud'SERVICE_ACCOUNT='bq-wh-sa@bq-wh-nb.iam.gserviceaccount.com'MAX_AGE_DAYS=20keys_json=$(gcloud iam service-accounts keys list --iam-account="$SERVICE_ACCOUNT" --project=bq-wh-nb --format=json)python3 - <<'PY' "$keys_json" "$MAX_AGE_DAYS"import datetime as dtimport jsonimport syskeys = json.loads(sys.argv[1])max_age_days = int(sys.argv[2])cutoff = dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=max_age_days)for key in sorted((item for item in keys if item["keyType"] == "USER_MANAGED"), key=lambda item: item["validAfterTime"]): created = dt.datetime.fromisoformat(key["validAfterTime"].replace("Z", "+00:00")) key_id = key["name"].split("/")[-1][:12] status = "ROTATE" if created < cutoff else "OK" print(f"{status} - key {key_id}... created {key['validAfterTime']}")PY
ROTATE - key 3166c79513e7... created 2026-03-22T16:27:38ZOK - key b228f14a7cc8... created 2026-04-05T07:34:04Z
Data movement pipelines
Most production automation moves files between systems more often than it performs complicated in-memory transformations. These patterns show the handoff points explicitly: a local file published to GCS, a local file loaded straight into BigQuery, a host file streamed into SQL Server, a JSON file upserted into Firestore, and a chained pipeline that crosses all four targets in sequence.
Destination loads
These examples start from local files under C:\Users\aperi\My Drive\VAULT\data\powershell-automation. Each script finishes with a live destination-side check so the movement step proves that the target now contains the expected data instead of only assuming the upload succeeded.
Local file to GCS object
Use this when a local export, transformed file, or partner drop must be made available to cloud consumers through a bucket path. It is typically triggered when a Bash run has produced a file on the host and the next stage expects a GCS object instead of a local path. This example uploads the projection CSV into stoxx-stage-bucket and then reads the object metadata back from GCS.
Upload the local projection CSV into stoxx-stage-bucket and confirm the created object metadata.
Use this when a small or medium file already exists on the host and you want an immediate table load without first staging it in GCS. It is typically triggered when a Bash job has produced a CSV locally and the next step is an agent-local BigQuery load. This example loads signals_daily_projection.csv straight into stoxx_bronze.powershell_automation_local_file_load and verifies the destination table with the saved SQL file.
Load the local projection CSV directly into BigQuery and verify the resulting table.
Use this when SQL Server is the immediate next system but the source file exists only on the host running WSL. It is typically triggered when a CSV extract has landed on the runner and the target SQL Server instance cannot read that host path directly. This example prepares dbo.powershell_automation_local_file_load, generates INSERT statements from the projection CSV, executes them through SQLCMD.EXE, and then validates the result with the saved verification query.
Stream the local projection CSV into stoxx.dbo.powershell_automation_local_file_load and verify the loaded rows.
Use this when the destination is a document store and the source file already exists as local JSON on the runner. It is typically triggered when a process has produced a small dimension, control, or status file that should become Firestore documents. This example reads dim_country_sample.json, upserts one document per iso_alpha2 value into Firestore Native, and then checks the live collection count through the REST API.
Upsert the local country JSON file into the powershell_automation_country_load collection and confirm the live document count.
Source rows: 8Documents in collection: 8Collection: powershell_automation_country_load
Chained pipelines
Real orchestration usually crosses multiple systems in one run. The key is to make each handoff explicit, persist intermediate artifacts where they matter, and validate every destination before advancing to the next hop.
GCS to SQL Server to BigQuery to Firestore
Use this when one automation run must ingest a staged cloud file, land it in SQL Server, publish a relational summary into BigQuery, and expose the run result as a Firestore document. It is typically triggered when a bucket object has arrived and the operational requirement is a multi-system handoff rather than a single-target load. This example downloads the staged CSV, loads it into stoxx, exports a one-row summary to CSV, loads that summary into BigQuery, and then patches the Firestore run-status document.
Multi-hop loads need a shared run ID
Once a chain touches four systems, a partial success is an operational state, not an edge case. Without a shared identifier, replay, cleanup, and audit become guesswork.
Hop-local writes
Each destination is updated independently, so later operators cannot prove which BigQuery table and Firestore document belong to the same run.
Pipeline logs contain the earliest signal of problems: error spikes, latency changes, and unexpected operational patterns. These scripts extract actionable information from the flat log and NDJSON fixtures under data/powershell-automation/logs without requiring a separate observability stack.
Log analysis scripts
The outputs below come from the real fixture files used by the PowerShell note. Bash uses grep, find, gzip, and Python JSON parsing here because those are the tools actually present in the WSL environment.
Error rate calculator
Use this when a run has produced a flat log file and the next decision is whether the error rate is high enough to page or investigate. It is typically triggered during quick triage after a pipeline or automation wrapper finishes. This example counts ERROR, WARN, and INFO lines in pipeline.log and raises an alert when the error rate exceeds five percent.
Calculate the severity distribution in pipeline.log and alert on an elevated error rate.
OK - loaded 5 variable(s) from powershell-automation.env
Disk space pre-flight
Use this immediately before the job commits to work on the current host. It is typically triggered when the runtime environment must be validated before the main workload starts and temporary files, downloads, or exports may consume additional space. This example checks the root filesystem and the mounted Windows volume used by the vault and fails only if either exceeds the 90 percent threshold.
Check the key WSL mount points used by the workflow and fail if any exceeds the configured usage threshold.
#!/usr/bin/env bashset -euo pipefailTHRESHOLD=90breached=0while read -r mount usage; do pct="${usage%\%}" echo "$mount - $pct% used" if (( pct > THRESHOLD )); then echo "ALERT - $mount is ${pct}% full (threshold: ${THRESHOLD}%)" breached=1 fidone < <(df -P / /mnt/c | awk 'NR > 1 { print $6, $5 }')if (( breached )); then exit 1fiecho 'OK - all 2 mount(s) below 90% usage'
These scripts solve the glue problems around job scheduling: preventing overlapping runs, retrying flaky commands, and alerting on outcomes. They complement orchestrators like airflow-dag-patterns by handling concerns that cron and lightweight wrappers do not address natively.
Orchestration scripts
These examples use the helper scripts and state files under data/powershell-automation/state. The outputs below were taken from live WSL runs, including the deliberately failing webhook notification path.
Mutex lock wrapper
Use this when a job moves from one-off execution into unattended scheduling. It is typically triggered when the scheduler needs overlap control so a second run does not start while the first one still holds shared state. This example acquires a flock lock file before running the helper script under state.
Acquire a file lock before running the helper script under data/powershell-automation/state.
#!/usr/bin/env bashset -euo pipefailLOCK_FILE='/mnt/c/Users/aperi/My Drive/VAULT/data/powershell-automation/state/stoxx-bq-wh-nb-demo.lock'TARGET_SCRIPT='/mnt/c/Users/aperi/My Drive/VAULT/data/powershell-automation/state/mutex_target.sh'COMMAND="bash \"$TARGET_SCRIPT\""exec 200>"$LOCK_FILE"if ! flock -n 200; then echo "SKIPPED - another instance is already running (lock: $LOCK_FILE)" exit 0fiecho "Lock acquired, running: $COMMAND"bash "$TARGET_SCRIPT"status=$?echo "OK - command completed with exit code $status"exit "$status"
Use this when a job moves from one-off execution into unattended scheduling and transient failures are expected. It is typically triggered when the scheduler needs explicit retry and backoff semantics around a flaky dependency. This example increments a counter file, fails the first two attempts on purpose, and succeeds on the third attempt after running a live SQL Server health query through SQLCMD.EXE.
Retry logic needs replay safety
This wrapper is sound for transient read failures. It becomes unsafe when copied onto writes that can be applied more than once.
Retry a non-idempotent write
A timeout or broken connection after the remote side commits can still trigger a duplicate write on the next attempt.
until curl -sS -X POST "$URL" -d "$payload"; do sleep 1done
Retry an idempotent check
Keep generic retries around health checks, metadata reads, or writes protected by an idempotency key.
until "$SQLCMD" -S localhost,1434 -Q "SET NOCOUNT ON; SELECT 1;"; do sleep 1done
Retry a transiently failing operation until the third attempt, then complete with a live stoxx health query.
#!/usr/bin/env bashset -euo pipefailSTATE_FILE='/mnt/c/Users/aperi/My Drive/VAULT/data/powershell-automation/state/retry-count.txt'SQLCMD="/mnt/c/Program Files/Microsoft SQL Server/Client SDK/ODBC/180/Tools/Binn/SQLCMD.EXE"MAX_RETRIES=3attempt=0delay=1printf '0' > "$STATE_FILE"run_command() { local current next current=$(cat "$STATE_FILE") next=$((current + 1)) printf '%s' "$next" > "$STATE_FILE" if (( next < 3 )); then return 1 fi "$SQLCMD" -S localhost,1434 -d stoxx -U sa -P 'EsgDev2026Pass1' -C -Q "SET NOCOUNT ON; SELECT 1 AS HealthCheck;" -h -1 -W > /dev/null}while (( attempt < MAX_RETRIES )); do if run_command; then echo "OK - succeeded on attempt $((attempt + 1))" exit 0 fi attempt=$((attempt + 1)) if (( attempt >= MAX_RETRIES )); then break fi echo "Attempt $attempt/$MAX_RETRIES failed, retrying in ${delay}s..." sleep "$delay" delay=$((delay * 2))doneecho "FAILED - all $MAX_RETRIES attempts exhausted"exit 1
Attempt 1/3 failed, retrying in 1s...Attempt 2/3 failed, retrying in 2s...OK - succeeded on attempt 3
Run and alert pattern
Use this when a scheduled job needs an explicit success or failure notification path in addition to its exit code. It is typically triggered when the wrapper must send a webhook after the target command completes, but the command result still has to remain visible to the scheduler. This example runs the live stoxx health helper, attempts to post a webhook payload to a local endpoint, and preserves the command exit code even when the notification fails.
Run the stoxx health helper, attempt a webhook notification, and emit a scheduler-friendly status line.
Most Bash automation failures come from shell semantics and runtime context rather than from CSV or JSON handling itself. These patterns keep the scripts above predictable when they move from an interactive terminal into unattended WSL or Linux jobs.
Script defaults
Fail fast on shell errors
Start production scripts with set -euo pipefail so missing variables, failed commands, and broken pipelines terminate the run immediately instead of leaking bad state into later steps.
Use explicit conditionals around commands that are expected to fail as part of normal control flow. Strict mode is valuable only when the script distinguishes intentional non-zero paths from unexpected ones.
Run a short script that aborts on an unset variable and surfaces the resulting non-zero exit code.
before/mnt/c/Users/aperi/My Drive/VAULT/data/powershell-automation/state/fail-fast-demo.sh: line 4: UNSET_DEMO: unbound variableExit code: 1
Treat pipelines as a separate failure surface
Remember that a pipeline has more than one process in it. pipefail is what turns a hidden failure in the left side of curl | python3 or sqlcmd | python3 into a visible script failure instead of a false success.
Test the pipeline as one unit whenever upstream commands can fail independently of the parser. Production shell failures often come from assuming that the last command in the pipe tells the whole story.
Compare the same failing pipeline with and without pipefail enabled.
Use trap 'rm -f "$tmp_file"' EXIT whenever the script creates temp files, generated SQL, or transient downloads. Cleanup that exists only at the happy-path bottom of the script is not real cleanup.
For scheduled jobs, prefer EXIT plus explicit signal traps when the workload holds locks or external leases. Cleanup policy should survive both ordinary completion and operator interruption.
Create a temp file under state, fail intentionally, and confirm that the trap removed the file on exit.
Assume cron is a different runtime than your interactive shell. Set PATH, CLOUDSDK_CONFIG, working directory, and any required environment variables inside the script rather than relying on profile state.
Set timezone, shell, and notification behavior deliberately as well. Minimal scheduler environments are predictable only when the script declares all of the context it depends on.
Simulate a cron-like minimal environment, then rerun the same check with explicit PATH and CLOUDSDK_CONFIG set.
/usr/bin/bash: line 1: python3: No such file or directoryPython 3.12.3CLOUDSDK_CONFIG=/mnt/c/Users/aperi/AppData/Roaming/gcloudMinimal env exit code: 127
Logging and SQL Server patterns
Stamp logs with timestamps
A simple helper such as log() { printf "[%s] %s\n" "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >&2; } is enough when you need searchable timestamps in flat-file automation and do not yet have centralized logging.
Prefer UTC timestamps for multi-system data operations. Local wall-clock logging becomes difficult to reconcile once GCP, SQL Server, and scheduled jobs cross time zones.
Emit two UTC log lines with a lightweight log() helper so the timestamp shape is explicit and machine-searchable.
[2026-04-14T22:13:54Z] Started checksum verification[2026-04-14T22:13:54Z] Completed row-count reconciliation
Prefer sqlcmd with machine-friendly switches
For downstream parsing, favor -C -W -s"," -h -1 and explicit saved .sql files. Those switches remove a large amount of display formatting noise and make SQLCMD.EXE output usable from Bash without a brittle text scraper.
Do not treat those switches as universally safe defaults. -W changes whitespace semantics, and parser-friendly output still needs validation whenever the result set shape changes.
Query the live stoxx SQL Server instance with parser-friendly switches and split the returned row into Bash variables.
#!/usr/bin/env bashset -euo pipefailSQLCMD='/mnt/c/Program Files/Microsoft SQL Server/Client SDK/ODBC/180/Tools/Binn/SQLCMD.EXE'row=$("$SQLCMD" -S localhost,1434 -d stoxx -U sa -P 'EsgDev2026Pass1' -C -W -s"," -h -1 -Q "SET NOCOUNT ON; SELECT TOP (1) symbol, CONVERT(date, signal_date) AS signal_date FROM silver.signals_daily ORDER BY signal_date DESC, symbol;" | tr -d '\r' | awk 'NF && $1 !~ /^-/{print; exit}')IFS=',' read -r symbol signal_date <<< "$row"echo "Raw row: $row"echo "Parsed symbol: $symbol"echo "Parsed signal_date: $signal_date"
Raw row: 0388.HK,2026-04-08Parsed symbol: 0388.HKParsed signal_date: 2026-04-08
Troubleshooting
Use these symptoms to decide whether the failure is scheduler context, shell error handling, pipeline behavior, or input parsing.
Scheduling context
Script works interactively but fails in cron
Check the scheduled job’s PATH, working directory, WSL mount availability, CLOUDSDK_CONFIG, and service identity first. Interactive success often comes from profile state that cron never loads.
Verify the effective shell and the exact user context that the scheduler used. Many reproducibility failures are environment mismatches, not business-logic regressions.
Show how the same relative path fails from /tmp but succeeds when the working directory is the vault root.
From /tmp: relative file not foundFrom vault root: relative file found
command not found under cron
The command exists in your shell session but not in the minimal environment that cron starts with. Export the needed PATH explicitly or call the full executable path inside the script.
This is especially relevant for hybrid WSL paths such as SQLCMD.EXE and Windows-installed Google Cloud SDK tools. Mixed Windows and Linux runtimes should rely on explicit executable paths in scheduled jobs.
Run SQLCMD.EXE once through a minimal PATH, then call the full executable path explicitly from the same stripped-down environment.
/usr/bin/bash: line 1: SQLCMD.EXE: command not foundMicrosoft (R) SQL Server Command Line ToolMinimal PATH exit code: 127
Error handling
Script keeps running after failure
The script is missing set -e, the failing command is inside a construct that suppresses the error, or the failure is coming from a pipeline without pipefail. Fix the shell semantics first before debugging the business logic.
Also inspect command substitutions, subshells, and while read loops fed by pipes. Bash error behavior differs across those constructs, and the bug is often in control flow rather than in the data operation.
Compare the same failing command sequence once without set -e and once with set -e enabled.
#!/usr/bin/env bashset -euo pipefailif bash -lc 'echo "without set -e: start"; false; echo "without set -e: continued"'; then without_status=0else without_status=$?fiif bash -lc 'set -e; echo "with set -e: start"; false; echo "with set -e: continued"'; then with_status=0else with_status=$?fiecho "Without set -e exit code: $without_status"echo "With set -e exit code: $with_status"
without set -e: startwithout set -e: continuedwith set -e: startWithout set -e exit code: 0With set -e exit code: 1
Pipeline failure is not caught
One stage in a pipeline failed, but the last stage still exited successfully. Enable set -o pipefail and test the pipeline as one unit whenever the left side is allowed to fail independently.
If the pipeline spans network I/O and parsing, capture both the producer exit code and the parser outcome during debugging. Silent truncation frequently starts with a failed upstream command whose output parser still exits cleanly.
Read a missing file through wc -l once without pipefail and once with pipefail so the hidden failure is visible.
Without pipefail:stdout => 0cat: '/mnt/c/Users/aperi/My Drive/VAULT/data/powershell-automation/state/missing-pipeline-input.csv': No such file or directoryWith pipefail:stdout => 0cat: '/mnt/c/Users/aperi/My Drive/VAULT/data/powershell-automation/state/missing-pipeline-input.csv': No such file or directoryWithout pipefail exit code: 0With pipefail exit code: 1
Data parsing
cut or awk returns the wrong columns
The file is quoted CSV or contains embedded delimiters, so positional text slicing is no longer reliable. Switch to a CSV-aware parser such as Python’s csv module before trying to patch the shell expression further.
The same guidance applies when the producer changes column order without changing names. Data engineering automation should parse structured files with structured parsers, not with optimistic delimiter assumptions.
Write a quoted CSV row under state, then compare cut output to a CSV-aware Python parse of the same field.