Skip to content

feat: parquet generation function - #1845

Draft
davidgamez wants to merge 18 commits into
mainfrom
feat/parquet_generation
Draft

davidgamez wants to merge 18 commits into
mainfrom
feat/parquet_generation

Conversation

@davidgamez

@davidgamez davidgamez commented Sep 17, 2026 •

Copy link
Copy Markdown
Member

Summary:

Renders GTFS datasets as Parquet so clients can browse a feed without downloading the archive.

A new parquet-builder Cloud Function streams a dataset's archive from GCS, converts each table with DuckDB one at a time, and publishes {table}.parquet plus a manifest.json to the public bucket. Two Operations API endpoints drive it (/gtfs_feeds/{id}/parquet, /gtfs_datasets/{id}/parquet): POST to start, GET to poll. Files expire through the bucket's own daysSinceCustomTime lifecycle rule rather than a purge job.

The same source is deployed three times, with its own memory, in-memory volume and Cloud Tasks queue per size:

worker routed when the largest file is memory vCPU volume DuckDB cap RLIMIT_AS
s under 256 MB 3Gi 1 1Gi 512MB 1848 MiB
m under 1.5 GB 7Gi 2 3Gi 1GB 3896 MiB
l anything larger, or unknown 16Gi 4 8Gi 2GB 7992 MiB

The volume is carved out of the memory, not added to it, so the process budget is memory - volume - 200 MiB. Routing is by the largest single uncompressed file because the volume holds one at a time; totals are the wrong signal. The bands come from the catalogue: of 4277 feeds the median archive is 0.2 MB and only 31 exceed 100 MB, while about ten have a member over 1 GB and the worst has one of 4.8 GB.

Routing self-corrects in both directions through a single per-feed config_value_feed override:

  • Up, on failure. A build that exhausts memory or disk re-queues itself one rung higher, capped at 3 attempts. A row is written only where it disagrees with the measurement, so the table holds genuine exceptions rather than restating the default.
  • Down, on success. An override drops a rung after 3 consecutive builds that each left 40% of the smaller worker spare on both memory and disk. Nothing is re-queued; it applies to the feed's next build.
  • Held, if an operator says so. {"size":"l","locked":true} stops both directions. A feed locked below what it needs fails and stays failed, which current_size_locked on the attempts endpoint makes visible.

task_execution_attempt records every attempt append-only — which worker ran, why, what failed, peak memory and the budget it ran against — exposed as GET /gtfs_feeds/{id}/execution_attempts (filterable by status, failure_kind, date) and aggregated by a new task_execution_stats task.

Two measurement bugs fixed along the way: peak_vms_bytes was sampled after DuckDB had been torn down, so it recorded the quiet moment after a build rather than the busy one during it (now polled on a sampler thread); and limit_gcp_memory only logged the budget it computed, so an attempt could not say whether its usage was comfortable (now returned and recorded).

Expected behavior:

POST to either parquet endpoint returns 202 with the current state; the GET reports absent → preparing (with progress) → ready, then serves base_url, from which manifest.json and each table are fetchable by HTTP range request. A feed with no dataset yet returns 404.

A dataset too large for the worker it was routed to fails, moves up a rung and succeeds without intervention, with the whole trail visible in execution_attempts. Measured on dev: s 3-21s, m 13-69s, l 3-133s, each peaking at 67-81% of its address-space budget.

Testing tips:

  • ./scripts/api-tests.sh --folder functions-python/parquet_builder (and helpers, operations_api).
  • Build locally without deploying: scripts/parquet-generate-local.sh.
  • Against dev, through the API rather than calling the function directly: POST /v1/operations/gtfs_datasets/{dataset_id}/parquet with {"force": true}, poll the GET, then open {base_url}/manifest.json in a browser to confirm CORS and range reads. mdb-2014 (4.07 GiB stop_times.txt) is the heaviest case; a small feed such as ntd-70242 exercises s.
  • GET /v1/operations/gtfs_feeds/{id}/execution_attempts shows routing, escalation and the current override per feed.
  • To force an escalation, pin a feed below its needs with the SQL in functions-python/parquet_builder/README.md and rebuild; add "locked": true to the same value to confirm it then refuses to escalate.

Please make sure these boxes are checked before submitting your pull request - thanks!

  • Run the unit tests with ./scripts/api-tests.sh to make sure you didn't break anything
  • Add or update any needed documentation to the repo
  • Format the title like "feat: [new feature short description]". Title must follow the Conventional Commit Specification(https://www.conventionalcommits.org/en/v1.0.0/).
  • Linked all relevant issues
  • Include screenshot(s) showing how this pull request works and fixes the issue(s)

🤖 Generated with Claude Code

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Concurrency, enqueue, purge, publication, and SQL-safety defects can leave builds duplicated, inaccessible, or incorrectly advertised.

Review effort: Balanced
Findings: 7 High severity · 2 Medium severity · 1 Low severity

Open (10)
What changed in this PR

Adds on-demand GTFS-to-Parquet generation, status APIs, retention cleanup, deployment infrastructure, and local development tooling.

Changes:

  • Implements Parquet conversion, publishing, progress tracking, and tests.
  • Adds Operations API endpoints and scheduled retention cleanup.
  • Configures Cloud Functions, task queues, storage CORS, and local tooling.
File Description
.gitignore Ignores local Parquet output.
README.md Documents local feed browsing.
docs/​OperationsAPI.yaml Defines Parquet API endpoints and models.
scripts/​parquet-generate-local.sh Bootstraps local generation.
infra/​batch/​main.tf Enables HEAD CORS requests.
infra/​functions-python/​main.tf Deploys builder, queue, purge, and volume.
infra/​functions-python/​vars.tf Adds Parquet infrastructure variables.
functions-python/​helpers/​ephemeral_workdir.py Generalizes work-directory ownership.
functions-python/​helpers/​task_execution/​task_execution_tracker.py Adds exclusive task claims and leases.
functions-python/​helpers/​tests/​test_task_execution_tracker.py Tests task claim behavior.
functions-python/​helpers/​utils.py Enqueues Parquet build tasks.
functions-python/​operations_api/​.openapi-generator/​FILES Registers generated Parquet API files.
functions-python/​operations_api/​README.md Documents local Parquet development.
functions-python/​operations_api/​src/​main.py Mounts the Parquet router.
functions-python/​operations_api/​src/​feeds_operations/​impl/​parquet_api_impl.py Implements status and generation endpoints.
functions-python/​operations_api/​tests/​feeds_operations/​impl/​test_parquet_api_impl.py Tests endpoint state handling.
functions-python/​parquet_builder/​README.md Documents builder behavior and usage.
functions-python/​parquet_builder/​function_config.json Configures the Cloud Function.
functions-python/​parquet_builder/​requirements.txt Adds runtime dependencies.
functions-python/​parquet_builder/​requirements_dev.txt Adds test dependencies.
functions-python/​parquet_builder/​src/​converter.py Converts GTFS tables to Parquet.
functions-python/​parquet_builder/​src/​main.py Orchestrates cloud builds and publishing.
functions-python/​parquet_builder/​src/​progress.py Throttles progress updates.
functions-python/​parquet_builder/​src/​scripts/​generate_local.py Implements local generation and serving.
functions-python/​parquet_builder/​tests/​test_converter.py Tests conversion contracts.
functions-python/​parquet_builder/​tests/​test_generate_local.py Tests local generation and range serving.
functions-python/​parquet_builder/​tests/​test_main.py Tests build orchestration.
functions-python/​parquet_builder/​tests/​test_progress.py Tests progress throttling.
functions-python/​pmtiles_builder/​src/​main.py Uses the shared work-directory helper.
functions-python/​pmtiles_builder/​tests/​test_workdir.py Updates work-directory tests.
functions-python/​tasks_executor/​src/​main.py Registers the purge task.
functions-python/​tasks_executor/​src/​tasks/​parquet/​__init__.py Adds the Parquet task package.
functions-python/​tasks_executor/​src/​tasks/​parquet/​purge_expired_parquet.py Implements retention cleanup.
functions-python/​tasks_executor/​tests/​tasks/​parquet/​test_purge_expired_parquet.py Tests retention cleanup.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +156 to +159
tracker.mark_triggered(
dataset.stable_id,
metadata={"phase": "start", "done": 0, "total": 0, "detail": ""},
)
return False
try:
con.execute(f"""
CREATE VIEW "{table}" AS
Comment on lines +219 to +220
tracker.heartbeat(dataset_stable_id, metadata=state)
db_session.commit()
Comment on lines +532 to +537
try:
blob.make_public()
except Exception as error:
# Uniform bucket-level access would make this unnecessary and impossible at the
# same time; the objects are public by bucket policy in that case.
logger.warning("Could not make %s public: %s", blob.name, error)
completed_at,
COALESCE((metadata ->> 'retention_days')::int, :default_days) AS retention_days
FROM task_execution_log
WHERE task_name = :task_name
purged = []
for dataset_stable_id in datasets:
try:
deleted_objects += _delete_prefix(bucket, dataset_stable_id)
Comment on lines +1696 to +1701
triggers_replace = {
function_name = google_cloudfunctions2_function.parquet_builder.name
region = var.gcp_region
project = var.project_id
size = var.parquet_builder_in_memory_size
}
Comment thread functions-python/helpers/utils.py Outdated
project_id=project_id,
gcp_region=gcp_region,
queue_name=queue_name,
task_name=None if force else task_name,
Comment on lines +275 to +281
except Exception as error:
logger.exception("Parquet build failed for %s", dataset_stable_id)
# Releases the claim as well as recording why, so the dataset can be retried
# without waiting out the lease.
tracker.mark_failed(dataset_stable_id, error_message=str(error))
db_session.commit()
raise
```json
{ "status": "absent", "feed_stable_id": "mdb-1210", "dataset_stable_id": "mdb-1210-202402121801" }
{ "status": "preparing", "phase": "convert", "done": 12, "total": 32, "detail": "stop_times" }
{ "status": "ready", "base_url": "https://.../parquet", "tables": [{"name": "stops"}] }

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants