Skip to main content
Once an External Table is created, the controller’s External Table watcher ingests data on a schedule. These read-only endpoints let you check each run’s status and see how far the queryable data lags behind the upstream table. There is also an endpoint to trigger a run immediately instead of waiting for the next scheduled tick.
The observability endpoints require executor=controller in the table’s ExternalTableSyncTask config (the default for tables onboarded via the Data Portal or the preview API).All paths are relative to your data-plane base URL — set export BASE_URL=https://dp.<data-plane-id>.cp.<region>.startree.cloud/api/pinot (the StarTree Cloud data-plane proxy). Add an Authorization header if your controller requires auth.
Run status is not the same as data freshness. A fileOnboardingRun.status of COMPLETED only means the last sync run finished — the table can still be behind the source, because the upstream table may have committed more data since. To judge whether your data is current, use syncState (with lag.percentDataIngested and lag.snapshotsBehind for detail), not the run status.

Common tasks

A map from what you want to know to the call that answers it and the field to read. Each row links to the full reference below.
Prefer clicking to curl? A table’s details page in the Data Portal shows the same run status and sync controls — see Trigger a sync run.

API Endpoints Quick Reference


Run status

GET /tables/{tableNameWithType}/externalTable/status Use this to answer “did the last sync run work?” and — with includeLag=true — “is my data current?” Returns the External Table sync watcher’s last run status for the table, under a fileOnboardingRun object. With includeLag=true, the response additionally reports how far the table’s queryable data is behind the upstream Iceberg table — see Ingestion lag. Top-level fields: fileOnboardingRun fields:
failurePhase / currentPhase values were renamed to describe what the sync is doing rather than Pinot’s internal step names: FILE_LISTING→LISTING_FILES, SCHEMA_UPDATE→UPDATING_SCHEMA, SEGMENT_GENERATION→GENERATING_SEGMENTS, SEGMENT_COMPRESSION→COMPRESSING_SEGMENTS, SEGMENT_UPLOAD→UPLOADING_SEGMENTS, SNAPSHOT_FINALIZE→PUBLISHING_SNAPSHOT, IS_EV_CONVERGENCE→WAITING_FOR_SEGMENTS_ONLINE, SNAPSHOT_READINESS_POLL→WAITING_FOR_SERVERS_READY, CHECKPOINT_SAVE→SAVING_PROGRESS. No alias is kept — update any dashboard, alert, or script matching on the old strings.
Status codes:

Ingestion lag

GET /tables/{tableNameWithType}/externalTable/status?includeLag=true Use this when you need to know whether the table is current with the source — and, if not, by how much.
Ingestion lag reporting is available starting in StarTree release 0.16.0.
Reports how far the table’s queryable data is behind the upstream Iceberg table. Lag is expressed as a snapshot delta rather than a time lag: the last fully synced snapshot versus the upstream head snapshot, plus the files and bytes still pending. Lag applies to catalogType=iceberg-rest tables synced with executor=controller. For any other table the endpoint still returns 200 with lag: null and a lagMessage explaining why; the status response is never failed because of lag. Snapshot lag is undefined for raw S3 listings, for example.
lag.caughtUp was removed. It was fully derivable from the top-level syncState: true always meant CAUGHT_UP, false never did, null meant UNKNOWN. Read syncState instead — it collapses ingestion health to one of CAUGHT_UP / CATCHING_UP / STALLED / UNKNOWN so a client never has to infer health from a nullable boolean and a percent.
lag object fields: Snapshot fields (synced and upstream):
There is deliberately no lagMs field. To express lag as a duration, subtract lag.synced.committedAtMs from lag.upstream.committedAtMs (raw epoch milliseconds; the ISO-8601 committedAt strings are seconds-precision display companions). Both timestamps come from the same upstream catalog clock, so the difference is not skewed by clock drift between the catalog and the Pinot controller.
Transient catalog or ZooKeeper failures never turn into a 500. The affected lag fields are returned as null with an explanatory message, and a synced state that exists but cannot be read is reported as unavailable rather than as a confident “nothing ingested”.

Exact pending mode

pendingExact now defaults to true: the controller diffs the live data-file sets of the head and synced snapshots, giving exact filesPending / bytesPending. This replaced defaulting to the cheap summary-totals estimate, because netting cumulative summary totals cannot answer the pending question at all once the source has non-append commits (compaction, overwrite, delete) between the synced and head snapshots — it silently reports nothing (pendingState=UNAVAILABLE) rather than a wrong number. The exact diff enumerates files and is capped at iceberg.snapshotProcessing.lagMaxDiffFiles files per snapshot enumeration (default 100,000) — a per-table key in the ExternalTableSyncTask config. It is also bounded by a controller-wide cap on concurrent exact diffs that StarTree manages; either cap degrades the response to the summary estimate and says so in lag.message, with pendingState=UNAVAILABLE (or ESTIMATED if the fallback succeeds) and pendingExact: false. Pass ?pendingExact=false to opt out to the O(1) summary-totals estimate up front.

Integration notes

  • Pending fields can be null even when syncState is not CAUGHT_UP. A compaction, overwrite, or delete between snapshots can leave both the exact diff and the summary estimate unable to answer (pendingState=UNAVAILABLE), so filesPending, bytesPending, and percentDataIngested may come back null. Fall back to snapshotsBehind; lag.message and lag.unavailableReason explain why.
  • Two different message fields. Top-level lagMessage means lag itself is null (lag does not apply to the table); without includeLag=true neither key appears at all. lag.message means lag is present but an individual field is degraded.
  • Display strings vs numbers. startTime, endTime, committedAt, controllerTime, syncedAt, and totalSize are display strings. Every timestamp string has a raw epoch-milliseconds companion (startTimeMs, endTimeMs, committedAtMs, controllerTimeMs, syncedAtMs), and totalSize has a raw companion too (totalBytes) — use those for arithmetic. filesPending, bytesPending, percentDataIngested, totalFiles, totalRecords, and snapshotsBehind remain numbers.

Snapshot ingestion (cross-run progress)

Use this when a single snapshot is too large to ingest in one run, and you need to know how far the ingestion as a whole has got — not just the last batch. fileOnboardingRun is overwritten with RUNNING the moment the next run starts and reports IDLE between runs, and each run’s own counts (filesDiscovered, segmentsUploaded) cover only that run’s page of at most iceberg.snapshotProcessing.batchSize files. Neither one answers “how far through this snapshot are we” for a snapshot ingested over many runs. The snapshotIngestion object in the Run status response does: it accumulates runs, filesDiscovered, segmentsUploaded, and filesFailed across every run that has processed the snapshot the table is currently on, alongside the snapshot’s own totalFiles for context. It also carries the outcome of the last run that actually finished — lastRunStatus, lastRunAt, lastRunError — plus consecutiveFailedRuns, since a fresh RUNNING record can otherwise hide a failure for the length of one cron tick. The failure streak is counted per run, not per snapshot, so a run that re-targets a newer upstream snapshot does not reset it. snapshotIngestion is null when the table has neither an ingestion watermark nor a finished run yet (a brand-new table before its first sync).

Ingestion watermark

GET /tables/{tableNameWithType}/externalTable/checkpoint Use this to answer “is the snapshot fully ingested, or only partly?” A large snapshot is ingested over many runs, one page of files at a time. A run that reports COMPLETED therefore says nothing about whether the snapshot as a whole is finished — it means that page succeeded. The watermark is what tracks the snapshot itself: which one the table is on, and how far through it ingestion has got. Requires executor=controller; legacy minion-executor tables are rejected with 400, because they never write a watermark and would otherwise be indistinguishable from a table that has not run yet. All value fields are null when the table has never synced. The Data Portal does not surface this endpoint — call it directly.
state: DONE is the signal that a table is at a snapshot boundary — the point at which it is safe to change how the table ingests, for example when enabling segment groups.

Metrics and alerting

The status and lag endpoints answer on-demand questions. For dashboards and continuous monitoring, the controller also emits per-sync metrics (available wherever you scrape StarTree Pinot controller metrics). Per-phase sync timers (milliseconds) — how long each stage of a run takes; useful for latency dashboards and for finding where a slow sync spends its time: Deletion-vector convergence — only when deletion vectors are enabled; covers the server-readiness handshake: Sync health — a per-table latch driven by run outcomes, so a dashboard or alert doesn’t have to poll the status API:
There is no metric for ingestion lag today — monitor that through the status API:
  • Data falling behind — poll …?includeLag=true and alert when syncState is STALLED (or CATCHING_UP for longer than one sync interval), or lag.percentDataIngested stays below your threshold. Note syncState can flicker STALLED → CATCHING_UP on a fast-committing source: a permanently broken table’s watermark cycles FAILED → IN_PROGRESS → FAILED as it re-targets each new upstream snapshot, so alert on the sustained condition, or on snapshotIngestion.consecutiveFailedRuns instead, which does not reset on a re-target.
  • Snapshot silently skipped — a run that reports COMPLETED with segmentsUploaded: 0 while syncState is not CAUGHT_UP. See Sync says COMPLETED but no data lands.
Per-run success/failure now has a metric too — EXTERNAL_TABLE_SYNC_UNHEALTHY above — but for triage detail (failurePhase, errorMessage) still poll …/externalTable/status.

Trigger a sync run (optional)

POST /tasks/schedule?taskType=ExternalTableSyncTask&tableName=<tableNameWithType>
You normally do not need this. After a table is created, the controller’s watcher runs the first sync automatically and then re-syncs on the table’s schedule. Use this endpoint only to start a run immediately instead of waiting for the next tick.
A table’s details page in the Data Portal also exposes Sync Now, Pause Sync, and Resume Sync for the sync task, so you don’t need to call these endpoints directly. Pausing removes the table’s sync schedule (already-ingested data is untouched); resuming restores it — the primary action button itself becomes Resume Sync while the task is paused. See Task Observability for the broader task-monitoring view these controls sit alongside.