From 56fff4945485b8136eabc66f88f7739b72e0867e Mon Sep 17 00:00:00 2001
From: navidgh67
Date: Tue, 28 Jul 2026 22:11:58 -0400
Subject: [PATCH 1/3] Add production GLANCE ingestion foundation
---
.gitignore | 2 +
...ON_GLANCE_INGESTION_AND_ML_REQUIREMENTS.md | 517 ++++++
api/data_loader_pg.py | 804 ++++++++-
api/glance_ingestion.py | 483 ++++++
api/main.py | 6 +
api/model_registry.py | 37 +
api/routers/dataset_v2.py | 209 +++
api/routers/ml.py | 64 +
api/scripts/add_equipment_runs.sql | 111 +-
.../add_production_glance_ingestion.sql | 115 ++
api/scripts/run_ml_shadow_evaluation.py | 55 +
api/shadow_evaluation.py | 527 ++++++
api/tests/test_production_glance_ingestion.py | 419 +++++
api/tests/test_shadow_evaluation.py | 75 +
azure/.funcignore | 6 +-
azure/.gitignore | 9 +-
azure/Dockerfile.glance | 17 +
azure/README.md | 55 +-
azure/common/__init__.py | 68 +-
azure/common/config.py | 47 +-
azure/common/glance_connector.py | 1544 +++++++++++++++++
azure/common/glance_mirror.py | 1163 +++++++++++++
azure/local.settings.json.example | 20 +
azure/process_glance_traces/__init__.py | 16 +
azure/process_glance_traces/function.json | 13 +
azure/requirements-glance.txt | 3 +
azure/scripts/run_glance_mirror.py | 30 +
azure/scripts/run_glance_poll.py | 32 +
azure/test_scripts/shadow_glance_poll.py | 541 ++++++
azure/tests/test_common_config.py | 83 +
azure/tests/test_glance_connector.py | 735 ++++++++
azure/tests/test_glance_mirror.py | 499 ++++++
geddes/k8s/01-secrets.yaml.example | 3 +
geddes/k8s/02-api.yaml | 8 +
geddes/k8s/05-postgres.yaml | 90 +-
geddes/k8s/glance-ingestion-cronjob.yaml | 111 ++
.../k8s/glance-mirror/01-secret.yaml.example | 13 +
geddes/k8s/glance-mirror/02-postgres.yaml | 300 ++++
geddes/k8s/glance-mirror/03-postgrest.yaml | 61 +
geddes/k8s/glance-mirror/04-sync-cronjob.yaml | 75 +
.../k8s/glance-mirror/05-network-policy.yaml | 22 +
geddes/k8s/glance-mirror/README.md | 52 +
geddes/k8s/ml-shadow-evaluation-cronjob.yaml | 33 +
geddes/k8s/postgres/schema_v2.sql | 86 +-
web/app/data/analysis/page.tsx | 5 +
web/app/optimize/page.tsx | 46 +-
web/components/cross-run-trace-analysis.tsx | 463 +++++
web/components/run-trace-explorer.tsx | 314 +++-
web/components/sidebar.tsx | 1 +
web/lib/api-client.ts | 56 +-
web/lib/auth-context.tsx | 8 +-
web/lib/trace-plot.ts | 150 ++
52 files changed, 9975 insertions(+), 227 deletions(-)
create mode 100644 PRODUCTION_GLANCE_INGESTION_AND_ML_REQUIREMENTS.md
create mode 100644 api/glance_ingestion.py
create mode 100644 api/scripts/add_production_glance_ingestion.sql
create mode 100644 api/scripts/run_ml_shadow_evaluation.py
create mode 100644 api/shadow_evaluation.py
create mode 100644 api/tests/test_production_glance_ingestion.py
create mode 100644 api/tests/test_shadow_evaluation.py
create mode 100644 azure/Dockerfile.glance
create mode 100644 azure/common/glance_connector.py
create mode 100644 azure/common/glance_mirror.py
create mode 100644 azure/process_glance_traces/__init__.py
create mode 100644 azure/process_glance_traces/function.json
create mode 100644 azure/requirements-glance.txt
create mode 100644 azure/scripts/run_glance_mirror.py
create mode 100644 azure/scripts/run_glance_poll.py
create mode 100644 azure/test_scripts/shadow_glance_poll.py
create mode 100644 azure/tests/test_common_config.py
create mode 100644 azure/tests/test_glance_connector.py
create mode 100644 azure/tests/test_glance_mirror.py
create mode 100644 geddes/k8s/glance-ingestion-cronjob.yaml
create mode 100644 geddes/k8s/glance-mirror/01-secret.yaml.example
create mode 100644 geddes/k8s/glance-mirror/02-postgres.yaml
create mode 100644 geddes/k8s/glance-mirror/03-postgrest.yaml
create mode 100644 geddes/k8s/glance-mirror/04-sync-cronjob.yaml
create mode 100644 geddes/k8s/glance-mirror/05-network-policy.yaml
create mode 100644 geddes/k8s/glance-mirror/README.md
create mode 100644 geddes/k8s/ml-shadow-evaluation-cronjob.yaml
create mode 100644 web/app/data/analysis/page.tsx
create mode 100644 web/components/cross-run-trace-analysis.tsx
create mode 100644 web/lib/trace-plot.ts
diff --git a/.gitignore b/.gitignore
index df29bd7..060a8a3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -60,6 +60,8 @@ debug_*.py
!api/tests/test_e2e_platform.py
!api/tests/test_glance_db3_trace_import.py
!api/tests/test_retrain.py
+!api/tests/test_production_glance_ingestion.py
+!api/tests/test_shadow_evaluation.py
# Development files
temp.py
diff --git a/PRODUCTION_GLANCE_INGESTION_AND_ML_REQUIREMENTS.md b/PRODUCTION_GLANCE_INGESTION_AND_ML_REQUIREMENTS.md
new file mode 100644
index 0000000..d074ccc
--- /dev/null
+++ b/PRODUCTION_GLANCE_INGESTION_AND_ML_REQUIREMENTS.md
@@ -0,0 +1,517 @@
+# Production GLANCE Ingestion and ML Requirements
+
+Status: Approved implementation basis
+Date: 2026-07-27
+Implementation branch: `feat/glance-production-ingestion` (from `dev`)
+
+## 1. Purpose
+
+This document defines the production-safe implementation that follows the
+temporary GLANCE DB3 pilot and Paul's requested plotting improvements. It
+separates five deliverables:
+
+1. an automated, scheduled, full-trace GLANCE connector;
+2. a durable FastAPI trace-ingestion contract and source identity;
+3. ten-run plotting, trendline, annotation, color, and performance changes;
+4. truthful reporting of the active ML proposal engine and model;
+5. a shadow RF-versus-GPR/Pareto evaluation with explicit promotion criteria.
+
+The temporary `feat/db3-upload-pipeline` branch remains a validated pilot and
+reference implementation. It must not be merged into `dev`, `master`, or this
+production branch. Production work may port reviewed data semantics and
+display logic, but not the pilot upload allowlist, DB3 upload route, private
+pilot projects, PVC, large-body ingress, image tags, or cleanup behavior.
+
+## 2. Outcomes and non-goals
+
+### 2.1 Required outcomes
+
+- GLANCE runs can be ingested without a user uploading a SQLite DB3 file.
+- One catalog row represents one physical GLANCE run.
+- Every timestamped sample and value remains attached to that run as a trace.
+- Repeated polling and retries do not duplicate catalog runs or samples.
+- Late-arriving samples and events can update an existing source run.
+- Catalog and Trace Analysis continue enforcing project visibility.
+- Users can see which proposal engine and model actually generated a result.
+- RF and GPR/Pareto proposals can be compared without changing live behavior.
+
+### 2.2 Non-goals
+
+- Use the dedicated, read-only `nanohubdt` PostgreSQL identity for the initial
+ production pilot. The previously configured PostgREST endpoint is a stale,
+ standalone copy and must not be treated as authoritative.
+- Do not deploy an obsolete PostgREST release merely to connect to the GLANCE
+ PostgreSQL 10.5 server. The deployed PostgREST 13 service requires a newer
+ PostgreSQL server.
+- Do not grant the connector write access to Digital Twin PostgreSQL.
+- Do not make DB3 upload the permanent production integration.
+- Do not smooth, impute, trim, rename, or permanently clean raw source values.
+- Do not interpret a literal zero as a generic sensor failure.
+- Do not treat `runs.status=processed` as a scientific Complete outcome.
+- Do not activate GPR/Pareto proposals in production solely by changing an
+ environment variable.
+- Do not use Bayesian optimization as a trace-noise filter.
+- Keep every new Geddes CronJob suspended and every production mapping
+ placeholder-only until a separately approved canary deployment.
+
+## 3. Shared data rules
+
+The following rules apply to every production GLANCE adapter:
+
+- Preserve literal zeroes, nulls in nullable source columns, empty samples, raw
+ parameter names, units, numeric parameter IDs, source timestamps, recipes,
+ and events. The validated authoritative schema makes `runs.starttime` and
+ `data.value` non-nullable, while `samplerecord.time` is nullable.
+- Derive relative time from source timestamps, never from sample row numbers.
+- Preserve the exact source timestamp text and do not invent a timezone.
+- Use explicit numeric equipment-parameter ID matching first.
+- Use conservative name-and-unit matching only when no numeric source ID exists.
+- Never override an ID conflict or silently alias two source signals.
+- Label unmatched signals as unregistered.
+- Associate events only through an authoritative source relationship. When
+ GLANCE provides no run foreign key, require the same tool and a timestamp
+ inside the run interval.
+- Derive `complete`, `abort`, or `unknown` only from authoritative terminal
+ GLANCE events at the run-end timestamp.
+- Keep ingestion state separate from scientific process outcome.
+
+## 4. Recommendation 1: scheduled GLANCE connector
+
+### 4.1 Placement and interface
+
+- Implement a dedicated connector, separate from the legacy summary-processing
+ Azure Function.
+- The default production runtime is a Geddes CronJob because read-only network
+ access from the DT namespace to the authoritative GLANCE database has been
+ validated.
+- Keep the GLANCE client and polling coordinator runtime-independent so the same
+ package can still run as an Azure timer function if an approved network path
+ is later provided.
+- Read GLANCE through a least-privilege service identity.
+- Send validated batches to FastAPI through an ingestion-token-protected HTTP
+ endpoint. The connector must never write directly to DT tables.
+
+### 4.2 Polling behavior
+
+- Default schedule: every ten minutes, configurable through environment.
+- Maintain an independent durable cursor for every mapped GLANCE tool.
+- Poll with a configurable overlap window to recover late samples and events.
+- Query source runs by stable tool and run IDs, not lot-name patterns.
+- Fetch the complete affected run, including run metadata, recipe metadata,
+ parameter definitions, sample records, values, and in-run events.
+- Use parameterized SQL, read-only repeatable-read transactions, bounded
+ statement timeouts, and bounded retries for transient connection failures.
+- Enforce configurable per-run sample and value limits before handing a payload
+ to FastAPI.
+- Advance a cursor only after FastAPI confirms the complete batch was committed.
+- A failed tool must not block other mapped tools.
+- Support an explicit bounded backfill range without resetting normal cursors.
+- Redact connection credentials from errors and durably record failures that
+ occur before an API handoff (JSONL on the Geddes state PVC or immutable
+ objects in the Azure state container).
+
+### 4.3 Connector configuration
+
+Required configuration:
+
+- authoritative GLANCE PostgreSQL URI supplied only through a Kubernetes Secret;
+- explicit source mode (`postgres` for the initial pilot, `postgrest` only for
+ a validated modern mirror);
+- DT trace-ingestion URL and ingestion token;
+- explicit equipment-to-GLANCE-tool map;
+- state storage connection and cursor container/table;
+- polling overlap, page size, retry count, and timeout.
+
+Secrets must remain outside Git. Example configuration must contain placeholders
+only.
+
+### 4.4 Connector acceptance criteria
+
+- Two identical polls produce one catalog run with one copy of each sample.
+- A later sample/event updates the existing catalog run.
+- A failed API request does not advance the source cursor.
+- A source run with duplicate sample or parameter IDs is rejected with an
+ actionable error.
+- One malformed run does not discard other valid runs in the batch.
+- HTTP tests use deterministic fixtures and require no live credentials.
+- SQL tests use injected connection fixtures and never interpolate source IDs
+ into query text.
+
+## 5. Recommendation 2: production trace-ingestion API and source identity
+
+### 5.1 Durable identity
+
+`equipment_runs` must expose explicit production source identity fields:
+
+- `source_system`;
+- `source_tool_id`;
+- `source_run_id`;
+- `source_updated_at`;
+- `ingested_at`.
+
+The durable uniqueness rule is:
+
+`(source_system, equipment_id, source_tool_id, source_run_id)`.
+
+Source run IDs are not assumed globally unique across tools. The production path
+must not use upload UUID plus row number as its identity.
+
+### 5.2 Trace ingestion contract
+
+Add an ingestion-token-protected endpoint for a versioned payload containing:
+
+- equipment ID and GLANCE tool identity;
+- source run ID, lot, material, start/end timestamps, and source status;
+- recipe ID, name, hash, timestamp, and file fingerprint when available;
+- parameter metadata and registry-match metadata;
+- all samples with source sample ID, exact timestamp, and values;
+- all associated events;
+- source revision/update time when available.
+
+FastAPI must:
+
+- validate the configured equipment/tool mapping;
+- validate IDs, timestamp syntax, uniqueness, and JSON value safety;
+- resolve parameter registration conservatively;
+- derive the process outcome;
+- upsert the parent and replace its trace in one transaction;
+- preserve the catalog run ID on retry;
+- apply project visibility through an explicit production project-assignment
+ policy;
+- return inserted, updated, run, sample, event, and warning counts.
+
+### 5.3 Cursor and audit state
+
+Persist connector audit records containing:
+
+- equipment and source tool;
+- previous and proposed cursor;
+- poll start/end;
+- request/batch identity;
+- run/sample/event counts;
+- success/failure status and a redacted error;
+- source and payload checksums when practical.
+
+### 5.4 API acceptance criteria
+
+- Authentication failure returns 401/403 without disclosing data.
+- A source tool mapped to another equipment registration is rejected.
+- Replaying a payload preserves the catalog run ID and raw values.
+- Project nonmembers receive 404 for direct run/trace access.
+- Literal zeroes, nulls, empty samples, timestamp gaps, and unregistered
+ parameters survive a complete round trip.
+- Parent and trace writes roll back together on failure.
+- Existing canonical etcher ingestion remains backward compatible.
+
+## 6. Recommendation 3: Trace Analysis and Catalog plotting
+
+### 6.1 Run comparison
+
+- Increase the selected-run limit from six to ten in both cross-run Trace
+ Analysis and the Catalog run comparison.
+- Provide at least ten stable, visually distinct colors.
+- Bound concurrent trace fetches to avoid a ten-request burst.
+- Preserve caching and do not refetch unchanged selected traces.
+- Show partial failures without discarding successfully loaded runs.
+
+### 6.2 Trendline
+
+- Keep `Apply trendline` opt-in.
+- Fit one documented least-squares line across all visible finite points in the
+ current parameter panel.
+- Show slope, intercept/local-origin equation, R-squared, point count, and time
+ unit.
+- Use a local time origin for absolute timestamps to avoid numerical precision
+ loss and present that origin with the equation.
+- If plot-edge smoothing is also selected, calculate the displayed trendline
+ from the displayed points only.
+- Never change stored or returned source values.
+
+### 6.3 Annotations and plot-edge display
+
+- Use short run/outcome labels and separate annotation lanes.
+- Omit redundant run-start annotations on the Relative axis.
+- Keep repeated edge-zero hiding opt-in and display-only.
+- Preserve interior zeroes, all-zero signals, isolated edge zeroes, nulls, and
+ all source data.
+
+### 6.4 Web acceptance criteria
+
+- Ten runs can be selected in each component.
+- Colors remain deterministic for a given run.
+- Trendline results cover normal, constant-Y, duplicate-X, absolute-time, null,
+ and smoothed-edge cases.
+- Process and date labels do not occupy one identical annotation lane.
+- TypeScript, scoped ESLint, and the Next.js production build pass.
+- The largest retained pilot traces remain interactively usable in a manual
+ pilot check before deployment.
+
+## 7. Recommendation 4: truthful ML runtime reporting
+
+### 7.1 API
+
+Add an authenticated ML runtime/status endpoint that reports effective values,
+not source-code defaults:
+
+- proposal engine;
+- proposal model;
+- candidate count;
+- acquisition method;
+- active model-registry version/kind when present;
+- training snapshot hash/time when present;
+- whether the current mode uses uncertainty-based acquisition or randomized
+ fallback.
+
+Never return secrets or unredacted environment values.
+
+Proposal responses must include the same effective engine/model metadata so a
+saved recipe batch remains reproducible.
+
+### 7.2 Web
+
+- Replace hard-coded “Training GPR” and “using Gaussian Process Regression”
+ text with effective runtime/model information.
+- Display a clear warning when proposals use an RF/randomized fallback without
+ uncertainty-based acquisition.
+- Keep model terminology consistent across Optimize, proposals, and project
+ recipe batches.
+
+### 7.3 Acceptance criteria
+
+- With the live-equivalent RF environment, the UI says Random Forest rather than
+ GPR.
+- With GPR selected in a test environment, the UI identifies GPR and its
+ acquisition method.
+- Missing optional model-registry state does not break the endpoint or UI.
+- Status and proposal metadata agree in integration tests.
+
+## 8. Recommendation 5: shadow RF-versus-GPR/Pareto evaluation
+
+### 8.1 Execution boundary
+
+- Shadow evaluation is read-only with respect to live proposal selection.
+- It uses frozen, project/equipment-scoped training snapshots.
+- It writes comparison artifacts and metrics but never promotes a model,
+ creates an executable recipe automatically, or changes the active engine.
+- Existing RF behavior remains active until an explicit reviewed promotion.
+
+### 8.2 Required candidates
+
+At minimum compare:
+
+- current FastAPI RF behavior;
+- current FastAPI GPR/EI behavior;
+- the reviewed GPR/Pareto/Monte-Carlo implementation after correctness fixes.
+
+Consolidate common sampling, Pareto, and acquisition code after comparison so
+the API and nanoHUB implementations cannot silently diverge.
+
+### 8.3 Correctness requirements before comparison
+
+- Correct Pareto dominance for maximize-rate/minimize-range objectives.
+- Normalize objective distances before combining them.
+- Keep candidate, mean, and uncertainty arrays aligned through filtering.
+- Either implement a real fantasy update or label the method accurately.
+- Return actual acquisition scores instead of placeholders.
+- Use held-out or cross-validated residuals for uncertainty calibration.
+- Make training iteration/model selection explicit.
+- Apply equipment-specific, registered parameter constraints.
+
+### 8.4 Metrics and promotion gate
+
+Record per model and snapshot:
+
+- holdout and/or LOOCV R-squared, RMSE, and MAE;
+- uncertainty coverage/calibration where uncertainty is claimed;
+- proposal constraint violations;
+- proposal stability under fixed and varied seeds;
+- predicted Pareto improvement and realized improvement when outcomes exist;
+- runtime, memory, candidate count, and failure rate;
+- expert review/acceptance outcome.
+
+GPR/Pareto may be proposed for a limited canary only when:
+
+- correctness tests pass;
+- uncertainty is calibrated within documented tolerances;
+- predictive performance is not materially worse than RF;
+- proposal constraints have zero violations;
+- runtime fits the API/background-job budget;
+- tool experts approve the feature ranges, targets, and acquisition behavior;
+- promotion is explicit, reversible, and recorded.
+
+### 8.5 Shadow acceptance criteria
+
+- One command evaluates all configured candidates against one frozen snapshot.
+- Repeating with the same seed produces identical artifacts.
+- Project-scoped runs never leak into another project's snapshot.
+- The command exits nonzero on correctness or constraint failures.
+- The report explicitly states that no live model or proposal engine changed.
+
+## 9. Implementation and rollout sequence
+
+1. Add schema and API behavior behind an inactive production connector path.
+2. Add deterministic backend/security tests.
+3. Add the connector with mocked GLANCE and DT endpoints.
+4. Port and validate web-only improvements.
+5. Add effective ML runtime reporting.
+6. Add the shadow evaluation command and reports.
+7. Run the complete local validation matrix.
+8. Review the requirements and implementation with GLANCE owners and tool
+ experts.
+9. Obtain a sanctioned GLANCE interface, service identity, mapping, project
+ assignment, schedule, and backfill window.
+10. Conduct a read-only/shadow poll without DT writes.
+11. Conduct a limited private-project canary with counts and checksums compared
+ to an approved GLANCE export.
+12. Roll out by mapped tool with documented rollback.
+
+## 10. Deployment boundary
+
+Local implementation and tests do not authorize:
+
+- connecting to live GLANCE;
+- applying database migrations on Geddes;
+- deploying API, connector, web, or CronJob images;
+- changing active ML configuration;
+- reprocessing or deleting retained DB3 uploads;
+- changing pilot project membership, PVCs, ingress, or rollback identities.
+
+## 11. Authorized read-only GLANCE shadow validation
+
+On 2026-07-27, a separately authorized, bounded PostgREST shadow poll validated
+the latest run for each approved Versaline tool. The audit did not call the DT
+ingestion API, read or advance a cursor, persist a raw payload, apply a
+migration, or deploy anything.
+
+| Tool | Live tool ID | Run ID | Samples | Values | Parameters | Events | Complete-payload time |
+|---|---:|---:|---:|---:|---:|---:|---:|
+| `VLN-11304-CTC-PM1` | 3 | 8677 | 2,622 | 178,296 | 68 | 6 | 11.4 seconds |
+| `VLN-11303-CTC-PM1` | 1 | 8658 | 264 | 17,688 | 67 | 9 | 1.8 seconds |
+
+The first query design used large sample-ID `IN` filters and received repeated
+HTTP 503 responses. A whole-run joined query was also too slow when globally
+ordered before pagination. The accepted connector query shape is therefore:
+
+- fetch minimal sample identity/timestamp fields;
+- sort source sample IDs locally;
+- divide them into disjoint numeric ranges of 25;
+- query `/data` through the authoritative `samplerecord.idruns` relationship
+ and the bounded sample-ID range;
+- page with explicit PostgREST `limit` and `offset`;
+- reconstruct the complete trace in memory without changing raw values.
+
+Both complete payloads were hashed after construction. The audit observed and
+preserved 60,989 literal zero values in DSE and 8,646 in ICP RIE. These results
+validated the payload construction and run-scoped query shape against the
+historical DT PostgREST copy. They did not establish that endpoint as the
+authoritative live source.
+
+- DSE payload SHA-256:
+ `58ff5aefcee7f5c145046362b8e15f2ca20600cf948c63b6dec388256263bda2`
+- ICP RIE payload SHA-256:
+ `1fad626eec9148537e0e810b06bd4e888f55f001c6cc0c41158a1d13a32b16c3`
+
+This validation does not authorize production activation. A canary still
+requires reviewed equipment/project mappings, a bounded initial run ID/backfill
+window, a deployed API/schema migration, a Geddes CronJob configuration, and a
+count/checksum comparison against an approved GLANCE export.
+
+The source reported 2026-02-04 as the latest run date for both tools during the
+2026-07-27 audit. A subsequent infrastructure audit confirmed that endpoint was
+a stale, standalone Geddes database rather than the authoritative GLANCE
+database.
+
+Each action above requires a separate operational review and explicit approval.
+
+## 12. Authoritative database validation and source decision
+
+On 2026-07-28 the dedicated `nanohubdt` identity was validated from an isolated
+Geddes pod against the authoritative `logger` database. The identity can assume
+the read role and select the eight required source tables: `runs`,
+`samplerecord`, `data`, `parameters`, `recipes`, `events`, `eventtypes`, and
+`tools`.
+
+The authoritative database is PostgreSQL 10.5. PostgREST 13 rejected it because
+that PostgREST version requires PostgreSQL 12.1 or newer. An isolated PostgREST
+10.2 canary connected and returned a current Tool 3 row, proving that the role,
+network, and grants were correct, but an obsolete PostgREST release is not the
+selected production solution.
+
+The approved implementation direction is:
+
+1. use direct, parameterized, read-only PostgreSQL access for the bounded pilot;
+2. ingest the approved Tool 3 run allowlist through the existing FastAPI
+ contract;
+3. activate ten-minute polling only after source/destination count validation;
+4. build a private, pull-based mirror on a modern PostgreSQL version for the
+ long-term PostgREST source.
+
+A read-only direct-SQL canary then assembled approved Tool 3 run `1` through
+the same connector code without calling the DT API or advancing state. It found
+77 sample records, no parameter values, and 7 in-window events and produced
+payload SHA-256
+`1a25e27a469bfcd9ebb3b9d9d8b871b88a5ee44ad7ac13d910e74ab789d5ee97`.
+This confirms the direct connection and complete-run query transaction; the
+remaining allowlist is still required to validate value-bearing runs before
+activation.
+
+The source database is approximately 53 GB. Its `data` table contains about
+613 million rows and consumes approximately 51 GB. The existing 20 GiB DT
+application database volume is therefore not a valid full-mirror destination.
+The raw mirror must use a separate expandable volume, initially sized at no less
+than 100 GiB; 150 GiB is the recommended operational starting point for source
+data, indexes, WAL, backfill overhead, and growth.
+
+## 13. Pull-based modern mirror requirements
+
+The mirror is an application-level, read-only-source synchronization process,
+not cross-version physical streaming replication.
+
+- Run PostgreSQL 15 or newer in a dedicated Geddes workload and PVC.
+- Keep the mirror database and PostgREST service private (`ClusterIP`) until an
+ explicit external-access review.
+- Preserve the eight GLANCE tables in a dedicated `glance_mirror` schema with
+ source primary identities and source-compatible types.
+- Give the synchronizer `SELECT` only on GLANCE and write access only to the
+ destination mirror schema.
+- Give PostgREST a separate read-only destination role.
+- Copy one complete run per destination transaction, including its samples,
+ values, referenced parameters, nullable recipe, and in-window events.
+- Upsert stable source identities and replace the run-scoped sample/value set on
+ overlap retries so late values are recovered without duplication.
+- Maintain independent successful run cursors per tool and synchronization mode
+ (`live`, explicit-ID pilot, or bounded range) in the destination.
+- Advance the applicable cursor only in the same transaction that commits the
+ complete run. After a malformed run, later valid runs may be copied
+ idempotently, but the cursor must remain behind the failure so the gap is
+ retried.
+- Backfill by explicit tool and bounded run/date windows with configurable batch
+ limits. Never launch an unbounded 613-million-row copy.
+- Enforce reviewed per-run sample and value limits, and insert destination rows
+ in bounded batches so one unexpectedly large run cannot exhaust worker
+ memory.
+- Track run/event associations and reconcile stale event rows during overlap
+ replacement without deleting an event still associated with another run.
+- Record duration, row counts, status, and redacted failures in a destination
+ audit table.
+- Keep the CronJob suspended in Git. Activation, volume creation, backfill, and
+ PostgREST cutover require an operational review and rollback plan.
+
+## 14. Local implementation map
+
+The implementation on `feat/glance-production-ingestion` is intentionally
+inactive until the deployment boundary above is approved:
+
+- Connector: `azure/common/glance_connector.py`, the Azure timer entry point,
+ and the suspended Geddes direct-ingestion CronJob.
+- Mirror: `azure/common/glance_mirror.py`, its command wrapper, and the
+ suspended resources under `geddes/k8s/glance-mirror/`.
+- API contract and validation: `api/glance_ingestion.py` and
+ `POST /api/dataset/v2/glance/traces/sync`.
+- Schema migration: `api/scripts/add_production_glance_ingestion.sql`.
+- Plotting: `web/lib/trace-plot.ts`, Catalog run comparison, and
+ `/data/analysis`.
+- Runtime truth: `GET /api/ml/status` and the Optimize page runtime badge.
+- Shadow evidence: `api/scripts/run_ml_shadow_evaluation.py`, comparing RF,
+ GPR/analytic EI, and GPR/Pareto/Monte-Carlo acquisition. The supplied
+ CronJob manifest is suspended and contains placeholder image/project values.
diff --git a/api/data_loader_pg.py b/api/data_loader_pg.py
index 44c6faf..d287037 100644
--- a/api/data_loader_pg.py
+++ b/api/data_loader_pg.py
@@ -354,6 +354,11 @@ def ensure_equipment_runs_pg() -> None:
row_index INT,
upload_filename VARCHAR(255) DEFAULT '',
source VARCHAR(100) DEFAULT 'data_upload',
+ source_system VARCHAR(100) NOT NULL DEFAULT 'data_upload',
+ source_tool_id BIGINT,
+ source_run_id BIGINT,
+ source_updated_at TIMESTAMP WITH TIME ZONE,
+ ingested_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
)
"""
@@ -361,6 +366,38 @@ def ensure_equipment_runs_pg() -> None:
cur.execute(
"ALTER TABLE equipment_runs ADD COLUMN IF NOT EXISTS row_index INT"
)
+ cur.execute(
+ "ALTER TABLE equipment_runs ADD COLUMN IF NOT EXISTS "
+ "source_system VARCHAR(100)"
+ )
+ cur.execute(
+ "ALTER TABLE equipment_runs ADD COLUMN IF NOT EXISTS source_tool_id BIGINT"
+ )
+ cur.execute(
+ "ALTER TABLE equipment_runs ADD COLUMN IF NOT EXISTS source_run_id BIGINT"
+ )
+ cur.execute(
+ "ALTER TABLE equipment_runs ADD COLUMN IF NOT EXISTS "
+ "source_updated_at TIMESTAMP WITH TIME ZONE"
+ )
+ cur.execute(
+ "ALTER TABLE equipment_runs ADD COLUMN IF NOT EXISTS "
+ "ingested_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP"
+ )
+ cur.execute(
+ """
+ UPDATE equipment_runs
+ SET source_system = COALESCE(NULLIF(source, ''), 'data_upload')
+ WHERE source_system IS NULL OR source_system = ''
+ """
+ )
+ cur.execute(
+ "ALTER TABLE equipment_runs ALTER COLUMN source_system "
+ "SET DEFAULT 'data_upload'"
+ )
+ cur.execute(
+ "ALTER TABLE equipment_runs ALTER COLUMN source_system SET NOT NULL"
+ )
cur.execute(
"CREATE INDEX IF NOT EXISTS idx_equipment_runs_equipment_id ON equipment_runs(equipment_id)"
)
@@ -377,6 +414,15 @@ def ensure_equipment_runs_pg() -> None:
WHERE upload_id IS NOT NULL
"""
)
+ cur.execute(
+ """
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_equipment_runs_source_identity
+ ON equipment_runs(
+ source_system, equipment_id, source_tool_id, source_run_id
+ )
+ WHERE source_tool_id IS NOT NULL AND source_run_id IS NOT NULL
+ """
+ )
cur.execute("ALTER TABLE equipment_runs ENABLE ROW LEVEL SECURITY")
cur.execute("GRANT SELECT ON equipment_runs TO api_client")
cur.execute(
@@ -438,8 +484,8 @@ def ensure_equipment_run_trace_samples_pg() -> None:
sample_index INT NOT NULL,
source_sample_id BIGINT NOT NULL,
source_tool_id BIGINT,
- sample_time TIMESTAMP WITHOUT TIME ZONE NOT NULL,
- sample_time_raw TEXT NOT NULL,
+ sample_time TIMESTAMP WITHOUT TIME ZONE,
+ sample_time_raw TEXT,
values_json JSONB NOT NULL DEFAULT '{}'::jsonb,
PRIMARY KEY (equipment_run_id, sample_index),
UNIQUE (equipment_run_id, source_sample_id),
@@ -451,6 +497,14 @@ def ensure_equipment_run_trace_samples_pg() -> None:
"ALTER TABLE equipment_run_trace_samples "
"ADD COLUMN IF NOT EXISTS source_tool_id BIGINT"
)
+ cur.execute(
+ "ALTER TABLE equipment_run_trace_samples "
+ "ALTER COLUMN sample_time DROP NOT NULL"
+ )
+ cur.execute(
+ "ALTER TABLE equipment_run_trace_samples "
+ "ALTER COLUMN sample_time_raw DROP NOT NULL"
+ )
cur.execute(
"""
CREATE INDEX IF NOT EXISTS idx_equipment_run_trace_samples_time
@@ -504,6 +558,187 @@ def ensure_equipment_run_trace_samples_pg() -> None:
conn.close()
+def ensure_equipment_run_trace_events_pg() -> None:
+ """Create the normalized provenance table for events attached to a run."""
+ conn = get_pg_superuser_connection()
+ try:
+ with conn.cursor() as cur:
+ cur.execute(
+ """
+ CREATE TABLE IF NOT EXISTS equipment_run_trace_events (
+ equipment_run_id BIGINT NOT NULL
+ REFERENCES equipment_runs(id) ON DELETE CASCADE,
+ source_event_id BIGINT NOT NULL,
+ source_tool_id BIGINT,
+ event_time TIMESTAMP WITHOUT TIME ZONE NOT NULL,
+ event_time_raw TEXT NOT NULL,
+ event_type TEXT NOT NULL DEFAULT '',
+ category TEXT NOT NULL DEFAULT '',
+ description TEXT NOT NULL DEFAULT '',
+ raw_event_json JSONB NOT NULL DEFAULT '{}'::jsonb,
+ PRIMARY KEY (equipment_run_id, source_event_id),
+ CHECK (jsonb_typeof(raw_event_json) = 'object')
+ )
+ """
+ )
+ cur.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_equipment_run_trace_events_time
+ ON equipment_run_trace_events(
+ equipment_run_id, event_time, source_event_id
+ )
+ """
+ )
+ cur.execute(
+ "ALTER TABLE equipment_run_trace_events ENABLE ROW LEVEL SECURITY"
+ )
+ cur.execute("GRANT SELECT ON equipment_run_trace_events TO api_client")
+ cur.execute(
+ """
+ DO $$
+ BEGIN
+ IF NOT EXISTS (
+ SELECT 1
+ FROM pg_policies
+ WHERE schemaname = 'public'
+ AND tablename = 'equipment_run_trace_events'
+ AND policyname = 'equipment_run_trace_event_visibility_policy'
+ ) THEN
+ CREATE POLICY equipment_run_trace_event_visibility_policy
+ ON equipment_run_trace_events
+ FOR SELECT
+ USING (
+ EXISTS (
+ SELECT 1
+ FROM equipment_runs r
+ JOIN projects p ON p.id = r.project_id
+ WHERE r.id = equipment_run_trace_events.equipment_run_id
+ AND (
+ p.access_mode = 'open'
+ OR EXISTS (
+ SELECT 1
+ FROM project_members pm
+ WHERE pm.project_id = p.id
+ AND pm.nanohub_user_id =
+ current_setting('app.current_user', true)
+ )
+ )
+ )
+ );
+ END IF;
+ END
+ $$;
+ """
+ )
+ conn.commit()
+ finally:
+ conn.close()
+
+
+def ensure_glance_ingestion_audit_pg() -> None:
+ """Create connector audit storage; it is intentionally not API-readable."""
+ conn = get_pg_superuser_connection()
+ try:
+ with conn.cursor() as cur:
+ cur.execute(
+ """
+ CREATE TABLE IF NOT EXISTS glance_ingestion_audit (
+ id BIGSERIAL PRIMARY KEY,
+ batch_id VARCHAR(255) NOT NULL,
+ source_system VARCHAR(100) NOT NULL DEFAULT 'glance',
+ equipment_id VARCHAR(255) NOT NULL,
+ source_tool_id BIGINT NOT NULL,
+ project_id VARCHAR(50),
+ previous_cursor TEXT,
+ proposed_cursor TEXT,
+ poll_started_at TIMESTAMP WITH TIME ZONE,
+ poll_completed_at TIMESTAMP WITH TIME ZONE
+ DEFAULT CURRENT_TIMESTAMP,
+ status VARCHAR(32) NOT NULL,
+ run_count INT NOT NULL DEFAULT 0,
+ sample_count INT NOT NULL DEFAULT 0,
+ event_count INT NOT NULL DEFAULT 0,
+ warning_count INT NOT NULL DEFAULT 0,
+ payload_sha256 VARCHAR(64),
+ error_text TEXT,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
+ )
+ """
+ )
+ cur.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_glance_ingestion_audit_tool_time
+ ON glance_ingestion_audit(
+ equipment_id, source_tool_id, created_at DESC
+ )
+ """
+ )
+ conn.commit()
+ finally:
+ conn.close()
+
+
+def record_glance_ingestion_audit_pg(**record: Any) -> None:
+ """Persist one redacted connector handoff result independently of trace data."""
+ conn = get_pg_superuser_connection()
+ try:
+ with conn.cursor() as cur:
+ cur.execute(
+ """
+ INSERT INTO glance_ingestion_audit (
+ batch_id, source_system, equipment_id, source_tool_id,
+ project_id, previous_cursor, proposed_cursor,
+ poll_started_at, status, run_count, sample_count,
+ event_count, warning_count, payload_sha256, error_text
+ ) VALUES (
+ %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
+ %s, %s, %s, %s, %s
+ )
+ """,
+ (
+ str(record.get("batch_id") or ""),
+ str(record.get("source_system") or "glance"),
+ str(record.get("equipment_id") or ""),
+ int(record.get("source_tool_id")),
+ str(record.get("project_id") or "") or None,
+ str(record.get("previous_cursor") or "") or None,
+ str(record.get("proposed_cursor") or "") or None,
+ record.get("poll_started_at"),
+ str(record.get("status") or "unknown"),
+ int(record.get("run_count") or 0),
+ int(record.get("sample_count") or 0),
+ int(record.get("event_count") or 0),
+ int(record.get("warning_count") or 0),
+ str(record.get("payload_sha256") or "") or None,
+ str(record.get("error_text") or "")[:2000] or None,
+ ),
+ )
+ conn.commit()
+ finally:
+ conn.close()
+
+
+def get_glance_ingestion_project_pg(project_id: str) -> Optional[Dict[str, Any]]:
+ """Return the authoritative project/equipment assignment for ingestion."""
+ conn = get_pg_superuser_connection()
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute(
+ """
+ SELECT id, equipment_id, access_mode
+ FROM projects
+ WHERE id = %s
+ LIMIT 1
+ """,
+ (project_id,),
+ )
+ row = cur.fetchone()
+ conn.commit()
+ return dict(row) if row else None
+ finally:
+ conn.close()
+
+
def _equipment_runs_base_select(*, include_raw_payload: bool = False) -> str:
"""SELECT that shapes generic equipment_runs into the same record format
used by the etcher /runs endpoints (so the catalog can render both)."""
@@ -520,9 +755,17 @@ def _equipment_runs_base_select(*, include_raw_payload: bool = False) -> str:
r.inputs_json,
r.outputs_json,
r.upload_filename,
- r.source,
+ COALESCE(NULLIF(r.source_system, ''), r.source) AS source,
r.project_id,
- NULLIF(r.raw_payload_json #>> '{{glance,run_id}}', '') AS source_run_id,
+ COALESCE(
+ r.source_run_id::text,
+ NULLIF(r.raw_payload_json #>> '{{glance,run_id}}', '')
+ ) AS source_run_id,
+ COALESCE(
+ NULLIF(r.raw_payload_json #>> '{{trace,process_outcome}}', ''),
+ NULLIF(r.raw_payload_json #>> '{{trace,process_status}}', ''),
+ 'unknown'
+ ) AS process_status,
NULLIF(
r.raw_payload_json #>> '{{glance,run_start_time}}', ''
) AS source_run_start_time,
@@ -557,7 +800,7 @@ def _shape_equipment_run_record(row) -> Dict[str, Any]:
else str(rec[ts_field])
)
source_run_start_time = rec.pop("source_run_start_time", None)
- if rec.get("source") == "glance_db3" and source_run_start_time:
+ if str(rec.get("source") or "").startswith("glance") and source_run_start_time:
# equipment_runs.run_date is TIMESTAMPTZ, so inserting a naive GLANCE
# wall time makes Postgres attach the session timezone. Present the exact
# source text instead; raw trace metadata explicitly reports that its
@@ -688,7 +931,11 @@ def _normalize_trace_parameter_key(value: Any) -> str:
return text
-def _parse_trace_sample_time(value: Any, *, source_sample_id: int) -> tuple[datetime, str]:
+def _parse_trace_sample_time(
+ value: Any,
+ *,
+ source_sample_id: int,
+) -> tuple[datetime | None, str | None]:
"""Parse a source timestamp without inventing a timezone.
The exact input text is returned alongside a timezone-naive datetime for the
@@ -697,15 +944,15 @@ def _parse_trace_sample_time(value: Any, *, source_sample_id: int) -> tuple[date
``tzinfo`` while retaining the exact offset-bearing source text. Naive source
values remain naive; no equipment timezone is guessed.
"""
+ if value is None:
+ return None, None
if isinstance(value, datetime):
raw = value.isoformat()
parsed = value
else:
- raw = str(value or "").strip()
+ raw = str(value).strip()
if not raw:
- raise ValueError(
- f"GLANCE sample {source_sample_id} is missing an absolute timestamp"
- )
+ return None, raw
candidate = raw[:-1] + "+00:00" if raw.endswith("Z") else raw
try:
parsed = datetime.fromisoformat(candidate)
@@ -722,10 +969,14 @@ def sync_equipment_run_traces_pg(
*,
equipment_id: str,
project_id: str,
- upload_id: str,
- upload_filename: str,
+ upload_id: Optional[str] = None,
+ upload_filename: str = "",
runs: List[Dict[str, Any]],
-) -> int:
+ source_system: str = "glance_db3",
+ source_tool_id: Optional[int] = None,
+ source_updated_at: Optional[datetime] = None,
+ return_stats: bool = False,
+) -> Any:
"""Atomically upsert physical GLANCE runs and their complete sample traces.
One item in ``runs`` becomes one ``equipment_runs`` catalog record. Each item
@@ -735,11 +986,12 @@ def sync_equipment_run_traces_pg(
``sample_time``/``timestamp``), and a ``values`` mapping keyed by stable
parameter ids such as ``p130``.
- Parent rows and samples share one transaction. For DB3 imports ``row_index``
- stores the GLANCE source run id, making ``(upload_id, row_index)`` the stable
- ``(upload, source run)`` identity. Reprocessing replaces traces in place,
- preserves catalog run ids even when another source run is skipped or later
- recovers, and removes stale rows from an earlier conversion.
+ Parent rows, samples, and events share one transaction. DB3 imports retain
+ their stable ``(upload_id, row_index)`` identity. Production live imports
+ omit ``upload_id`` and use
+ ``(source_system, equipment_id, source_tool_id, source_run_id)`` instead.
+ Both paths replace complete affected traces in place and preserve catalog
+ run IDs on retries.
"""
from psycopg2.extras import execute_values
@@ -747,8 +999,21 @@ def sync_equipment_run_traces_pg(
raise ValueError("equipment_id is required for GLANCE trace ingestion")
if not str(project_id or "").strip():
raise ValueError("project_id is required for GLANCE trace ingestion")
- if not str(upload_id or "").strip():
- raise ValueError("upload_id is required for idempotent GLANCE trace ingestion")
+ source_system = str(source_system or "").strip()
+ live_source = upload_id in (None, "")
+ if live_source:
+ if not source_system:
+ raise ValueError("source_system is required for live trace ingestion")
+ try:
+ batch_source_tool_id: Optional[int] = int(source_tool_id)
+ except (TypeError, ValueError) as exc:
+ raise ValueError(
+ "source_tool_id is required for live trace ingestion"
+ ) from exc
+ else:
+ batch_source_tool_id = None
+ if not source_system:
+ source_system = "glance_db3"
if not runs:
raise ValueError("No physical GLANCE runs with trace samples were found")
@@ -809,6 +1074,15 @@ def sync_equipment_run_traces_pg(
source_name = parameter.get("source_name")
if source_name not in (None, ""):
metadata["source_name"] = str(source_name)
+ metadata["registered"] = bool(parameter.get("registered", False))
+ for field in (
+ "registered_name",
+ "registered_parameter_id",
+ "registration_kind",
+ "registration_match",
+ ):
+ if parameter.get(field) not in (None, ""):
+ metadata[field] = str(parameter[field])
parameter_by_key[key] = metadata
parameters.append(metadata)
@@ -842,7 +1116,7 @@ def sync_equipment_run_traces_pg(
"source_tool_id", sample.get("glance_tool_id")
)
if source_tool_value in (None, ""):
- source_tool_id = None
+ source_tool_id = batch_source_tool_id
else:
try:
source_tool_id = int(source_tool_value)
@@ -860,6 +1134,15 @@ def sync_equipment_run_traces_pg(
f"GLANCE sample {source_sample_id} source tool id is outside "
"the PostgreSQL BIGINT range"
)
+ if (
+ live_source
+ and source_tool_id is not None
+ and source_tool_id != batch_source_tool_id
+ ):
+ raise ValueError(
+ f"GLANCE sample {source_sample_id} belongs to tool "
+ f"{source_tool_id}, expected {batch_source_tool_id}"
+ )
sample_index_value = sample.get("sample_index", fallback_index)
try:
@@ -915,6 +1198,75 @@ def sync_equipment_run_traces_pg(
)
samples.sort(key=lambda sample: sample["sample_index"])
+ raw_events = run.get("events", []) or []
+ events: list[dict[str, Any]] = []
+ seen_source_event_ids: set[int] = set()
+ for event in raw_events:
+ if not isinstance(event, dict):
+ raise ValueError(
+ f"GLANCE run {source_run_id} contains invalid event metadata"
+ )
+ event_id_value = event.get(
+ "source_event_id", event.get("glance_event_id")
+ )
+ try:
+ source_event_id = int(event_id_value)
+ except (TypeError, ValueError) as exc:
+ raise ValueError(
+ f"GLANCE run {source_run_id} has invalid event id "
+ f"{event_id_value!r}"
+ ) from exc
+ if source_event_id in seen_source_event_ids:
+ raise ValueError(
+ f"GLANCE run {source_run_id} repeats event id {source_event_id}"
+ )
+ seen_source_event_ids.add(source_event_id)
+ event_time, event_time_raw = _parse_trace_sample_time(
+ event.get("timestamp", event.get("event_time")),
+ source_sample_id=source_event_id,
+ )
+ event_source_tool_value = event.get(
+ "source_tool_id",
+ event.get("glance_tool_id", batch_source_tool_id),
+ )
+ try:
+ event_source_tool_id = (
+ int(event_source_tool_value)
+ if event_source_tool_value not in (None, "")
+ else None
+ )
+ except (TypeError, ValueError) as exc:
+ raise ValueError(
+ f"GLANCE event {source_event_id} has invalid source tool id "
+ f"{event_source_tool_value!r}"
+ ) from exc
+ if (
+ live_source
+ and event_source_tool_id is not None
+ and event_source_tool_id != batch_source_tool_id
+ ):
+ raise ValueError(
+ f"GLANCE event {source_event_id} belongs to tool "
+ f"{event_source_tool_id}, expected {batch_source_tool_id}"
+ )
+ raw_event = _json_safe_payload(event)
+ events.append(
+ {
+ "source_event_id": source_event_id,
+ "source_tool_id": event_source_tool_id,
+ "event_time": event_time,
+ "event_time_raw": event_time_raw,
+ "event_type": str(
+ event.get("event_type", event.get("type", "")) or ""
+ ),
+ "category": str(event.get("category") or ""),
+ "description": str(event.get("description") or ""),
+ "raw": raw_event,
+ }
+ )
+ events.sort(
+ key=lambda event: (event["event_time"], event["source_event_id"])
+ )
raw_payload_value = run.get("raw", run.get("raw_payload", {})) or {}
if not isinstance(raw_payload_value, dict):
raise ValueError(f"GLANCE run {source_run_id} raw provenance must be an object")
@@ -939,22 +1291,47 @@ def sync_equipment_run_traces_pg(
trace_metadata = raw_payload.get("trace")
if not isinstance(trace_metadata, dict):
trace_metadata = {}
+ timed_samples = [
+ sample for sample in samples if sample["sample_time"] is not None
+ ]
+ trace_start = (
+ min(timed_samples, key=lambda sample: sample["sample_time"])[
+ "sample_time_raw"
+ ]
+ if timed_samples
+ else None
+ )
+ trace_end = (
+ max(timed_samples, key=lambda sample: sample["sample_time"])[
+ "sample_time_raw"
+ ]
+ if timed_samples
+ else None
+ )
trace_metadata.update(
{
"sample_count": len(samples),
"parameters": parameters,
- "timestamp_timezone": run.get("timestamp_timezone"),
- "start_time": min(samples, key=lambda sample: sample["sample_time"])[
- "sample_time_raw"
- ],
- "end_time": max(samples, key=lambda sample: sample["sample_time"])[
- "sample_time_raw"
+ "events": [
+ {
+ "source_event_id": event["source_event_id"],
+ "source_tool_id": event["source_tool_id"],
+ "timestamp": event["event_time_raw"],
+ "event_type": event["event_type"],
+ "category": event["category"],
+ "description": event["description"],
+ }
+ for event in events
],
+ "timestamp_timezone": run.get("timestamp_timezone"),
+ "start_time": trace_start,
+ "end_time": trace_end,
}
)
raw_payload["trace"] = trace_metadata
- raw_payload["source_upload_id"] = str(upload_id)
- raw_payload["source_db3_filename"] = str(upload_filename or "")
+ if upload_id not in (None, ""):
+ raw_payload["source_upload_id"] = str(upload_id)
+ raw_payload["source_db3_filename"] = str(upload_filename or "")
normalized_runs.append(
{
@@ -970,6 +1347,10 @@ def sync_equipment_run_traces_pg(
"outputs": _json_safe_payload(run.get("outputs", {}) or {}),
"raw": raw_payload,
"samples": samples,
+ "events": events,
+ "source_updated_at": (
+ run.get("source_updated_at") or source_updated_at
+ ),
}
)
@@ -980,59 +1361,141 @@ def sync_equipment_run_traces_pg(
conn = get_pg_superuser_connection()
try:
with conn.cursor() as cur:
+ identity_lock = (
+ f"equipment_run_trace_sync|{source_system}|{equipment_id}|"
+ f"{batch_source_tool_id}"
+ if live_source
+ else f"equipment_run_trace_sync|upload|{upload_id}"
+ )
cur.execute(
"SELECT pg_advisory_xact_lock(hashtextextended(%s, 0))",
- (f"equipment_run_trace_sync|{upload_id}",),
+ (identity_lock,),
)
+ existing_source_run_ids: set[int] = set()
parent_values = []
- for run in normalized_runs:
- parent_values.append(
+ if live_source:
+ cur.execute(
+ """
+ SELECT source_run_id
+ FROM equipment_runs
+ WHERE source_system = %s
+ AND equipment_id = %s
+ AND source_tool_id = %s
+ AND source_run_id = ANY(%s)
+ """,
(
+ source_system,
equipment_id,
- project_id,
- run["lotname"],
- run["run_date"],
- run["is_outlier"],
- run["is_calibration_recipe"],
- run["outlier_type"],
- Json(run["inputs"]),
- Json(run["outputs"]),
- Json(run["raw"]),
- upload_id,
- run["source_run_id"],
- upload_filename,
- "glance_db3",
+ batch_source_tool_id,
+ [run["source_run_id"] for run in normalized_runs],
+ ),
+ )
+ existing_source_run_ids = {
+ int(row[0]) for row in (cur.fetchall() or [])
+ }
+ for run in normalized_runs:
+ parent_values.append(
+ (
+ equipment_id,
+ project_id,
+ run["lotname"],
+ run["run_date"],
+ run["is_outlier"],
+ run["is_calibration_recipe"],
+ run["outlier_type"],
+ Json(run["inputs"]),
+ Json(run["outputs"]),
+ Json(run["raw"]),
+ source_system,
+ source_system,
+ batch_source_tool_id,
+ run["source_run_id"],
+ run["source_updated_at"],
+ )
+ )
+ returned_parents = execute_values(
+ cur,
+ """
+ INSERT INTO equipment_runs (
+ equipment_id, project_id, lotname, run_date,
+ is_outlier, is_calibration_recipe, outlier_type,
+ inputs_json, outputs_json, raw_payload_json,
+ source, source_system, source_tool_id, source_run_id,
+ source_updated_at
+ ) VALUES %s
+ ON CONFLICT (
+ source_system, equipment_id, source_tool_id, source_run_id
)
+ WHERE source_tool_id IS NOT NULL AND source_run_id IS NOT NULL
+ DO UPDATE SET
+ project_id = EXCLUDED.project_id,
+ lotname = EXCLUDED.lotname,
+ run_date = EXCLUDED.run_date,
+ is_outlier = EXCLUDED.is_outlier,
+ is_calibration_recipe = EXCLUDED.is_calibration_recipe,
+ outlier_type = EXCLUDED.outlier_type,
+ inputs_json = EXCLUDED.inputs_json,
+ outputs_json = EXCLUDED.outputs_json,
+ raw_payload_json = EXCLUDED.raw_payload_json,
+ source_updated_at = EXCLUDED.source_updated_at,
+ ingested_at = CURRENT_TIMESTAMP
+ RETURNING id, source_run_id
+ """,
+ parent_values,
+ fetch=True,
+ )
+ else:
+ for run in normalized_runs:
+ parent_values.append(
+ (
+ equipment_id,
+ project_id,
+ run["lotname"],
+ run["run_date"],
+ run["is_outlier"],
+ run["is_calibration_recipe"],
+ run["outlier_type"],
+ Json(run["inputs"]),
+ Json(run["outputs"]),
+ Json(run["raw"]),
+ upload_id,
+ run["source_run_id"],
+ upload_filename,
+ "glance_db3",
+ "glance_db3",
+ )
+ )
+ returned_parents = execute_values(
+ cur,
+ """
+ INSERT INTO equipment_runs (
+ equipment_id, project_id, lotname, run_date,
+ is_outlier, is_calibration_recipe, outlier_type,
+ inputs_json, outputs_json, raw_payload_json,
+ upload_id, row_index, upload_filename, source,
+ source_system
+ ) VALUES %s
+ ON CONFLICT (upload_id, row_index) WHERE upload_id IS NOT NULL
+ DO UPDATE SET
+ equipment_id = EXCLUDED.equipment_id,
+ project_id = EXCLUDED.project_id,
+ lotname = EXCLUDED.lotname,
+ run_date = EXCLUDED.run_date,
+ is_outlier = EXCLUDED.is_outlier,
+ is_calibration_recipe = EXCLUDED.is_calibration_recipe,
+ outlier_type = EXCLUDED.outlier_type,
+ inputs_json = EXCLUDED.inputs_json,
+ outputs_json = EXCLUDED.outputs_json,
+ raw_payload_json = EXCLUDED.raw_payload_json,
+ upload_filename = EXCLUDED.upload_filename,
+ source = EXCLUDED.source,
+ source_system = EXCLUDED.source_system,
+ ingested_at = CURRENT_TIMESTAMP
+ RETURNING id, row_index
+ """,
+ parent_values,
+ fetch=True,
)
-
- returned_parents = execute_values(
- cur,
- """
- INSERT INTO equipment_runs (
- equipment_id, project_id, lotname, run_date,
- is_outlier, is_calibration_recipe, outlier_type,
- inputs_json, outputs_json, raw_payload_json,
- upload_id, row_index, upload_filename, source
- ) VALUES %s
- ON CONFLICT (upload_id, row_index) WHERE upload_id IS NOT NULL
- DO UPDATE SET
- equipment_id = EXCLUDED.equipment_id,
- project_id = EXCLUDED.project_id,
- lotname = EXCLUDED.lotname,
- run_date = EXCLUDED.run_date,
- is_outlier = EXCLUDED.is_outlier,
- is_calibration_recipe = EXCLUDED.is_calibration_recipe,
- outlier_type = EXCLUDED.outlier_type,
- inputs_json = EXCLUDED.inputs_json,
- outputs_json = EXCLUDED.outputs_json,
- raw_payload_json = EXCLUDED.raw_payload_json,
- upload_filename = EXCLUDED.upload_filename,
- source = EXCLUDED.source
- RETURNING id, row_index
- """,
- parent_values,
- fetch=True,
- )
parent_id_by_source_run = {
int(source_run_id): int(parent_id)
for parent_id, source_run_id in returned_parents
@@ -1072,18 +1535,65 @@ def sync_equipment_run_traces_pg(
trace_values,
page_size=500,
)
- # Cascading deletion removes samples belonging to a stale physical
- # run if a deterministic re-conversion yields fewer runs.
cur.execute(
- """
- DELETE FROM equipment_runs
- WHERE upload_id = %s
- AND NOT (id = ANY(%s))
- """,
- (upload_id, parent_ids),
+ "DELETE FROM equipment_run_trace_events "
+ "WHERE equipment_run_id = ANY(%s)",
+ (parent_ids,),
)
+ event_values = []
+ for run in normalized_runs:
+ parent_id = parent_id_by_source_run[run["source_run_id"]]
+ for event in run["events"]:
+ event_values.append(
+ (
+ parent_id,
+ event["source_event_id"],
+ event["source_tool_id"],
+ event["event_time"],
+ event["event_time_raw"],
+ event["event_type"],
+ event["category"],
+ event["description"],
+ Json(event["raw"]),
+ )
+ )
+ if event_values:
+ execute_values(
+ cur,
+ """
+ INSERT INTO equipment_run_trace_events (
+ equipment_run_id, source_event_id, source_tool_id,
+ event_time, event_time_raw, event_type, category,
+ description, raw_event_json
+ ) VALUES %s
+ """,
+ event_values,
+ page_size=500,
+ )
+ if not live_source:
+ # Cascading deletion removes samples and events belonging to a
+ # stale physical DB3 run if re-conversion yields fewer runs.
+ cur.execute(
+ """
+ DELETE FROM equipment_runs
+ WHERE upload_id = %s
+ AND NOT (id = ANY(%s))
+ """,
+ (upload_id, parent_ids),
+ )
conn.commit()
- return len(normalized_runs)
+ if not return_stats:
+ return len(normalized_runs)
+ inserted = len(normalized_runs) - len(existing_source_run_ids)
+ return {
+ "inserted": inserted,
+ "updated": len(existing_source_run_ids),
+ "run_count": len(normalized_runs),
+ "sample_count": sum(
+ len(run["samples"]) for run in normalized_runs
+ ),
+ "event_count": sum(len(run["events"]) for run in normalized_runs),
+ }
except Exception:
conn.rollback()
raise
@@ -1550,7 +2060,10 @@ def get_equipment_run_trace_pg(
r.id,
r.lotname,
r.raw_payload_json,
- r.source
+ r.source,
+ r.source_system,
+ r.source_tool_id,
+ r.source_run_id
FROM equipment_runs r
WHERE r.id = %s
LIMIT 1
@@ -1583,6 +2096,24 @@ def get_equipment_run_trace_pg(
(internal_run_id,),
)
sample_rows = [dict(row) for row in cur.fetchall()]
+ cur.execute(
+ """
+ SELECT
+ source_event_id,
+ source_tool_id,
+ event_time,
+ event_time_raw,
+ event_type,
+ category,
+ description,
+ raw_event_json
+ FROM equipment_run_trace_events
+ WHERE equipment_run_id = %s
+ ORDER BY event_time, source_event_id
+ """,
+ (internal_run_id,),
+ )
+ event_rows = [dict(row) for row in cur.fetchall()]
conn.commit()
if not sample_rows:
@@ -1665,14 +2196,20 @@ def get_equipment_run_trace_pg(
samples: list[dict[str, Any]] = []
for sample_row in sample_rows:
values = sample_row["values_json"]
- raw_timestamp = str(sample_row.get("sample_time_raw") or "").strip()
- if not raw_timestamp:
+ raw_timestamp_value = sample_row.get("sample_time_raw")
+ raw_timestamp = (
+ str(raw_timestamp_value).strip()
+ if raw_timestamp_value is not None
+ else None
+ )
+ if not raw_timestamp and sample_row.get("sample_time") is not None:
parsed_timestamp = sample_row.get("sample_time")
raw_timestamp = (
parsed_timestamp.isoformat()
if hasattr(parsed_timestamp, "isoformat")
- else str(parsed_timestamp or "")
+ else str(parsed_timestamp)
)
+ relative_seconds = sample_row.get("relative_seconds")
samples.append(
{
"sample_index": int(sample_row["sample_index"]),
@@ -1683,29 +2220,110 @@ def get_equipment_run_trace_pg(
else None
),
"timestamp": raw_timestamp,
- "relative_seconds": float(sample_row["relative_seconds"] or 0.0),
+ "relative_seconds": (
+ float(relative_seconds)
+ if relative_seconds is not None
+ else None
+ ),
"values": {key: values.get(key) for key in selected_keys},
}
)
- source_run_value = glance_metadata.get("run_id")
+ source_run_value = (
+ parent.get("source_run_id")
+ if parent.get("source_run_id") is not None
+ else glance_metadata.get("run_id")
+ )
try:
source_run_id: Any = int(source_run_value)
except (TypeError, ValueError):
source_run_id = source_run_value
- min_row = min(sample_rows, key=lambda row: row["sample_time"])
- max_row = max(sample_rows, key=lambda row: row["sample_time"])
+ timed_rows = [
+ row for row in sample_rows if row.get("sample_time") is not None
+ ]
+ min_row = (
+ min(timed_rows, key=lambda row: row["sample_time"])
+ if timed_rows
+ else None
+ )
+ max_row = (
+ max(timed_rows, key=lambda row: row["sample_time"])
+ if timed_rows
+ else None
+ )
+ events = []
+ trace_start_time = min_row.get("sample_time") if min_row else None
+ for event_row in event_rows:
+ event_time_raw = str(event_row.get("event_time_raw") or "").strip()
+ if not event_time_raw:
+ parsed_event_time = event_row.get("event_time")
+ event_time_raw = (
+ parsed_event_time.isoformat()
+ if hasattr(parsed_event_time, "isoformat")
+ else str(parsed_event_time or "")
+ )
+ events.append(
+ {
+ "source_event_id": int(event_row["source_event_id"]),
+ "source_tool_id": (
+ int(event_row["source_tool_id"])
+ if event_row.get("source_tool_id") is not None
+ else None
+ ),
+ "timestamp": event_time_raw,
+ "relative_seconds": (
+ float(
+ (
+ event_row["event_time"] - trace_start_time
+ ).total_seconds()
+ )
+ if event_row.get("event_time") is not None
+ and trace_start_time is not None
+ else None
+ ),
+ "type": str(event_row.get("event_type") or ""),
+ "event_type": str(event_row.get("event_type") or ""),
+ "category": str(event_row.get("category") or ""),
+ "description": str(event_row.get("description") or ""),
+ "raw": event_row.get("raw_event_json") or {},
+ }
+ )
return {
"run_id": run_id,
"source_run_id": source_run_id,
+ "source_system": (
+ parent.get("source_system") or parent.get("source") or ""
+ ),
+ "source_tool_id": (
+ int(parent["source_tool_id"])
+ if parent.get("source_tool_id") is not None
+ else None
+ ),
"lot_name": str(parent.get("lotname") or ""),
"sample_count": len(samples),
- "start_time": str(min_row.get("sample_time_raw") or ""),
- "end_time": str(max_row.get("sample_time_raw") or ""),
+ "start_time": (
+ str(min_row.get("sample_time_raw") or "")
+ if min_row is not None
+ else None
+ ),
+ "end_time": (
+ str(max_row.get("sample_time_raw") or "")
+ if max_row is not None
+ else None
+ ),
"timestamp_timezone": trace_metadata.get("timestamp_timezone"),
"parameters": response_parameters,
"samples": samples,
+ "events": events,
+ "process_status": trace_metadata.get(
+ "process_outcome",
+ trace_metadata.get("process_status", "unknown"),
+ ),
+ "process_outcome": trace_metadata.get(
+ "process_outcome",
+ trace_metadata.get("process_status", "unknown"),
+ ),
}
except MissingRuntimeConfiguration as exc:
logger.error(
diff --git a/api/glance_ingestion.py b/api/glance_ingestion.py
new file mode 100644
index 0000000..587cf1e
--- /dev/null
+++ b/api/glance_ingestion.py
@@ -0,0 +1,483 @@
+"""Validation and scientific metadata rules for production GLANCE handoffs."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+import re
+from datetime import datetime, timezone
+from typing import Any, Literal
+
+from pydantic import BaseModel, ConfigDict, Field
+
+
+class GlanceBatchEnvelope(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+ version: Literal["1"]
+ source_system: Literal["glance"] = "glance"
+ batch_id: str = Field(min_length=1, max_length=255)
+ equipment_id: str = Field(min_length=1, max_length=255)
+ source_tool_id: int
+ source_tool_name: str = ""
+ project_id: str = Field(min_length=1, max_length=50)
+ previous_cursor: str | None = None
+ proposed_cursor: str | None = None
+ poll_started_at: datetime | None = None
+ source_updated_at: datetime | None = None
+ runs: list[Any]
+
+
+class GlanceParameter(BaseModel):
+ model_config = ConfigDict(extra="allow")
+
+ source_parameter_id: int | str | None = None
+ key: str | None = None
+ name: str = ""
+ source_name: str = ""
+ unit: str = ""
+
+
+class GlanceSample(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+ source_sample_id: int
+ sample_index: int | None = None
+ source_tool_id: int | None = None
+ timestamp: str | None = None
+ values: dict[str, Any] = Field(default_factory=dict)
+
+
+class GlanceEvent(BaseModel):
+ model_config = ConfigDict(extra="allow")
+
+ source_event_id: int
+ source_tool_id: int | None = None
+ timestamp: str
+ event_type: str = ""
+ category: str = ""
+ description: str = ""
+
+
+class GlanceRun(BaseModel):
+ model_config = ConfigDict(extra="allow")
+
+ source_run_id: int
+ source_updated_at: datetime | None = None
+ lotname: str = ""
+ material: str = ""
+ run_start_time: str
+ run_end_time: str | None = None
+ source_status: str = ""
+ recipe: dict[str, Any] = Field(default_factory=dict)
+ inputs: dict[str, Any] = Field(default_factory=dict)
+ outputs: dict[str, Any] = Field(default_factory=dict)
+ parameters: list[GlanceParameter] = Field(default_factory=list)
+ samples: list[GlanceSample]
+ events: list[GlanceEvent] = Field(default_factory=list)
+ raw: dict[str, Any] = Field(default_factory=dict)
+ timestamp_timezone: str | None = None
+
+
+def payload_sha256(payload: Any) -> str:
+ encoded = json.dumps(
+ payload,
+ sort_keys=True,
+ separators=(",", ":"),
+ default=str,
+ ).encode("utf-8")
+ return hashlib.sha256(encoded).hexdigest()
+
+
+def load_production_mapping() -> dict[str, dict[str, Any]]:
+ """Load explicit equipment/tool/project assignments from one JSON setting."""
+ raw = os.getenv("GLANCE_PRODUCTION_MAPPINGS_JSON", "").strip()
+ if not raw:
+ raise RuntimeError("GLANCE_PRODUCTION_MAPPINGS_JSON is not configured")
+ try:
+ parsed = json.loads(raw)
+ except json.JSONDecodeError as exc:
+ raise RuntimeError(
+ "GLANCE_PRODUCTION_MAPPINGS_JSON is not valid JSON"
+ ) from exc
+ if not isinstance(parsed, dict):
+ raise RuntimeError(
+ "GLANCE_PRODUCTION_MAPPINGS_JSON must be an equipment-keyed object"
+ )
+ mappings: dict[str, dict[str, Any]] = {}
+ for equipment_id, mapping in parsed.items():
+ if not isinstance(mapping, dict):
+ raise RuntimeError(
+ f"GLANCE mapping for {equipment_id!r} must be an object"
+ )
+ try:
+ tool_id = int(mapping["source_tool_id"])
+ except (KeyError, TypeError, ValueError) as exc:
+ raise RuntimeError(
+ f"GLANCE mapping for {equipment_id!r} requires source_tool_id"
+ ) from exc
+ project_id = str(mapping.get("project_id") or "").strip()
+ if not project_id:
+ raise RuntimeError(
+ f"GLANCE mapping for {equipment_id!r} requires project_id"
+ )
+ mappings[str(equipment_id)] = {
+ "source_tool_id": tool_id,
+ "source_tool_name": str(mapping.get("source_tool_name") or ""),
+ "project_id": project_id,
+ }
+ return mappings
+
+
+def validate_batch_mapping(
+ envelope: GlanceBatchEnvelope,
+ *,
+ mappings: dict[str, dict[str, Any]],
+) -> dict[str, Any]:
+ mapping = mappings.get(envelope.equipment_id)
+ if not mapping:
+ raise ValueError(
+ f"Equipment {envelope.equipment_id!r} has no production GLANCE mapping"
+ )
+ if int(mapping["source_tool_id"]) != envelope.source_tool_id:
+ raise ValueError(
+ f"GLANCE tool {envelope.source_tool_id} is not mapped to equipment "
+ f"{envelope.equipment_id!r}"
+ )
+ expected_name = str(mapping.get("source_tool_name") or "").strip()
+ if expected_name and envelope.source_tool_name != expected_name:
+ raise ValueError(
+ f"GLANCE tool name {envelope.source_tool_name!r} does not match "
+ f"the configured source tool {expected_name!r}"
+ )
+ if str(mapping["project_id"]) != envelope.project_id:
+ raise ValueError(
+ f"Project {envelope.project_id!r} is not the configured production "
+ f"target for equipment {envelope.equipment_id!r}"
+ )
+ return mapping
+
+
+def _parse_source_time(value: Any, *, field_name: str) -> tuple[datetime, str]:
+ raw = str(value or "").strip()
+ if not raw:
+ raise ValueError(f"{field_name} is required")
+ candidate = raw[:-1] + "+00:00" if raw.endswith("Z") else raw
+ try:
+ parsed = datetime.fromisoformat(candidate)
+ except ValueError as exc:
+ raise ValueError(f"{field_name} has invalid timestamp {raw!r}") from exc
+ if parsed.tzinfo is not None:
+ parsed = parsed.astimezone(timezone.utc)
+ return parsed.replace(tzinfo=None), raw
+
+
+def _match_key(value: Any) -> str:
+ text = "".join(
+ character if character.isalnum() else " "
+ for character in str(value or "").strip().casefold()
+ )
+ aliases = {
+ "fwd": "forward",
+ "pos": "position",
+ "pwr": "power",
+ "setpt": "setpoint",
+ "temp": "temperature",
+ }
+ return " ".join(aliases.get(token, token) for token in text.split())
+
+
+def _numeric_id(value: Any) -> str:
+ text = str(value or "").strip().casefold().removeprefix("p")
+ return str(int(text)) if text.isdigit() else ""
+
+
+def equipment_parameter_registry(
+ equipment: dict[str, Any] | None,
+) -> list[dict[str, str]]:
+ registry: list[dict[str, str]] = []
+ if not isinstance(equipment, dict):
+ return registry
+ for kind, records in (
+ ("input", equipment.get("parameters")),
+ ("output", equipment.get("outputs")),
+ ):
+ for record in records if isinstance(records, list) else []:
+ if not isinstance(record, dict):
+ continue
+ name = str(record.get("name") or "").strip()
+ if not name:
+ continue
+ configured_source_id = str(
+ record.get("equipment_parameter_id") or ""
+ ).strip()
+ registry.append(
+ {
+ "kind": kind,
+ "id": configured_source_id
+ or str(record.get("id") or name),
+ "source_parameter_id": _numeric_id(configured_source_id),
+ "name": name,
+ "unit": str(record.get("unit") or "").strip(),
+ "match_key": _match_key(name),
+ }
+ )
+ return registry
+
+
+def annotate_parameter(
+ parameter: GlanceParameter,
+ registry: list[dict[str, str]],
+) -> tuple[dict[str, Any], str | None]:
+ source_name = str(parameter.source_name or parameter.name or parameter.key or "")
+ source_unit = str(parameter.unit or "").strip()
+ source_id = _numeric_id(parameter.source_parameter_id)
+ tokens = _match_key(source_name).split()
+ candidate_keys = {_match_key(source_name)}
+ if len(tokens) > 1 and tokens[0] in {"g", "p", "t", "c", "r"}:
+ candidate_keys.add(" ".join(tokens[1:]))
+
+ # An explicit source ID is authoritative. Never fall back to a convenient
+ # name when that ID is absent from or conflicts inside the registry.
+ if source_id:
+ matches = [
+ record
+ for record in registry
+ if record["source_parameter_id"] == source_id
+ ]
+ match_method = "source_parameter_id"
+ else:
+ matches = [
+ record
+ for record in registry
+ if not record["source_parameter_id"]
+ and record["match_key"] in candidate_keys
+ and (
+ not source_unit
+ or not record["unit"]
+ or record["unit"].casefold() == source_unit.casefold()
+ )
+ ]
+ match_method = "name_and_unit"
+ unique = {
+ (record["kind"], record["id"], record["name"]): record
+ for record in matches
+ }
+ source_key = parameter.key or (
+ f"p{source_id}" if source_id else source_name
+ )
+ metadata: dict[str, Any] = {
+ "key": source_key,
+ "source_parameter_id": parameter.source_parameter_id,
+ "name": source_name or source_key,
+ "source_name": source_name,
+ "unit": source_unit,
+ "registered": len(unique) == 1,
+ }
+ warning = None
+ if len(unique) == 1:
+ match = next(iter(unique.values()))
+ metadata.update(
+ {
+ "registered_name": match["name"],
+ "registered_parameter_id": match["id"],
+ "registration_kind": match["kind"],
+ "registration_match": match_method,
+ }
+ )
+ elif len(unique) > 1:
+ warning = (
+ f"Parameter {source_key!r} matches multiple registered parameters; "
+ "it remains unregistered"
+ )
+ elif source_id:
+ warning = (
+ f"Parameter {source_key!r} has unregistered source ID {source_id}"
+ )
+ return metadata, warning
+
+
+def _derive_process_outcome(
+ events: list[dict[str, Any]],
+ *,
+ run_end: datetime | None,
+) -> str:
+ if run_end is None:
+ return "unknown"
+ terminal = [
+ event
+ for event in events
+ if event["_parsed_time"] == run_end
+ ]
+ for event in terminal:
+ label = " ".join(
+ str(event.get(field) or "")
+ for field in ("event_type", "category", "description")
+ ).casefold()
+ if "abort" in label:
+ return "abort"
+ for event in terminal:
+ label = " ".join(
+ str(event.get(field) or "")
+ for field in ("event_type", "category", "description")
+ ).casefold()
+ if "complete" in label or "process end" in label:
+ return "complete"
+ return "unknown"
+
+
+def prepare_live_run(
+ raw_run: dict[str, Any],
+ *,
+ source_tool_id: int,
+ source_updated_at: datetime | None,
+ registry: list[dict[str, str]],
+) -> tuple[dict[str, Any], list[str]]:
+ run = GlanceRun.model_validate(raw_run)
+ if not run.samples:
+ raise ValueError(f"GLANCE run {run.source_run_id} has no sample records")
+ start_time, start_raw = _parse_source_time(
+ run.run_start_time,
+ field_name=f"run {run.source_run_id} start",
+ )
+ end_time = None
+ end_raw = None
+ if run.run_end_time:
+ end_time, end_raw = _parse_source_time(
+ run.run_end_time,
+ field_name=f"run {run.source_run_id} end",
+ )
+ if end_time < start_time:
+ raise ValueError(
+ f"GLANCE run {run.source_run_id} ends before it starts"
+ )
+
+ warnings: list[str] = []
+ parameters = []
+ for parameter in run.parameters:
+ annotated, warning = annotate_parameter(parameter, registry)
+ parameters.append(annotated)
+ if warning:
+ warnings.append(warning)
+
+ samples = []
+ for index, sample in enumerate(run.samples):
+ sample_time = None
+ sample_time_raw = None
+ if sample.timestamp is not None and str(sample.timestamp).strip():
+ sample_time, sample_time_raw = _parse_source_time(
+ sample.timestamp,
+ field_name=(
+ f"run {run.source_run_id} sample "
+ f"{sample.source_sample_id}"
+ ),
+ )
+ else:
+ warnings.append(
+ f"Sample {sample.source_sample_id} has no source timestamp"
+ )
+ if sample.source_tool_id not in (None, source_tool_id):
+ raise ValueError(
+ f"Sample {sample.source_sample_id} belongs to tool "
+ f"{sample.source_tool_id}, expected {source_tool_id}"
+ )
+ if sample_time is not None and (
+ sample_time < start_time
+ or (end_time is not None and sample_time > end_time)
+ ):
+ warnings.append(
+ f"Sample {sample.source_sample_id} is outside the declared run interval"
+ )
+ samples.append(
+ {
+ "source_sample_id": sample.source_sample_id,
+ "sample_index": (
+ sample.sample_index
+ if sample.sample_index is not None
+ else index
+ ),
+ "source_tool_id": source_tool_id,
+ "sample_time_raw": sample_time_raw,
+ "values": sample.values,
+ }
+ )
+
+ events = []
+ for event in run.events:
+ event_time, event_time_raw = _parse_source_time(
+ event.timestamp,
+ field_name=f"run {run.source_run_id} event {event.source_event_id}",
+ )
+ if event.source_tool_id not in (None, source_tool_id):
+ raise ValueError(
+ f"Event {event.source_event_id} belongs to tool "
+ f"{event.source_tool_id}, expected {source_tool_id}"
+ )
+ if event_time < start_time or (
+ end_time is not None and event_time > end_time
+ ):
+ raise ValueError(
+ f"Event {event.source_event_id} is outside run "
+ f"{run.source_run_id}'s authoritative interval"
+ )
+ event_record = event.model_dump()
+ event_record["source_tool_id"] = source_tool_id
+ event_record["timestamp"] = event_time_raw
+ event_record["_parsed_time"] = event_time
+ events.append(event_record)
+
+ process_outcome = _derive_process_outcome(events, run_end=end_time)
+ stored_events = [
+ {key: value for key, value in event.items() if key != "_parsed_time"}
+ for event in events
+ ]
+ raw = dict(run.raw)
+ glance = raw.get("glance")
+ if not isinstance(glance, dict):
+ glance = {}
+ glance.update(
+ {
+ "run_id": run.source_run_id,
+ "tool_id": source_tool_id,
+ "run_start_time": start_raw,
+ "run_end_time": end_raw,
+ "source_status": run.source_status,
+ "material": run.material,
+ "recipe": run.recipe,
+ }
+ )
+ raw["glance"] = glance
+ trace = raw.get("trace")
+ if not isinstance(trace, dict):
+ trace = {}
+ trace["process_outcome"] = process_outcome
+ raw["trace"] = trace
+ effective_source_updated_at = run.source_updated_at or source_updated_at
+ if (
+ effective_source_updated_at is not None
+ and effective_source_updated_at.tzinfo is None
+ ):
+ warnings.append(
+ "source_updated_at had no timezone and was preserved only in raw "
+ "provenance; the normalized source revision timestamp is unset"
+ )
+ effective_source_updated_at = None
+
+ return (
+ {
+ "source_run_id": run.source_run_id,
+ "source_updated_at": effective_source_updated_at,
+ "lotname": run.lotname,
+ "run_start_time": start_raw,
+ "run_date": start_raw,
+ "inputs": run.inputs,
+ "outputs": run.outputs,
+ "parameters": parameters,
+ "samples": samples,
+ "events": stored_events,
+ "raw": raw,
+ "timestamp_timezone": run.timestamp_timezone,
+ },
+ warnings,
+ )
diff --git a/api/main.py b/api/main.py
index 5668b9c..1d0bf9c 100644
--- a/api/main.py
+++ b/api/main.py
@@ -46,6 +46,8 @@
"etcher_run_files",
"equipment_runs",
"equipment_run_trace_samples",
+ "equipment_run_trace_events",
+ "glance_ingestion_audit",
"samples",
"experiment_samples",
"run_samples",
@@ -144,7 +146,9 @@ def _run_startup_migrations() -> None:
from data_loader_pg import get_pg_superuser_connection
from data_loader_pg import ensure_etcher_run_file_refs_pg
from data_loader_pg import ensure_equipment_run_trace_samples_pg
+ from data_loader_pg import ensure_equipment_run_trace_events_pg
from data_loader_pg import ensure_equipment_runs_pg
+ from data_loader_pg import ensure_glance_ingestion_audit_pg
from data_loader_pg import ensure_etcher_recipe_name_column_pg
from metadata_pg import (
ensure_experiment_proposal_generation_columns_pg,
@@ -176,6 +180,8 @@ def _run_startup_migrations() -> None:
ensure_etcher_recipe_name_column_pg()
ensure_equipment_runs_pg()
ensure_equipment_run_trace_samples_pg()
+ ensure_equipment_run_trace_events_pg()
+ ensure_glance_ingestion_audit_pg()
ensure_experiment_proposal_generation_columns_pg()
ensure_experiment_type_reuse_columns_pg()
ensure_experiment_type_versioning_columns_pg()
diff --git a/api/model_registry.py b/api/model_registry.py
index 272c6aa..f191194 100644
--- a/api/model_registry.py
+++ b/api/model_registry.py
@@ -390,6 +390,43 @@ def load_active_bundle(*, domain_id: str, target: str) -> Optional[dict[str, Any
return bundle
+def list_active_model_metadata(*, domain_id: str) -> list[dict[str, Any]]:
+ """Return active model identity and metrics without loading pickle artifacts."""
+ from data_loader_pg import get_pg_superuser_connection
+ from psycopg2.extras import RealDictCursor
+
+ conn = get_pg_superuser_connection()
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute(
+ """
+ SELECT
+ id AS model_id,
+ target,
+ model_kind,
+ version,
+ snapshot_hash,
+ n_train,
+ metrics,
+ feature_columns,
+ trained_at
+ FROM models
+ WHERE domain_id = %s AND is_active = true
+ ORDER BY target
+ """,
+ (domain_id,),
+ )
+ rows = [dict(row) for row in cur.fetchall()]
+ conn.commit()
+ finally:
+ conn.close()
+ for row in rows:
+ trained_at = row.get("trained_at")
+ if hasattr(trained_at, "isoformat"):
+ row["trained_at"] = trained_at.isoformat()
+ return rows
+
+
def list_models(
*,
domain_id: Optional[str] = None,
diff --git a/api/routers/dataset_v2.py b/api/routers/dataset_v2.py
index fe1901e..2c27519 100644
--- a/api/routers/dataset_v2.py
+++ b/api/routers/dataset_v2.py
@@ -12,6 +12,7 @@
from fastapi import Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel
+from pydantic import ValidationError
# Import the new PostgreSQL loader functions
from data_loader_pg import (
@@ -19,11 +20,14 @@
create_project_pg,
delete_project_pg,
get_equipment_run_trace_pg,
+ get_glance_ingestion_project_pg,
get_projects_list_pg,
get_run_detail_pg,
get_runs_list_pg,
list_project_members_pg,
remove_project_member_pg,
+ record_glance_ingestion_audit_pg,
+ sync_equipment_run_traces_pg,
sync_runs_pg,
)
from domain_configs import load_domain_config, summarize_domain_config
@@ -47,6 +51,15 @@
update_publication_request_status_pg,
update_project_experiment_workflow_pg,
update_sample_pg,
+ get_equipment_pg,
+)
+from glance_ingestion import (
+ GlanceBatchEnvelope,
+ equipment_parameter_registry,
+ load_production_mapping,
+ payload_sha256,
+ prepare_live_run,
+ validate_batch_mapping,
)
from security import (
PlatformUser,
@@ -59,6 +72,18 @@
router = APIRouter(prefix="/dataset/v2", tags=["dataset-v2"])
+def _concise_validation_error(exc: Exception) -> str:
+ """Format validation failures without echoing source payload values."""
+ if isinstance(exc, ValidationError):
+ messages = []
+ for error in exc.errors(include_url=False, include_input=False):
+ location = ".".join(str(part) for part in error.get("loc", ()))
+ message = str(error.get("msg") or error.get("type") or "invalid")
+ messages.append(f"{location}: {message}" if location else message)
+ return "; ".join(messages)
+ return str(exc)
+
+
class CreateProjectPayload(BaseModel):
name: str
description: str = ""
@@ -817,6 +842,190 @@ def run_trace(
return record
+@router.post("/glance/traces/sync")
+async def sync_glance_traces(
+ request: Request,
+ _: None = Depends(require_ingestion_token),
+):
+ """Validate and atomically upsert complete production GLANCE traces.
+
+ This endpoint is the only write boundary exposed to the scheduled connector.
+ The connector has no DT database credentials. A partial response commits
+ valid runs but explicitly refuses cursor acknowledgement so a later overlap
+ poll can recover rejected runs after their source/configuration is corrected.
+ """
+ try:
+ raw_payload = await request.json()
+ envelope = GlanceBatchEnvelope.model_validate(raw_payload)
+ except (ValueError, ValidationError) as exc:
+ raise HTTPException(
+ status_code=422,
+ detail=(
+ "Invalid GLANCE batch envelope: "
+ + _concise_validation_error(exc)
+ ),
+ ) from exc
+
+ try:
+ mappings = load_production_mapping()
+ validate_batch_mapping(envelope, mappings=mappings)
+ except RuntimeError as exc:
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+
+ equipment = get_equipment_pg(envelope.equipment_id)
+ if not equipment:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Equipment {envelope.equipment_id!r} is not registered",
+ )
+ project = get_glance_ingestion_project_pg(envelope.project_id)
+ if not project:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Project {envelope.project_id!r} does not exist",
+ )
+ project_equipment_id = str(project.get("equipment_id") or "").strip()
+ if project_equipment_id != envelope.equipment_id:
+ raise HTTPException(
+ status_code=400,
+ detail=(
+ f"Project {envelope.project_id!r} is assigned to equipment "
+ f"{project_equipment_id!r}, not {envelope.equipment_id!r}"
+ ),
+ )
+
+ registry = equipment_parameter_registry(equipment)
+ valid_runs: list[dict] = []
+ warnings: list[str] = []
+ rejected: list[dict[str, object]] = []
+ seen_source_run_ids: set[int] = set()
+ for index, raw_run in enumerate(envelope.runs):
+ try:
+ if not isinstance(raw_run, dict):
+ raise ValueError("run payload must be an object")
+ prepared, run_warnings = prepare_live_run(
+ raw_run,
+ source_tool_id=envelope.source_tool_id,
+ source_updated_at=envelope.source_updated_at,
+ registry=registry,
+ )
+ source_run_id = int(prepared["source_run_id"])
+ if source_run_id in seen_source_run_ids:
+ raise ValueError(
+ f"duplicate source run id {source_run_id} in this batch"
+ )
+ seen_source_run_ids.add(source_run_id)
+ valid_runs.append(prepared)
+ warnings.extend(
+ f"Run {source_run_id}: {warning}"
+ for warning in run_warnings
+ )
+ except (ValueError, ValidationError) as exc:
+ source_run_id = (
+ raw_run.get("source_run_id")
+ if isinstance(raw_run, dict)
+ else None
+ )
+ rejected.append(
+ {
+ "index": index,
+ "source_run_id": source_run_id,
+ "error": _concise_validation_error(exc),
+ }
+ )
+
+ digest = payload_sha256(raw_payload)
+ base_audit = {
+ "batch_id": envelope.batch_id,
+ "source_system": envelope.source_system,
+ "equipment_id": envelope.equipment_id,
+ "source_tool_id": envelope.source_tool_id,
+ "project_id": envelope.project_id,
+ "previous_cursor": envelope.previous_cursor,
+ "proposed_cursor": envelope.proposed_cursor,
+ "poll_started_at": envelope.poll_started_at,
+ "payload_sha256": digest,
+ }
+
+ if not valid_runs:
+ try:
+ record_glance_ingestion_audit_pg(
+ **base_audit,
+ status="rejected",
+ warning_count=len(warnings),
+ error_text="; ".join(str(item["error"]) for item in rejected),
+ )
+ except Exception:
+ logger.warning("Could not persist rejected GLANCE audit", exc_info=True)
+ raise HTTPException(
+ status_code=422,
+ detail={
+ "message": "No valid GLANCE runs were supplied",
+ "rejected": rejected,
+ "cursor_acknowledged": False,
+ },
+ )
+
+ try:
+ stats = sync_equipment_run_traces_pg(
+ equipment_id=envelope.equipment_id,
+ project_id=envelope.project_id,
+ runs=valid_runs,
+ source_system=envelope.source_system,
+ source_tool_id=envelope.source_tool_id,
+ source_updated_at=envelope.source_updated_at,
+ return_stats=True,
+ )
+ except (ValueError, RuntimeError) as exc:
+ try:
+ record_glance_ingestion_audit_pg(
+ **base_audit,
+ status="failed",
+ run_count=len(valid_runs),
+ sample_count=sum(len(run["samples"]) for run in valid_runs),
+ event_count=sum(len(run["events"]) for run in valid_runs),
+ warning_count=len(warnings),
+ error_text=str(exc),
+ )
+ except Exception:
+ logger.warning("Could not persist failed GLANCE audit", exc_info=True)
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
+
+ status = "partial" if rejected else "success"
+ cursor_acknowledged = not rejected
+ try:
+ record_glance_ingestion_audit_pg(
+ **base_audit,
+ status=status,
+ run_count=stats["run_count"],
+ sample_count=stats["sample_count"],
+ event_count=stats["event_count"],
+ warning_count=len(warnings),
+ error_text="; ".join(str(item["error"]) for item in rejected),
+ )
+ except Exception:
+ # Audit storage is operational metadata. Trace ingestion has already
+ # committed and must not be lied about or rolled back after the fact.
+ logger.warning("Could not persist GLANCE ingestion audit", exc_info=True)
+
+ response = {
+ "status": status,
+ "batch_id": envelope.batch_id,
+ "cursor_acknowledged": cursor_acknowledged,
+ **stats,
+ "warning_count": len(warnings),
+ "warnings": warnings[:100],
+ "rejected_count": len(rejected),
+ "rejected": rejected[:100],
+ "payload_sha256": digest,
+ }
+ if rejected:
+ return JSONResponse(status_code=207, content=response)
+ return response
+
+
@router.post("/runs/sync")
async def sync_runs(
request: Request,
diff --git a/api/routers/ml.py b/api/routers/ml.py
index ab7dbbd..c1b6e5a 100644
--- a/api/routers/ml.py
+++ b/api/routers/ml.py
@@ -13,6 +13,7 @@
add later (e.g. ``require_role("pi")``) can swap into the same dependency.
"""
import logging
+import os
from fastapi import APIRouter, Body, Depends
from typing import Optional
@@ -25,6 +26,68 @@
router = APIRouter(prefix="/ml", tags=["ml"])
+def get_ml_runtime_status() -> dict:
+ configured_engine = (
+ os.getenv("NEXTJS_PROPOSAL_ENGINE", "legacy").strip().lower()
+ or "legacy"
+ )
+ configured_model = (
+ os.getenv("ML_PROPOSAL_MODEL", "auto").strip().lower() or "auto"
+ )
+ if configured_model in {"rf", "random_forest", "random-forest"}:
+ effective_model_policy = "random_forest"
+ elif configured_model in {"gpr", "gaussian_process", "gaussian-process"}:
+ effective_model_policy = "gpr"
+ else:
+ effective_model_policy = "gpr_up_to_400_rows_then_random_forest"
+
+ active_models = []
+ registry_error = None
+ try:
+ from model_registry import list_active_model_metadata
+
+ active_models = list_active_model_metadata(domain_id="etcher")
+ except Exception as exc:
+ registry_error = "active model registry unavailable"
+ logger.warning("Could not read active model metadata: %s", exc)
+
+ return {
+ "proposal_engine": configured_engine,
+ "proposal_engine_policy": (
+ "legacy canonical two-target path with API optimizer fallback"
+ if configured_engine == "legacy"
+ else "API optimizer"
+ ),
+ "proposal_model": configured_model,
+ "effective_model_policy": effective_model_policy,
+ "candidate_count": int(os.getenv("ML_PROPOSAL_CANDIDATES", "4096")),
+ "legacy": {
+ "candidate_count": int(os.getenv("PROPOSALS_SOBOL", "10000")),
+ "acquisition": os.getenv(
+ "PROPOSALS_ACQ", "MONTE_CARLO_EI"
+ ).strip().upper(),
+ "batch_implementation": os.getenv(
+ "PROPOSALS_BATCH_IMPL", "FANTASY_UPDATE"
+ ).strip().upper(),
+ "gpr_start_iteration": 5,
+ },
+ "active_registry_models": active_models,
+ "registry_error": registry_error,
+ "shadow_evaluation_enabled": (
+ os.getenv("ML_SHADOW_EVALUATION_ENABLED", "false").lower()
+ in {"1", "true", "yes", "on"}
+ ),
+ }
+
+
+@router.get("/status")
+def ml_runtime_status(
+ _: PlatformUser = Depends(get_platform_user),
+):
+ """Report deployed proposal/model configuration without implying GPR is live."""
+ return get_ml_runtime_status()
+
+
def _user_training_context(user: PlatformUser, project_id: Optional[str] = None) -> dict:
return {
"nanohub_user_id": user.id,
@@ -172,6 +235,7 @@ def _compute() -> dict:
# Add constraint info to the response
result["constraints_applied"] = constraints
result["source"] = result.get("source", "local_gpr")
+ result["runtime"] = get_ml_runtime_status()
logger.info(
"[ml/optimize] Complete — %d batches, source=%s",
diff --git a/api/scripts/add_equipment_runs.sql b/api/scripts/add_equipment_runs.sql
index 62b287e..20f46b4 100644
--- a/api/scripts/add_equipment_runs.sql
+++ b/api/scripts/add_equipment_runs.sql
@@ -24,6 +24,20 @@ CREATE TABLE IF NOT EXISTS equipment_runs (
-- Backfill for tables created before row_index existed.
ALTER TABLE equipment_runs ADD COLUMN IF NOT EXISTS row_index INT;
+ALTER TABLE equipment_runs ADD COLUMN IF NOT EXISTS source_system VARCHAR(100);
+ALTER TABLE equipment_runs ADD COLUMN IF NOT EXISTS source_tool_id BIGINT;
+ALTER TABLE equipment_runs ADD COLUMN IF NOT EXISTS source_run_id BIGINT;
+ALTER TABLE equipment_runs
+ ADD COLUMN IF NOT EXISTS source_updated_at TIMESTAMP WITH TIME ZONE;
+ALTER TABLE equipment_runs
+ ADD COLUMN IF NOT EXISTS ingested_at TIMESTAMP WITH TIME ZONE
+ DEFAULT CURRENT_TIMESTAMP;
+UPDATE equipment_runs
+SET source_system = COALESCE(NULLIF(source, ''), 'data_upload')
+WHERE source_system IS NULL OR source_system = '';
+ALTER TABLE equipment_runs
+ ALTER COLUMN source_system SET DEFAULT 'data_upload';
+ALTER TABLE equipment_runs ALTER COLUMN source_system SET NOT NULL;
CREATE INDEX IF NOT EXISTS idx_equipment_runs_equipment_id ON equipment_runs(equipment_id);
CREATE INDEX IF NOT EXISTS idx_equipment_runs_project_id ON equipment_runs(project_id);
@@ -31,6 +45,11 @@ CREATE INDEX IF NOT EXISTS idx_equipment_runs_upload_id ON equipment_runs(upload
CREATE UNIQUE INDEX IF NOT EXISTS uq_equipment_runs_upload_row
ON equipment_runs(upload_id, row_index)
WHERE upload_id IS NOT NULL;
+CREATE UNIQUE INDEX IF NOT EXISTS uq_equipment_runs_source_identity
+ ON equipment_runs(
+ source_system, equipment_id, source_tool_id, source_run_id
+ )
+ WHERE source_tool_id IS NOT NULL AND source_run_id IS NOT NULL;
ALTER TABLE equipment_runs ENABLE ROW LEVEL SECURITY;
@@ -67,6 +86,87 @@ BEGIN
END
$$;
+CREATE TABLE IF NOT EXISTS equipment_run_trace_events (
+ equipment_run_id BIGINT NOT NULL
+ REFERENCES equipment_runs(id) ON DELETE CASCADE,
+ source_event_id BIGINT NOT NULL,
+ source_tool_id BIGINT,
+ event_time TIMESTAMP WITHOUT TIME ZONE NOT NULL,
+ event_time_raw TEXT NOT NULL,
+ event_type TEXT NOT NULL DEFAULT '',
+ category TEXT NOT NULL DEFAULT '',
+ description TEXT NOT NULL DEFAULT '',
+ raw_event_json JSONB NOT NULL DEFAULT '{}'::jsonb,
+ PRIMARY KEY (equipment_run_id, source_event_id),
+ CHECK (jsonb_typeof(raw_event_json) = 'object')
+);
+
+CREATE INDEX IF NOT EXISTS idx_equipment_run_trace_events_time
+ ON equipment_run_trace_events(
+ equipment_run_id, event_time, source_event_id
+ );
+
+ALTER TABLE equipment_run_trace_events ENABLE ROW LEVEL SECURITY;
+GRANT SELECT ON equipment_run_trace_events TO api_client;
+
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1
+ FROM pg_policies
+ WHERE schemaname = 'public'
+ AND tablename = 'equipment_run_trace_events'
+ AND policyname = 'equipment_run_trace_event_visibility_policy'
+ ) THEN
+ CREATE POLICY equipment_run_trace_event_visibility_policy
+ ON equipment_run_trace_events
+ FOR SELECT
+ USING (
+ EXISTS (
+ SELECT 1
+ FROM equipment_runs r
+ JOIN projects p ON p.id = r.project_id
+ WHERE r.id = equipment_run_trace_events.equipment_run_id
+ AND (
+ p.access_mode = 'open'
+ OR EXISTS (
+ SELECT 1
+ FROM project_members pm
+ WHERE pm.project_id = p.id
+ AND pm.nanohub_user_id =
+ current_setting('app.current_user', true)
+ )
+ )
+ )
+ );
+ END IF;
+END
+$$;
+
+CREATE TABLE IF NOT EXISTS glance_ingestion_audit (
+ id BIGSERIAL PRIMARY KEY,
+ batch_id VARCHAR(255) NOT NULL,
+ source_system VARCHAR(100) NOT NULL DEFAULT 'glance',
+ equipment_id VARCHAR(255) NOT NULL,
+ source_tool_id BIGINT NOT NULL,
+ project_id VARCHAR(50),
+ previous_cursor TEXT,
+ proposed_cursor TEXT,
+ poll_started_at TIMESTAMP WITH TIME ZONE,
+ poll_completed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+ status VARCHAR(32) NOT NULL,
+ run_count INT NOT NULL DEFAULT 0,
+ sample_count INT NOT NULL DEFAULT 0,
+ event_count INT NOT NULL DEFAULT 0,
+ warning_count INT NOT NULL DEFAULT 0,
+ payload_sha256 VARCHAR(64),
+ error_text TEXT,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX IF NOT EXISTS idx_glance_ingestion_audit_tool_time
+ ON glance_ingestion_audit(equipment_id, source_tool_id, created_at DESC);
+
-- Full time-series traces are stored separately so one equipment_runs row still
-- represents one physical machine run. GLANCE timestamps are currently naive;
-- keep the parsed wall time timezone-free and preserve the exact source text.
@@ -76,8 +176,8 @@ CREATE TABLE IF NOT EXISTS equipment_run_trace_samples (
sample_index INT NOT NULL,
source_sample_id BIGINT NOT NULL,
source_tool_id BIGINT,
- sample_time TIMESTAMP WITHOUT TIME ZONE NOT NULL,
- sample_time_raw TEXT NOT NULL,
+ sample_time TIMESTAMP WITHOUT TIME ZONE,
+ sample_time_raw TEXT,
values_json JSONB NOT NULL DEFAULT '{}'::jsonb,
PRIMARY KEY (equipment_run_id, sample_index),
UNIQUE (equipment_run_id, source_sample_id),
@@ -88,7 +188,12 @@ ALTER TABLE equipment_run_trace_samples
ADD COLUMN IF NOT EXISTS source_tool_id BIGINT;
CREATE INDEX IF NOT EXISTS idx_equipment_run_trace_samples_time
- ON equipment_run_trace_samples(equipment_run_id, sample_time, sample_index);
+ON equipment_run_trace_samples(equipment_run_id, sample_time, sample_index);
+
+ALTER TABLE equipment_run_trace_samples
+ ALTER COLUMN sample_time DROP NOT NULL;
+ALTER TABLE equipment_run_trace_samples
+ ALTER COLUMN sample_time_raw DROP NOT NULL;
ALTER TABLE equipment_run_trace_samples ENABLE ROW LEVEL SECURITY;
diff --git a/api/scripts/add_production_glance_ingestion.sql b/api/scripts/add_production_glance_ingestion.sql
new file mode 100644
index 0000000..6b7b9d0
--- /dev/null
+++ b/api/scripts/add_production_glance_ingestion.sql
@@ -0,0 +1,115 @@
+-- Production GLANCE source identity, event provenance, and connector audit.
+-- Idempotent and safe to run before deploying the matching API.
+
+ALTER TABLE equipment_runs
+ ADD COLUMN IF NOT EXISTS source_system VARCHAR(100);
+ALTER TABLE equipment_runs
+ ADD COLUMN IF NOT EXISTS source_tool_id BIGINT;
+ALTER TABLE equipment_runs
+ ADD COLUMN IF NOT EXISTS source_run_id BIGINT;
+ALTER TABLE equipment_runs
+ ADD COLUMN IF NOT EXISTS source_updated_at TIMESTAMP WITH TIME ZONE;
+ALTER TABLE equipment_runs
+ ADD COLUMN IF NOT EXISTS ingested_at TIMESTAMP WITH TIME ZONE
+ DEFAULT CURRENT_TIMESTAMP;
+
+UPDATE equipment_runs
+SET source_system = COALESCE(NULLIF(source, ''), 'data_upload')
+WHERE source_system IS NULL OR source_system = '';
+
+ALTER TABLE equipment_runs
+ ALTER COLUMN source_system SET DEFAULT 'data_upload';
+ALTER TABLE equipment_runs
+ ALTER COLUMN source_system SET NOT NULL;
+
+CREATE UNIQUE INDEX IF NOT EXISTS uq_equipment_runs_source_identity
+ ON equipment_runs(
+ source_system, equipment_id, source_tool_id, source_run_id
+ )
+ WHERE source_tool_id IS NOT NULL AND source_run_id IS NOT NULL;
+
+ALTER TABLE IF EXISTS equipment_run_trace_samples
+ ALTER COLUMN sample_time DROP NOT NULL;
+ALTER TABLE IF EXISTS equipment_run_trace_samples
+ ALTER COLUMN sample_time_raw DROP NOT NULL;
+
+CREATE TABLE IF NOT EXISTS equipment_run_trace_events (
+ equipment_run_id BIGINT NOT NULL
+ REFERENCES equipment_runs(id) ON DELETE CASCADE,
+ source_event_id BIGINT NOT NULL,
+ source_tool_id BIGINT,
+ event_time TIMESTAMP WITHOUT TIME ZONE NOT NULL,
+ event_time_raw TEXT NOT NULL,
+ event_type TEXT NOT NULL DEFAULT '',
+ category TEXT NOT NULL DEFAULT '',
+ description TEXT NOT NULL DEFAULT '',
+ raw_event_json JSONB NOT NULL DEFAULT '{}'::jsonb,
+ PRIMARY KEY (equipment_run_id, source_event_id),
+ CHECK (jsonb_typeof(raw_event_json) = 'object')
+);
+
+CREATE INDEX IF NOT EXISTS idx_equipment_run_trace_events_time
+ ON equipment_run_trace_events(
+ equipment_run_id, event_time, source_event_id
+ );
+
+ALTER TABLE equipment_run_trace_events ENABLE ROW LEVEL SECURITY;
+GRANT SELECT ON equipment_run_trace_events TO api_client;
+
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1
+ FROM pg_policies
+ WHERE schemaname = 'public'
+ AND tablename = 'equipment_run_trace_events'
+ AND policyname = 'equipment_run_trace_event_visibility_policy'
+ ) THEN
+ CREATE POLICY equipment_run_trace_event_visibility_policy
+ ON equipment_run_trace_events
+ FOR SELECT
+ USING (
+ EXISTS (
+ SELECT 1
+ FROM equipment_runs r
+ JOIN projects p ON p.id = r.project_id
+ WHERE r.id = equipment_run_trace_events.equipment_run_id
+ AND (
+ p.access_mode = 'open'
+ OR EXISTS (
+ SELECT 1
+ FROM project_members pm
+ WHERE pm.project_id = p.id
+ AND pm.nanohub_user_id =
+ current_setting('app.current_user', true)
+ )
+ )
+ )
+ );
+ END IF;
+END
+$$;
+
+CREATE TABLE IF NOT EXISTS glance_ingestion_audit (
+ id BIGSERIAL PRIMARY KEY,
+ batch_id VARCHAR(255) NOT NULL,
+ source_system VARCHAR(100) NOT NULL DEFAULT 'glance',
+ equipment_id VARCHAR(255) NOT NULL,
+ source_tool_id BIGINT NOT NULL,
+ project_id VARCHAR(50),
+ previous_cursor TEXT,
+ proposed_cursor TEXT,
+ poll_started_at TIMESTAMP WITH TIME ZONE,
+ poll_completed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+ status VARCHAR(32) NOT NULL,
+ run_count INT NOT NULL DEFAULT 0,
+ sample_count INT NOT NULL DEFAULT 0,
+ event_count INT NOT NULL DEFAULT 0,
+ warning_count INT NOT NULL DEFAULT 0,
+ payload_sha256 VARCHAR(64),
+ error_text TEXT,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX IF NOT EXISTS idx_glance_ingestion_audit_tool_time
+ ON glance_ingestion_audit(equipment_id, source_tool_id, created_at DESC);
diff --git a/api/scripts/run_ml_shadow_evaluation.py b/api/scripts/run_ml_shadow_evaluation.py
new file mode 100644
index 0000000..3732772
--- /dev/null
+++ b/api/scripts/run_ml_shadow_evaluation.py
@@ -0,0 +1,55 @@
+#!/usr/bin/env python3
+"""Generate a versioned shadow report; never modifies the active ML engine."""
+
+from __future__ import annotations
+
+import argparse
+from pathlib import Path
+import sys
+
+API_ROOT = Path(__file__).resolve().parents[1]
+if str(API_ROOT) not in sys.path:
+ sys.path.insert(0, str(API_ROOT))
+
+from data_loader import ( # noqa: E402
+ FEATURES,
+ PRIMARY_TARGET,
+ SECONDARY_TARGET,
+)
+from data_loader_pg import get_training_df_pg # noqa: E402
+from shadow_evaluation import evaluate_shadow, report_json # noqa: E402
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--project-id", default="")
+ parser.add_argument("--candidate-count", type=int, default=1024)
+ parser.add_argument("--proposal-count", type=int, default=3)
+ parser.add_argument("--seed", type=int, default=42)
+ parser.add_argument("--output", type=Path)
+ args = parser.parse_args()
+
+ frame = get_training_df_pg(
+ project_id=args.project_id or None,
+ include_global_history=False,
+ )
+ report = evaluate_shadow(
+ frame,
+ features=list(FEATURES),
+ primary_target=PRIMARY_TARGET,
+ secondary_target=SECONDARY_TARGET,
+ project_id=args.project_id or None,
+ candidate_count=args.candidate_count,
+ proposal_count=args.proposal_count,
+ seed=args.seed,
+ )
+ rendered = report_json(report)
+ if args.output:
+ args.output.write_text(rendered + "\n", encoding="utf-8")
+ else:
+ print(rendered)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/api/shadow_evaluation.py b/api/shadow_evaluation.py
new file mode 100644
index 0000000..6136095
--- /dev/null
+++ b/api/shadow_evaluation.py
@@ -0,0 +1,527 @@
+"""Offline RF-versus-GPR/Pareto evidence generation with no promotion side effect."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import math
+import os
+import time
+from datetime import datetime, timezone
+from typing import Any
+
+import numpy as np
+import pandas as pd
+import scipy
+import sklearn
+from scipy.stats import norm, qmc
+from sklearn.ensemble import RandomForestRegressor
+from sklearn.gaussian_process import GaussianProcessRegressor
+from sklearn.gaussian_process.kernels import ConstantKernel, Matern, WhiteKernel
+from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score
+from sklearn.preprocessing import StandardScaler
+
+
+REPORT_SCHEMA_VERSION = "1"
+
+
+def _frame_hash(frame: pd.DataFrame, columns: list[str]) -> str:
+ payload = frame[columns].to_json(
+ orient="records", date_format="iso", double_precision=15
+ )
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
+
+
+def _metrics(
+ actual: np.ndarray,
+ predicted: np.ndarray,
+ uncertainty: np.ndarray,
+) -> dict[str, float]:
+ coverage = float(
+ np.mean(
+ (actual >= predicted - 1.96 * uncertainty)
+ & (actual <= predicted + 1.96 * uncertainty)
+ )
+ )
+ return {
+ "rmse": float(math.sqrt(mean_squared_error(actual, predicted))),
+ "mae": float(mean_absolute_error(actual, predicted)),
+ "r2": float(r2_score(actual, predicted)),
+ "interval_95_coverage": coverage,
+ "interval_95_calibration_error": abs(coverage - 0.95),
+ }
+
+
+def _rf_predict_with_std(
+ model: RandomForestRegressor,
+ values: np.ndarray,
+) -> tuple[np.ndarray, np.ndarray]:
+ predictions = np.vstack(
+ [tree.predict(values) for tree in model.estimators_]
+ )
+ return predictions.mean(axis=0), predictions.std(axis=0)
+
+
+def _pareto_indices(rate: np.ndarray, spread: np.ndarray) -> np.ndarray:
+ """Return non-dominated indices for max(rate), min(spread)."""
+ keep = np.ones(len(rate), dtype=bool)
+ for index in range(len(rate)):
+ dominated = (
+ (rate >= rate[index])
+ & (spread <= spread[index])
+ & ((rate > rate[index]) | (spread < spread[index]))
+ )
+ dominated[index] = False
+ if np.any(dominated):
+ keep[index] = False
+ return np.flatnonzero(keep)
+
+
+def _candidate_diversity(
+ candidates: np.ndarray,
+ lower: np.ndarray,
+ upper: np.ndarray,
+) -> float:
+ if len(candidates) < 2:
+ return 0.0
+ scale = np.where(upper > lower, upper - lower, 1.0)
+ normalized = (candidates - lower) / scale
+ distances = []
+ for left in range(len(normalized)):
+ for right in range(left + 1, len(normalized)):
+ distances.append(
+ float(np.linalg.norm(normalized[left] - normalized[right]))
+ )
+ return float(np.mean(distances)) if distances else 0.0
+
+
+def _select_candidates(
+ *,
+ rate_mean: np.ndarray,
+ range_mean: np.ndarray,
+ rate_std: np.ndarray,
+ range_std: np.ndarray,
+ candidate_values: np.ndarray,
+ count: int,
+) -> tuple[np.ndarray, np.ndarray]:
+ pareto = _pareto_indices(rate_mean, range_mean)
+ if not len(pareto):
+ return np.array([], dtype=int), pareto
+ rate_scale = max(float(np.ptp(rate_mean[pareto])), 1e-12)
+ range_scale = max(float(np.ptp(range_mean[pareto])), 1e-12)
+ uncertainty_scale = max(
+ float(np.ptp(rate_std[pareto] + range_std[pareto])), 1e-12
+ )
+ score = (
+ (rate_mean[pareto] - np.min(rate_mean[pareto])) / rate_scale
+ + (np.max(range_mean[pareto]) - range_mean[pareto]) / range_scale
+ + 0.1
+ * (
+ rate_std[pareto]
+ + range_std[pareto]
+ - np.min(rate_std[pareto] + range_std[pareto])
+ )
+ / uncertainty_scale
+ )
+ ordered = pareto[np.argsort(score)[::-1]]
+ # Greedy normalized-space separation makes the diversity metric meaningful.
+ selected = []
+ if len(ordered):
+ selected.append(int(ordered[0]))
+ while len(selected) < min(count, len(ordered)):
+ remaining = [int(index) for index in ordered if int(index) not in selected]
+ next_index = max(
+ remaining,
+ key=lambda index: min(
+ np.linalg.norm(candidate_values[index] - candidate_values[chosen])
+ for chosen in selected
+ ),
+ )
+ selected.append(next_index)
+ return np.asarray(selected, dtype=int), pareto
+
+
+def _select_scored_candidates(
+ score: np.ndarray,
+ candidate_values: np.ndarray,
+ count: int,
+) -> np.ndarray:
+ ordered = np.argsort(score)[::-1]
+ ordered = ordered[np.isfinite(score[ordered])]
+ if not len(ordered):
+ return np.asarray([], dtype=int)
+ selected = [int(ordered[0])]
+ while len(selected) < min(count, len(ordered)):
+ remaining = [int(index) for index in ordered if int(index) not in selected]
+ next_index = max(
+ remaining,
+ key=lambda index: (
+ float(score[index])
+ + 0.05
+ * min(
+ np.linalg.norm(
+ candidate_values[index] - candidate_values[chosen]
+ )
+ for chosen in selected
+ )
+ ),
+ )
+ selected.append(next_index)
+ return np.asarray(selected, dtype=int)
+
+
+def _analytic_expected_improvement(
+ mean: np.ndarray,
+ std: np.ndarray,
+ best_observed: float,
+) -> np.ndarray:
+ safe_std = np.maximum(std, 1e-12)
+ improvement = mean - best_observed
+ z_score = improvement / safe_std
+ result = improvement * norm.cdf(z_score) + safe_std * norm.pdf(z_score)
+ return np.where(std > 0, result, np.maximum(improvement, 0.0))
+
+
+def _monte_carlo_pareto_improvement(
+ *,
+ rate_mean: np.ndarray,
+ range_mean: np.ndarray,
+ rate_std: np.ndarray,
+ range_std: np.ndarray,
+ observed_rate: np.ndarray,
+ observed_range: np.ndarray,
+ samples: int,
+ seed: int,
+) -> np.ndarray:
+ """Probability a sampled outcome extends max-rate/min-range Pareto history."""
+ observed_front = _pareto_indices(observed_rate, observed_range)
+ front_rate = observed_rate[observed_front]
+ front_range = observed_range[observed_front]
+ rng = np.random.default_rng(seed)
+ probabilities = np.zeros(len(rate_mean), dtype=float)
+ chunk_size = 128
+ for start in range(0, len(rate_mean), chunk_size):
+ stop = min(start + chunk_size, len(rate_mean))
+ sampled_rate = rng.normal(
+ rate_mean[start:stop],
+ np.maximum(rate_std[start:stop], 1e-12),
+ size=(samples, stop - start),
+ )
+ sampled_range = rng.normal(
+ range_mean[start:stop],
+ np.maximum(range_std[start:stop], 1e-12),
+ size=(samples, stop - start),
+ )
+ # Shapes: MC x candidate x historical-front point.
+ rate_cube = sampled_rate[:, :, None]
+ range_cube = sampled_range[:, :, None]
+ dominated = np.any(
+ (front_rate[None, None, :] >= rate_cube)
+ & (front_range[None, None, :] <= range_cube)
+ & (
+ (front_rate[None, None, :] > rate_cube)
+ | (front_range[None, None, :] < range_cube)
+ ),
+ axis=2,
+ )
+ dominates = np.any(
+ (rate_cube >= front_rate[None, None, :])
+ & (range_cube <= front_range[None, None, :])
+ & (
+ (rate_cube > front_rate[None, None, :])
+ | (range_cube < front_range[None, None, :])
+ ),
+ axis=2,
+ )
+ probabilities[start:stop] = np.mean((~dominated) & dominates, axis=0)
+ return probabilities
+
+
+def evaluate_shadow(
+ frame: pd.DataFrame,
+ *,
+ features: list[str],
+ primary_target: str,
+ secondary_target: str,
+ project_id: str | None = None,
+ candidate_count: int = 1024,
+ proposal_count: int = 3,
+ seed: int = 42,
+) -> dict[str, Any]:
+ """Compare held-out predictions and candidate behavior without mutating state."""
+ started_at = time.perf_counter()
+ required = [*features, primary_target, secondary_target]
+ missing = [column for column in required if column not in frame.columns]
+ if missing:
+ raise ValueError("Shadow evaluation is missing columns: " + ", ".join(missing))
+ working = frame.copy()
+ for column in required:
+ working.loc[:, column] = pd.to_numeric(
+ working[column], errors="coerce"
+ )
+ working = working.dropna(subset=required)
+ for quality_column in ("is_outlier", "is_calibration_recipe"):
+ if quality_column in working.columns:
+ working = working[working[quality_column] != True] # noqa: E712
+ sort_column = "run_date" if "run_date" in working.columns else None
+ if sort_column:
+ working = working.sort_values(sort_column, kind="mergesort")
+ else:
+ working = working.sort_index(kind="mergesort")
+ working = working.reset_index(drop=True)
+ if len(working) < 20:
+ raise ValueError("Shadow evaluation requires at least 20 complete clean runs")
+
+ max_rows = max(20, int(os.getenv("ML_SHADOW_MAX_ROWS", "250")))
+ if len(working) > max_rows:
+ # Preserve temporal coverage instead of silently taking only recent data.
+ indices = np.linspace(0, len(working) - 1, max_rows).round().astype(int)
+ working = working.iloc[indices].reset_index(drop=True)
+
+ holdout_count = max(4, int(math.ceil(len(working) * 0.2)))
+ train = working.iloc[:-holdout_count]
+ test = working.iloc[-holdout_count:]
+ x_train = train[features].to_numpy(dtype=float)
+ x_test = test[features].to_numpy(dtype=float)
+
+ scaler = StandardScaler().fit(x_train)
+ x_train_scaled = scaler.transform(x_train)
+ x_test_scaled = scaler.transform(x_test)
+ target_reports: dict[str, Any] = {}
+ fitted: dict[str, dict[str, Any]] = {}
+ for target in (primary_target, secondary_target):
+ y_train = train[target].to_numpy(dtype=float)
+ y_test = test[target].to_numpy(dtype=float)
+ rf = RandomForestRegressor(
+ n_estimators=int(os.getenv("ML_SHADOW_RF_TREES", "300")),
+ random_state=seed,
+ n_jobs=-1,
+ ).fit(x_train, y_train)
+ rf_mean, rf_std = _rf_predict_with_std(rf, x_test)
+ kernel = (
+ ConstantKernel(1.0, (1e-3, 1e3))
+ * Matern(
+ length_scale=np.ones(len(features)),
+ length_scale_bounds=(1e-2, 1e2),
+ nu=1.5,
+ )
+ + WhiteKernel(1.0, (1e-4, 1e2))
+ )
+ gpr = GaussianProcessRegressor(
+ kernel=kernel,
+ alpha=1e-6,
+ normalize_y=True,
+ n_restarts_optimizer=0,
+ random_state=seed,
+ ).fit(x_train_scaled, y_train)
+ gpr_mean, gpr_std = gpr.predict(x_test_scaled, return_std=True)
+ target_reports[target] = {
+ "random_forest": _metrics(y_test, rf_mean, rf_std),
+ "gpr": _metrics(y_test, gpr_mean, gpr_std),
+ }
+ fitted[target] = {"rf": rf, "gpr": gpr}
+
+ lower = train[features].min().to_numpy(dtype=float)
+ upper = train[features].max().to_numpy(dtype=float)
+ sobol_power = int(math.ceil(math.log2(max(2, candidate_count))))
+ unit_candidates = qmc.Sobol(
+ d=len(features), scramble=True, seed=seed
+ ).random_base2(sobol_power)[:candidate_count]
+ candidates = qmc.scale(unit_candidates, lower, upper)
+ candidates_scaled = scaler.transform(candidates)
+
+ rf_rate, rf_rate_std = _rf_predict_with_std(
+ fitted[primary_target]["rf"], candidates
+ )
+ rf_range, rf_range_std = _rf_predict_with_std(
+ fitted[secondary_target]["rf"], candidates
+ )
+ gpr_rate, gpr_rate_std = fitted[primary_target]["gpr"].predict(
+ candidates_scaled, return_std=True
+ )
+ gpr_range, gpr_range_std = fitted[secondary_target]["gpr"].predict(
+ candidates_scaled, return_std=True
+ )
+ rf_selected, rf_pareto = _select_candidates(
+ rate_mean=rf_rate,
+ range_mean=rf_range,
+ rate_std=rf_rate_std,
+ range_std=rf_range_std,
+ candidate_values=(candidates - lower) / np.where(upper > lower, upper - lower, 1),
+ count=proposal_count,
+ )
+ _, gpr_pareto = _select_candidates(
+ rate_mean=gpr_rate,
+ range_mean=gpr_range,
+ rate_std=gpr_rate_std,
+ range_std=gpr_range_std,
+ candidate_values=(candidates - lower) / np.where(upper > lower, upper - lower, 1),
+ count=proposal_count,
+ )
+ normalized_candidates = (candidates - lower) / np.where(
+ upper > lower, upper - lower, 1
+ )
+ analytic_ei = _analytic_expected_improvement(
+ gpr_rate,
+ gpr_rate_std,
+ float(train[primary_target].max()),
+ )
+ gpr_ei_selected = _select_scored_candidates(
+ analytic_ei,
+ normalized_candidates,
+ proposal_count,
+ )
+ mc_samples = max(16, int(os.getenv("ML_SHADOW_MC_SAMPLES", "128")))
+ mc_scores_by_seed = []
+ mc_selections = []
+ for mc_seed in (seed, seed + 1, seed + 2):
+ scores = _monte_carlo_pareto_improvement(
+ rate_mean=gpr_rate,
+ range_mean=gpr_range,
+ rate_std=gpr_rate_std,
+ range_std=gpr_range_std,
+ observed_rate=train[primary_target].to_numpy(dtype=float),
+ observed_range=train[secondary_target].to_numpy(dtype=float),
+ samples=mc_samples,
+ seed=mc_seed,
+ )
+ # Candidate means must also be on the predicted Pareto front.
+ pareto_scores = np.full(len(scores), -np.inf)
+ pareto_scores[gpr_pareto] = scores[gpr_pareto]
+ selection = _select_scored_candidates(
+ pareto_scores,
+ normalized_candidates,
+ proposal_count,
+ )
+ mc_scores_by_seed.append(scores)
+ mc_selections.append(selection)
+ mc_selected = mc_selections[0]
+ baseline_set = set(mc_selected.tolist())
+ stability = []
+ for selection in mc_selections[1:]:
+ comparison_set = set(selection.tolist())
+ union = baseline_set | comparison_set
+ stability.append(
+ len(baseline_set & comparison_set) / len(union) if union else 1.0
+ )
+ rf_diversity = _candidate_diversity(
+ candidates[rf_selected], lower, upper
+ )
+ gpr_diversity = _candidate_diversity(
+ candidates[mc_selected], lower, upper
+ )
+
+ primary_rf = target_reports[primary_target]["random_forest"]
+ primary_gpr = target_reports[primary_target]["gpr"]
+ secondary_rf = target_reports[secondary_target]["random_forest"]
+ secondary_gpr = target_reports[secondary_target]["gpr"]
+ criteria = {
+ "primary_rmse_within_5_percent_of_rf": (
+ primary_gpr["rmse"] <= primary_rf["rmse"] * 1.05
+ ),
+ "secondary_rmse_within_5_percent_of_rf": (
+ secondary_gpr["rmse"] <= secondary_rf["rmse"] * 1.05
+ ),
+ "primary_interval_coverage_at_least_80_percent": (
+ primary_gpr["interval_95_coverage"] >= 0.8
+ ),
+ "secondary_interval_coverage_at_least_80_percent": (
+ secondary_gpr["interval_95_coverage"] >= 0.8
+ ),
+ "candidate_diversity_at_least_80_percent_of_rf": (
+ gpr_diversity >= rf_diversity * 0.8
+ ),
+ }
+ hash_columns = [
+ column
+ for column in ("idruns", "run_date", *required)
+ if column in working.columns
+ ]
+ report = {
+ "schema_version": REPORT_SCHEMA_VERSION,
+ "generated_at": datetime.now(timezone.utc).isoformat(),
+ "project_id": project_id,
+ "dataset": {
+ "hash": _frame_hash(working, hash_columns),
+ "row_count": len(working),
+ "training_row_count": len(train),
+ "holdout_row_count": len(test),
+ "split": "chronological_80_20",
+ "features": features,
+ "targets": [primary_target, secondary_target],
+ },
+ "libraries": {
+ "scikit_learn": sklearn.__version__,
+ "scipy": scipy.__version__,
+ },
+ "predictive_metrics": target_reports,
+ "candidate_metrics": {
+ "candidate_count": candidate_count,
+ "proposal_count": proposal_count,
+ "random_forest": {
+ "pareto_count": int(len(rf_pareto)),
+ "diversity": rf_diversity,
+ "constraint_violations": 0,
+ },
+ "gpr_analytic_ei": {
+ "selected_count": int(len(gpr_ei_selected)),
+ "diversity": _candidate_diversity(
+ candidates[gpr_ei_selected], lower, upper
+ ),
+ "mean_selected_ei": float(
+ np.mean(analytic_ei[gpr_ei_selected])
+ ),
+ "constraint_violations": 0,
+ },
+ "gpr_pareto_monte_carlo": {
+ "pareto_count": int(len(gpr_pareto)),
+ "diversity": gpr_diversity,
+ "monte_carlo_samples": mc_samples,
+ "mean_selected_probability_of_pareto_improvement": float(
+ np.mean(mc_scores_by_seed[0][mc_selected])
+ ),
+ "selection_jaccard_across_varied_seeds": float(
+ np.mean(stability)
+ ),
+ "fixed_seed_reproducible": True,
+ "constraint_violations": 0,
+ "mean_primary_uncertainty": float(
+ np.mean(gpr_rate_std[mc_selected])
+ ),
+ "mean_secondary_uncertainty": float(
+ np.mean(gpr_range_std[mc_selected])
+ ),
+ },
+ },
+ "operational_metrics": {
+ "runtime_seconds": time.perf_counter() - started_at,
+ "failure_count": 0,
+ "realized_improvement": None,
+ "expert_review": "pending",
+ },
+ "promotion_criteria": criteria,
+ "promotion_recommended": all(criteria.values()),
+ "production_changed": False,
+ "note": (
+ "This report is evidence only. Promotion requires repeated project-"
+ "scoped results and explicit configuration review."
+ ),
+ }
+ reproducible_sections = {
+ key: value
+ for key, value in report.items()
+ if key not in {"generated_at", "operational_metrics"}
+ }
+ report["deterministic_fingerprint"] = hashlib.sha256(
+ json.dumps(
+ reproducible_sections,
+ sort_keys=True,
+ separators=(",", ":"),
+ default=str,
+ ).encode("utf-8")
+ ).hexdigest()
+ return report
+
+
+def report_json(report: dict[str, Any]) -> str:
+ return json.dumps(report, indent=2, sort_keys=True, default=str)
diff --git a/api/tests/test_production_glance_ingestion.py b/api/tests/test_production_glance_ingestion.py
new file mode 100644
index 0000000..64cf44b
--- /dev/null
+++ b/api/tests/test_production_glance_ingestion.py
@@ -0,0 +1,419 @@
+from __future__ import annotations
+
+import os
+import sys
+import unittest
+from datetime import datetime, timezone
+from pathlib import Path
+from unittest.mock import patch
+
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+
+API_ROOT = Path(__file__).resolve().parents[1]
+REPO_ROOT = API_ROOT.parent
+AZURE_ROOT = REPO_ROOT / "azure"
+for path in (str(API_ROOT), str(AZURE_ROOT)):
+ if path not in sys.path:
+ sys.path.insert(0, path)
+
+from data_loader_pg import sync_equipment_run_traces_pg
+from glance_ingestion import (
+ equipment_parameter_registry,
+ prepare_live_run,
+)
+from common.glance_connector import (
+ ConnectorSettings,
+ GlancePoller,
+ ToolMapping,
+)
+from routers.dataset_v2 import router as dataset_v2_router
+
+
+class ProductionGlanceValidationTests(unittest.TestCase):
+ def setUp(self) -> None:
+ self.equipment = {
+ "parameters": [
+ {
+ "id": "pressure",
+ "name": "P Chamber Pressure",
+ "unit": "mTorr",
+ "equipment_parameter_id": "130",
+ }
+ ],
+ "outputs": [],
+ }
+
+ def test_preparation_preserves_zero_null_and_derives_terminal_outcome(self):
+ prepared, warnings = prepare_live_run(
+ {
+ "source_run_id": 101,
+ "lotname": "LOT-101",
+ "run_start_time": "2026-07-01 10:00:00.000001",
+ "run_end_time": "2026-07-01 10:00:02.000001",
+ "parameters": [
+ {
+ "source_parameter_id": 130,
+ "name": "P Chamber Pressure",
+ "unit": "mTorr",
+ },
+ {
+ "source_parameter_id": 999,
+ "name": "Unknown signal",
+ "unit": "V",
+ },
+ ],
+ "samples": [
+ {
+ "source_sample_id": 1,
+ "source_tool_id": 5,
+ "timestamp": "2026-07-01 10:00:00.000001",
+ "values": {"p130": 0, "p999": None},
+ },
+ {
+ "source_sample_id": 2,
+ "source_tool_id": 5,
+ "timestamp": "2026-07-01 10:00:01.000001",
+ "values": {},
+ },
+ ],
+ "events": [
+ {
+ "source_event_id": 7,
+ "source_tool_id": 5,
+ "timestamp": "2026-07-01 10:00:02.000001",
+ "event_type": "Process Complete",
+ }
+ ],
+ },
+ source_tool_id=5,
+ source_updated_at=None,
+ registry=equipment_parameter_registry(self.equipment),
+ )
+
+ self.assertEqual(prepared["samples"][0]["values"]["p130"], 0)
+ self.assertIsNone(prepared["samples"][0]["values"]["p999"])
+ self.assertEqual(prepared["samples"][1]["values"], {})
+ self.assertEqual(
+ prepared["raw"]["trace"]["process_outcome"], "complete"
+ )
+ self.assertTrue(prepared["parameters"][0]["registered"])
+ self.assertFalse(prepared["parameters"][1]["registered"])
+ self.assertTrue(any("unregistered source ID 999" in item for item in warnings))
+
+ def test_event_from_another_tool_is_rejected(self):
+ with self.assertRaisesRegex(ValueError, "belongs to tool 6"):
+ prepare_live_run(
+ {
+ "source_run_id": 1,
+ "run_start_time": "2026-07-01T10:00:00",
+ "run_end_time": "2026-07-01T10:01:00",
+ "samples": [
+ {
+ "source_sample_id": 1,
+ "timestamp": "2026-07-01T10:00:01",
+ "values": {},
+ }
+ ],
+ "events": [
+ {
+ "source_event_id": 2,
+ "source_tool_id": 6,
+ "timestamp": "2026-07-01T10:01:00",
+ }
+ ],
+ },
+ source_tool_id=5,
+ source_updated_at=None,
+ registry=[],
+ )
+
+ def test_missing_nullable_sample_timestamp_is_preserved(self):
+ prepared, warnings = prepare_live_run(
+ {
+ "source_run_id": 102,
+ "run_start_time": "2026-07-01T10:00:00",
+ "samples": [
+ {
+ "source_sample_id": 3,
+ "source_tool_id": 5,
+ "timestamp": None,
+ "values": {"p130": 0},
+ }
+ ],
+ "events": [],
+ },
+ source_tool_id=5,
+ source_updated_at=None,
+ registry=equipment_parameter_registry(self.equipment),
+ )
+
+ self.assertIsNone(prepared["samples"][0]["sample_time_raw"])
+ self.assertTrue(
+ any("has no source timestamp" in warning for warning in warnings)
+ )
+
+
+class ProductionGlancePersistenceTests(unittest.TestCase):
+ def test_live_upsert_uses_source_identity_and_returns_counts(self):
+ queries: list[str] = []
+ value_queries: list[str] = []
+
+ class Cursor:
+ def execute(self, query, params=None):
+ queries.append(str(query))
+
+ def fetchall(self):
+ return []
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, exc_type, exc_value, traceback):
+ return False
+
+ class Connection:
+ def __init__(self):
+ self.committed = False
+ self.rolled_back = False
+
+ def cursor(self, cursor_factory=None):
+ return Cursor()
+
+ def commit(self):
+ self.committed = True
+
+ def rollback(self):
+ self.rolled_back = True
+
+ def close(self):
+ pass
+
+ def execute_values(cursor, query, values, **kwargs):
+ value_queries.append(str(query))
+ if "INSERT INTO equipment_runs" in query:
+ return [(7001, 101)]
+ return None
+
+ connection = Connection()
+ with (
+ patch(
+ "data_loader_pg.get_pg_superuser_connection",
+ return_value=connection,
+ ),
+ patch("psycopg2.extras.execute_values", side_effect=execute_values),
+ ):
+ result = sync_equipment_run_traces_pg(
+ equipment_id="equipment-test",
+ project_id="project-test",
+ source_system="glance",
+ source_tool_id=5,
+ source_updated_at=datetime.now(timezone.utc),
+ return_stats=True,
+ runs=[
+ {
+ "source_run_id": 101,
+ "run_start_time": "2026-07-01T10:00:00",
+ "parameters": [
+ {
+ "source_parameter_id": 1,
+ "key": "p1",
+ "name": "Signal",
+ }
+ ],
+ "samples": [
+ {
+ "source_sample_id": 1001,
+ "source_tool_id": 5,
+ "timestamp": "2026-07-01T10:00:01",
+ "values": {"p1": 0},
+ }
+ ],
+ "events": [
+ {
+ "source_event_id": 9,
+ "source_tool_id": 5,
+ "timestamp": "2026-07-01T10:00:01",
+ "event_type": "Process Complete",
+ }
+ ],
+ }
+ ],
+ )
+
+ self.assertEqual(result["inserted"], 1)
+ self.assertEqual(result["sample_count"], 1)
+ self.assertEqual(result["event_count"], 1)
+ self.assertTrue(
+ any(
+ "source_system, equipment_id, source_tool_id, source_run_id"
+ in query
+ for query in value_queries
+ )
+ )
+ self.assertTrue(connection.committed)
+ self.assertFalse(connection.rolled_back)
+
+
+class ProductionGlanceEndpointTests(unittest.TestCase):
+ def setUp(self) -> None:
+ app = FastAPI()
+ app.include_router(dataset_v2_router)
+ self.client = TestClient(app)
+ self.payload = {
+ "version": "1",
+ "source_system": "glance",
+ "batch_id": "batch-1",
+ "equipment_id": "equipment-test",
+ "source_tool_id": 5,
+ "source_tool_name": "Tool 5",
+ "project_id": "project-test",
+ "previous_cursor": "100",
+ "proposed_cursor": "101",
+ "poll_started_at": "2026-07-01T10:00:00Z",
+ "runs": [
+ {
+ "source_run_id": 101,
+ "run_start_time": "2026-07-01T10:00:00",
+ "run_end_time": "2026-07-01T10:00:01",
+ "parameters": [],
+ "samples": [
+ {
+ "source_sample_id": 1001,
+ "timestamp": "2026-07-01T10:00:00",
+ "values": {},
+ }
+ ],
+ "events": [],
+ }
+ ],
+ }
+
+ def test_endpoint_requires_token_and_validates_authoritative_mapping(self):
+ mapping = (
+ '{"equipment-test":{"source_tool_id":5,'
+ '"source_tool_name":"Tool 5","project_id":"project-test"}}'
+ )
+ environment = {
+ "INGESTION_TOKEN": "test-ingestion-token",
+ "GLANCE_PRODUCTION_MAPPINGS_JSON": mapping,
+ }
+ with patch.dict(os.environ, environment, clear=False):
+ denied = self.client.post(
+ "/dataset/v2/glance/traces/sync", json=self.payload
+ )
+ self.assertEqual(denied.status_code, 403)
+
+ with (
+ patch.dict(os.environ, environment, clear=False),
+ patch(
+ "routers.dataset_v2.get_equipment_pg",
+ return_value={"parameters": [], "outputs": []},
+ ),
+ patch(
+ "routers.dataset_v2.get_glance_ingestion_project_pg",
+ return_value={
+ "id": "project-test",
+ "equipment_id": "equipment-test",
+ },
+ ),
+ patch(
+ "routers.dataset_v2.sync_equipment_run_traces_pg",
+ return_value={
+ "inserted": 1,
+ "updated": 0,
+ "run_count": 1,
+ "sample_count": 1,
+ "event_count": 0,
+ },
+ ),
+ patch("routers.dataset_v2.record_glance_ingestion_audit_pg"),
+ ):
+ response = self.client.post(
+ "/dataset/v2/glance/traces/sync",
+ json=self.payload,
+ headers={"X-Ingestion-Token": "test-ingestion-token"},
+ )
+ self.assertEqual(response.status_code, 200)
+ self.assertTrue(response.json()["cursor_acknowledged"])
+ self.assertEqual(response.json()["inserted"], 1)
+
+
+class ProductionGlanceCursorTests(unittest.TestCase):
+ def _settings(self) -> ConnectorSettings:
+ return ConnectorSettings(
+ postgrest_url="https://glance.invalid",
+ postgrest_token="read-token",
+ ingestion_url=(
+ "https://dt.invalid/api/dataset/v2/glance/traces/sync"
+ ),
+ ingestion_token="ingest-token",
+ storage_connection="unused",
+ cursor_container="state",
+ cursor_blob="cursor.json",
+ mappings=(
+ ToolMapping("equipment-test", 5, "Tool 5", "project-test", 100),
+ ),
+ overlap_run_count=5,
+ page_size=100,
+ timeout=(1, 1),
+ data_batch_size=10,
+ backfill_min_run_id=None,
+ backfill_max_run_id=None,
+ )
+
+ def test_cursor_advances_only_when_api_acknowledges_complete_batch(self):
+ class Client:
+ def affected_runs(self, mapping, cursor):
+ return [{"idruns": 101, "idtools": 5}]
+
+ def complete_run(self, run, mapping):
+ return {"source_run_id": 101}
+
+ class Store:
+ def __init__(self):
+ self.saved = []
+
+ def load(self):
+ return {"equipment-test:5": 100}
+
+ def save(self, cursors):
+ self.saved.append(dict(cursors))
+
+ class AuditStore:
+ def record(self, _entry):
+ pass
+
+ store = Store()
+ poller = GlancePoller(
+ self._settings(),
+ client=Client(),
+ cursor_store=store,
+ failure_audit_store=AuditStore(),
+ handoff_session=object(),
+ )
+ poller._handoff = lambda payload: {
+ "status": "partial",
+ "cursor_acknowledged": False,
+ "run_count": 1,
+ "rejected_count": 1,
+ }
+ cursors = store.load()
+ result = poller.poll_mapping(self._settings().mappings[0], cursors)
+ self.assertFalse(result["cursor_acknowledged"])
+ self.assertEqual(store.saved, [])
+ self.assertEqual(cursors["equipment-test:5"], 100)
+
+ poller._handoff = lambda payload: {
+ "status": "success",
+ "cursor_acknowledged": True,
+ "run_count": 1,
+ "rejected_count": 0,
+ }
+ result = poller.poll_mapping(self._settings().mappings[0], cursors)
+ self.assertTrue(result["cursor_acknowledged"])
+ self.assertEqual(store.saved[-1]["equipment-test:5"], 101)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/api/tests/test_shadow_evaluation.py b/api/tests/test_shadow_evaluation.py
new file mode 100644
index 0000000..d007fdc
--- /dev/null
+++ b/api/tests/test_shadow_evaluation.py
@@ -0,0 +1,75 @@
+from __future__ import annotations
+
+import sys
+import unittest
+import warnings
+from pathlib import Path
+from unittest.mock import patch
+
+import numpy as np
+import pandas as pd
+
+
+API_ROOT = Path(__file__).resolve().parents[1]
+if str(API_ROOT) not in sys.path:
+ sys.path.insert(0, str(API_ROOT))
+
+from shadow_evaluation import _pareto_indices, evaluate_shadow
+
+
+class ShadowEvaluationTests(unittest.TestCase):
+ def test_pareto_direction_is_max_rate_min_range(self):
+ rate = np.array([10.0, 11.0, 9.0, 12.0])
+ spread = np.array([4.0, 5.0, 6.0, 3.0])
+ self.assertEqual(_pareto_indices(rate, spread).tolist(), [3])
+
+ def test_report_compares_three_paths_and_is_seed_reproducible(self):
+ rng = np.random.default_rng(7)
+ features = ["flow", "power", "pressure"]
+ values = rng.uniform(0, 1, size=(28, len(features)))
+ frame = pd.DataFrame(values, columns=features)
+ frame["rate"] = (
+ 4 * values[:, 0] + values[:, 1] + rng.normal(0, 0.05, len(frame))
+ )
+ frame["range"] = (
+ values[:, 2] - 0.5 * values[:, 1]
+ + rng.normal(0, 0.05, len(frame))
+ )
+ frame["run_date"] = pd.date_range("2026-01-01", periods=len(frame))
+ environment = {
+ "ML_SHADOW_RF_TREES": "20",
+ "ML_SHADOW_MC_SAMPLES": "16",
+ }
+ with patch.dict("os.environ", environment, clear=False), warnings.catch_warnings():
+ warnings.simplefilter("ignore")
+ first = evaluate_shadow(
+ frame,
+ features=features,
+ primary_target="rate",
+ secondary_target="range",
+ candidate_count=16,
+ seed=42,
+ )
+ second = evaluate_shadow(
+ frame,
+ features=features,
+ primary_target="rate",
+ secondary_target="range",
+ candidate_count=16,
+ seed=42,
+ )
+
+ self.assertIn("random_forest", first["candidate_metrics"])
+ self.assertIn("gpr_analytic_ei", first["candidate_metrics"])
+ self.assertIn(
+ "gpr_pareto_monte_carlo", first["candidate_metrics"]
+ )
+ self.assertEqual(
+ first["deterministic_fingerprint"],
+ second["deterministic_fingerprint"],
+ )
+ self.assertFalse(first["production_changed"])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/azure/.funcignore b/azure/.funcignore
index 9966315..e2c7e81 100644
--- a/azure/.funcignore
+++ b/azure/.funcignore
@@ -5,4 +5,8 @@ __blobstorage__
__queuestorage__
local.settings.json
test
-.venv
\ No newline at end of file
+tests
+test_scripts
+.venv
+Dockerfile.glance
+requirements-glance.txt
diff --git a/azure/.gitignore b/azure/.gitignore
index a8f005a..1b65495 100644
--- a/azure/.gitignore
+++ b/azure/.gitignore
@@ -128,6 +128,10 @@ dmypy.json
# Test scripts and data (not needed for deployment)
tests/
+!tests/
+!tests/test_common_config.py
+!tests/test_glance_connector.py
+!tests/test_glance_mirror.py
# Pyre type checker
.pyre/
@@ -149,6 +153,9 @@ test_*.py
debug_*.py
*_test.py
*_debug.py
+!tests/test_common_config.py
+!tests/test_glance_connector.py
+!tests/test_glance_mirror.py
# Data files (confidential - keep locally, don't push)
*.csv
@@ -185,4 +192,4 @@ process_etcher_data/azure_generated_dataset.csv
process_etcher_data/OUTPUT_DATASET.csv
# Timer-triggered variant (not used)
-process_etcher_data_timer/
\ No newline at end of file
+process_etcher_data_timer/
diff --git a/azure/Dockerfile.glance b/azure/Dockerfile.glance
new file mode 100644
index 0000000..94aec89
--- /dev/null
+++ b/azure/Dockerfile.glance
@@ -0,0 +1,17 @@
+FROM python:3.11-slim
+
+ENV PYTHONDONTWRITEBYTECODE=1 \
+ PYTHONUNBUFFERED=1
+
+WORKDIR /app/azure
+
+COPY requirements-glance.txt .
+RUN pip install --no-cache-dir -r requirements-glance.txt \
+ && useradd --create-home --uid 10001 glance
+
+COPY common ./common
+COPY scripts ./scripts
+
+USER 10001
+
+CMD ["python", "scripts/run_glance_poll.py"]
diff --git a/azure/README.md b/azure/README.md
index 2a18fdb..b4570a6 100644
--- a/azure/README.md
+++ b/azure/README.md
@@ -6,7 +6,8 @@ The Azure Functions component of the Birck Digital Twin system processes semicon
## Architecture
-The Azure Function is an HTTP-triggered function that:
+The Azure component includes the legacy HTTP-triggered summary function and a
+separate timer-triggered production full-trace connector. The summary function:
1. Synchronizes FIMAP files from Azure Blob Storage
2. Fetches run data from PostgREST API
3. Computes step averages using recipe setpoints or threshold methods
@@ -23,6 +24,7 @@ azure/
├── process_etcher_data/ # Main Azure Function
│ ├── __init__.py # Function code (main entry point)
│ └── function.json # Function binding configuration
+├── process_glance_traces/ # Scheduled production full-trace connector
├── host.json # Azure Functions host configuration
├── requirements.txt # Python dependencies
├── local.settings.json.example # Environment variables template
@@ -95,6 +97,57 @@ All configuration is done through environment variables (Application Settings in
- `INGESTION_TOKEN`: Shared token used by Azure ingestion to authenticate to the DT API
- `INGESTION_EQUIPMENT_ID`: Equipment/domain ID written into DT ingestion logs (default: `etcher`)
+**Production GLANCE full traces:**
+- `GLANCE_INGESTION_API_URL`: DT endpoint ending in `/api/dataset/v2/glance/traces/sync`
+- `GLANCE_SOURCE_MODE`: `postgres` for the authoritative GLANCE pilot;
+ `postgrest` is retained for a future modern mirror
+- `GLANCE_DB_URI`: read-only PostgreSQL URI, supplied through a Secret and
+ required only in `postgres` mode
+- `GLANCE_PRODUCTION_MAPPINGS_JSON`: Explicit equipment/tool/project mappings;
+ every entry must also define a bounded `initial_run_id`
+- `GLANCE_POLL_SCHEDULE`: Azure NCRONTAB schedule (default deployment target:
+ `0 */10 * * * *`)
+- `GLANCE_CURSOR_BACKEND`: `file` for the Geddes CronJob or `blob` for Azure
+- `GLANCE_CURSOR_FILE`: durable mounted path for the file backend
+- `GLANCE_CURSOR_CONTAINER` and `GLANCE_CURSOR_BLOB`: durable per-tool cursor
+ state for the Azure blob backend
+- `GLANCE_FAILURE_AUDIT_FILE`: append-only, private JSONL failure record for
+ the file backend (defaults beside the cursor file)
+- `GLANCE_FAILURE_AUDIT_PREFIX`: immutable per-failure object prefix for the
+ Azure blob backend (default: `production-trace-failures`)
+- `GLANCE_RUN_OVERLAP_COUNT`: Previously committed run IDs refetched for late
+ samples/events (default: 5)
+- `GLANCE_PAGE_SIZE`: Maximum requested PostgREST page size (default: 1000)
+- `GLANCE_DATA_BATCH_SIZE`: Number of numerically ordered sample IDs per
+ run-scoped `/data` range (default: 25). The connector also filters each range
+ through the authoritative `samplerecord.idruns` relationship.
+- `GLANCE_BACKFILL_MIN_RUN_ID` and `GLANCE_BACKFILL_MAX_RUN_ID`: Optional bounded
+ backfill range; omit during normal polling
+- `GLANCE_BACKFILL_RUN_IDS`: optional exact comma-separated pilot allowlist;
+ mutually exclusive with the min/max bounds and removed for normal polling
+- `GLANCE_DB_RETRIES`: bounded retry count for transient PostgreSQL connection
+ failures
+- `GLANCE_MAX_RUNS_PER_POLL`: hard source-run cap (default: 20)
+- `GLANCE_MAX_SAMPLES_PER_RUN`: reject a source run above this sample count
+ before constructing its payload (default: 100000)
+- `GLANCE_MAX_VALUES_PER_RUN`: reject a source run above this value count
+ (default: 1000000)
+- `GLANCE_HANDOFF_RUN_BATCH_SIZE`: number of complete runs sent to FastAPI per
+ request (default: 1, preventing a large multi-run in-memory request)
+
+The connector reads the authoritative database through parameterized,
+repeatable-read, read-only SQL, posts complete affected runs to FastAPI, and
+advances a tool cursor only when the response sets
+`cursor_acknowledged=true`. Local source/handoff errors are credential-redacted
+and written durably beside the cursor state. The connector never receives DT
+PostgreSQL credentials.
+
+The inactive Geddes deployment template is
+`geddes/k8s/glance-ingestion-cronjob.yaml`. It contains the approved Tool 3
+pilot run allowlist and `suspend: true`; it must not be activated until its
+image digest, equipment/project mapping, API migration, counts, and rollback
+plan are reviewed.
+
**Microsoft Graph API (SharePoint):**
- `TENANT_ID`: Azure AD tenant ID
- `CLIENT_ID`: Azure AD application (client) ID
diff --git a/azure/common/__init__.py b/azure/common/__init__.py
index 1dc93c0..269300c 100644
--- a/azure/common/__init__.py
+++ b/azure/common/__init__.py
@@ -1,63 +1,9 @@
-# Azure Functions Common Module
-# Shared utilities for multi-domain Azure Functions
+"""Shared Azure helpers.
-from .blob_helpers import (
- load_last_time,
- save_last_time,
- load_processed_runs,
- save_processed_runs,
- save_local_copy_from_blob,
- merge_and_upload_dataset,
- compare_datasets
-)
+Callers import the required submodule directly (for example
+``common.config``). Keeping package import side-effect free lets the
+runtime-independent GLANCE coordinator be unit-tested without loading optional
+Blob, pandas, and notification dependencies.
+"""
-from .notifications import (
- send_teams_notification,
- create_base_notification_payload,
- convert_to_native_type,
- create_new_data_notification,
- create_outlier_notification,
- create_filtered_run_notification,
- DEFAULT_OUTLIER_TYPE_MAP
-)
-
-from .config import (
- create_http_session,
- get_timeout_config,
- get_env_or_default,
- get_env_bool,
- get_storage_container,
- api_get_all,
- chunked,
- get_processing_mode,
- TIMEOUT_DEFAULTS
-)
-
-__all__ = [
- # Blob helpers
- 'load_last_time',
- 'save_last_time',
- 'load_processed_runs',
- 'save_processed_runs',
- 'save_local_copy_from_blob',
- 'merge_and_upload_dataset',
- 'compare_datasets',
- # Notifications
- 'send_teams_notification',
- 'create_base_notification_payload',
- 'convert_to_native_type',
- 'create_new_data_notification',
- 'create_outlier_notification',
- 'create_filtered_run_notification',
- 'DEFAULT_OUTLIER_TYPE_MAP',
- # Config
- 'create_http_session',
- 'get_timeout_config',
- 'get_env_or_default',
- 'get_env_bool',
- 'get_storage_container',
- 'api_get_all',
- 'chunked',
- 'get_processing_mode',
- 'TIMEOUT_DEFAULTS',
-]
+__all__: list[str] = []
diff --git a/azure/common/config.py b/azure/common/config.py
index 0146f17..b3fccf0 100644
--- a/azure/common/config.py
+++ b/azure/common/config.py
@@ -154,28 +154,63 @@ def api_get_all(
"""
import logging
- rows, start = [], 0
+ rows = []
+ request_params = dict(params)
+ raw_limit = request_params.pop("limit", None)
+ raw_offset = request_params.pop("offset", 0)
+ requested_limit = int(raw_limit) if raw_limit is not None else None
+ offset = int(raw_offset)
+ if requested_limit is not None and requested_limit < 0:
+ raise ValueError("PostgREST limit cannot be negative")
+ if offset < 0:
+ raise ValueError("PostgREST offset cannot be negative")
+ remaining = requested_limit
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/json"
}
while True:
- headers["Range"] = f"items={start}-{start + page_size - 1}"
+ request_limit = (
+ min(page_size, remaining)
+ if remaining is not None
+ else page_size
+ )
+ if request_limit == 0:
+ break
+ page_params = dict(request_params)
+ page_params["limit"] = request_limit
+ page_params["offset"] = offset
try:
resp = session.get(
base_url.rstrip("/") + endpoint,
headers=headers,
- params=params,
+ params=page_params,
timeout=timeout
)
resp.raise_for_status()
batch = resp.json()
+ if not isinstance(batch, list):
+ raise ValueError(
+ f"PostgREST endpoint {endpoint} returned a non-list payload"
+ )
rows.extend(batch)
- if len(batch) < page_size:
+ received = len(batch)
+ if received == 0:
break
- start += page_size
+ offset += received
+ if remaining is not None:
+ remaining -= received
+ if remaining <= 0:
+ break
except Exception as e:
- logging.error(f"API call failed for {endpoint}: {e}")
+ # Request exception strings can contain bearer-adjacent URLs and
+ # very large source-ID filters. Keep operational logs useful
+ # without copying source identifiers or query strings.
+ logging.error(
+ "API call failed for %s (%s)",
+ endpoint,
+ type(e).__name__,
+ )
raise
return rows
diff --git a/azure/common/glance_connector.py b/azure/common/glance_connector.py
new file mode 100644
index 0000000..fd5b847
--- /dev/null
+++ b/azure/common/glance_connector.py
@@ -0,0 +1,1544 @@
+"""Runtime-independent scheduled connector for complete GLANCE run traces."""
+
+from __future__ import annotations
+
+import base64
+import hashlib
+import json
+import logging
+import math
+import os
+import re
+import tempfile
+import time
+import uuid
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from decimal import Decimal
+from pathlib import Path
+from typing import Any, Callable, Iterable, Protocol, TypeVar
+
+import requests
+from common.config import api_get_all, chunked, create_http_session
+
+
+logger = logging.getLogger("dt.azure.glance_connector")
+
+_T = TypeVar("_T")
+
+
+class ConnectorConfigurationError(RuntimeError):
+ pass
+
+
+class ConnectorHandoffError(RuntimeError):
+ pass
+
+
+def _required_env(name: str) -> str:
+ value = os.getenv(name, "").strip()
+ if not value:
+ raise ConnectorConfigurationError(
+ f"Missing required environment variable: {name}"
+ )
+ return value
+
+
+@dataclass(frozen=True)
+class ToolMapping:
+ equipment_id: str
+ source_tool_id: int
+ source_tool_name: str
+ project_id: str
+ initial_run_id: int
+
+
+@dataclass(frozen=True)
+class ConnectorSettings:
+ postgrest_url: str
+ postgrest_token: str
+ ingestion_url: str
+ ingestion_token: str
+ storage_connection: str
+ cursor_container: str
+ cursor_blob: str
+ mappings: tuple[ToolMapping, ...]
+ overlap_run_count: int
+ page_size: int
+ timeout: tuple[int, int]
+ data_batch_size: int
+ backfill_min_run_id: int | None
+ backfill_max_run_id: int | None
+ source_mode: str = "postgrest"
+ db_uri: str = ""
+ db_retries: int = 3
+ cursor_backend: str = "blob"
+ cursor_file: str = ""
+ backfill_run_ids: tuple[int, ...] = ()
+ max_runs_per_poll: int = 20
+ handoff_run_batch_size: int = 1
+ max_samples_per_run: int = 100000
+ max_values_per_run: int = 1000000
+ failure_audit_file: str = ""
+ failure_audit_prefix: str = "production-trace-failures"
+
+ @classmethod
+ def from_env(cls) -> "ConnectorSettings":
+ mapping_raw = _required_env("GLANCE_PRODUCTION_MAPPINGS_JSON")
+ try:
+ mapping_json = json.loads(mapping_raw)
+ except json.JSONDecodeError as exc:
+ raise ConnectorConfigurationError(
+ "GLANCE_PRODUCTION_MAPPINGS_JSON is not valid JSON"
+ ) from exc
+ if not isinstance(mapping_json, dict) or not mapping_json:
+ raise ConnectorConfigurationError(
+ "GLANCE_PRODUCTION_MAPPINGS_JSON must be a non-empty object"
+ )
+ default_initial = os.getenv("GLANCE_INITIAL_RUN_ID", "").strip()
+ mappings = []
+ for equipment_id, raw_mapping in mapping_json.items():
+ if not isinstance(raw_mapping, dict):
+ raise ConnectorConfigurationError(
+ f"Mapping for {equipment_id!r} must be an object"
+ )
+ initial_value = raw_mapping.get("initial_run_id", default_initial)
+ try:
+ tool_id = int(raw_mapping["source_tool_id"])
+ initial_run_id = int(initial_value)
+ except (KeyError, TypeError, ValueError) as exc:
+ raise ConnectorConfigurationError(
+ f"Mapping for {equipment_id!r} requires numeric "
+ "source_tool_id and initial_run_id"
+ ) from exc
+ project_id = str(raw_mapping.get("project_id") or "").strip()
+ if not project_id:
+ raise ConnectorConfigurationError(
+ f"Mapping for {equipment_id!r} requires project_id"
+ )
+ mappings.append(
+ ToolMapping(
+ equipment_id=str(equipment_id),
+ source_tool_id=tool_id,
+ source_tool_name=str(
+ raw_mapping.get("source_tool_name") or ""
+ ),
+ project_id=project_id,
+ initial_run_id=initial_run_id,
+ )
+ )
+
+ ingestion_url = _required_env("GLANCE_INGESTION_API_URL").rstrip("/")
+ expected_suffix = "/api/dataset/v2/glance/traces/sync"
+ if not ingestion_url.endswith(expected_suffix):
+ raise ConnectorConfigurationError(
+ f"GLANCE_INGESTION_API_URL must end with {expected_suffix!r}"
+ )
+
+ def optional_int(name: str) -> int | None:
+ value = os.getenv(name, "").strip()
+ return int(value) if value else None
+
+ source_mode = os.getenv("GLANCE_SOURCE_MODE", "postgrest").strip().lower()
+ if source_mode not in {"postgres", "postgrest"}:
+ raise ConnectorConfigurationError(
+ "GLANCE_SOURCE_MODE must be 'postgres' or 'postgrest'"
+ )
+ postgrest_url = os.getenv("POSTGREST_URL", "").strip().rstrip("/")
+ postgrest_token = os.getenv("POSTGREST_TOKEN", "").strip()
+ db_uri = os.getenv("GLANCE_DB_URI", "").strip()
+ if source_mode == "postgres" and not db_uri:
+ raise ConnectorConfigurationError(
+ "GLANCE_DB_URI is required when GLANCE_SOURCE_MODE=postgres"
+ )
+ if source_mode == "postgrest":
+ if not postgrest_url:
+ raise ConnectorConfigurationError(
+ "POSTGREST_URL is required when GLANCE_SOURCE_MODE=postgrest"
+ )
+ if not postgrest_token:
+ raise ConnectorConfigurationError(
+ "POSTGREST_TOKEN is required when GLANCE_SOURCE_MODE=postgrest"
+ )
+
+ cursor_backend = os.getenv(
+ "GLANCE_CURSOR_BACKEND", "blob"
+ ).strip().lower()
+ if cursor_backend not in {"blob", "file"}:
+ raise ConnectorConfigurationError(
+ "GLANCE_CURSOR_BACKEND must be 'blob' or 'file'"
+ )
+ storage_connection = os.getenv("AzureWebJobsStorage", "").strip()
+ cursor_file = os.getenv("GLANCE_CURSOR_FILE", "").strip()
+ if cursor_backend == "blob" and not storage_connection:
+ raise ConnectorConfigurationError(
+ "AzureWebJobsStorage is required for the blob cursor backend"
+ )
+ if cursor_backend == "file" and not cursor_file:
+ raise ConnectorConfigurationError(
+ "GLANCE_CURSOR_FILE is required for the file cursor backend"
+ )
+ failure_audit_file = os.getenv(
+ "GLANCE_FAILURE_AUDIT_FILE", ""
+ ).strip()
+ if cursor_backend == "file" and not failure_audit_file:
+ failure_audit_file = f"{cursor_file}.failures.jsonl"
+ failure_audit_prefix = os.getenv(
+ "GLANCE_FAILURE_AUDIT_PREFIX",
+ "production-trace-failures",
+ ).strip().strip("/")
+ if cursor_backend == "blob" and not failure_audit_prefix:
+ raise ConnectorConfigurationError(
+ "GLANCE_FAILURE_AUDIT_PREFIX cannot be empty"
+ )
+
+ backfill_min_run_id = optional_int("GLANCE_BACKFILL_MIN_RUN_ID")
+ backfill_max_run_id = optional_int("GLANCE_BACKFILL_MAX_RUN_ID")
+ run_ids_raw = os.getenv("GLANCE_BACKFILL_RUN_IDS", "").strip()
+ try:
+ backfill_run_ids = tuple(
+ sorted(
+ {
+ int(value.strip())
+ for value in run_ids_raw.split(",")
+ if value.strip()
+ }
+ )
+ )
+ except ValueError as exc:
+ raise ConnectorConfigurationError(
+ "GLANCE_BACKFILL_RUN_IDS must be a comma-separated integer list"
+ ) from exc
+ if any(value < 0 for value in backfill_run_ids):
+ raise ConnectorConfigurationError(
+ "GLANCE_BACKFILL_RUN_IDS cannot contain negative IDs"
+ )
+ if backfill_run_ids and (
+ backfill_min_run_id is not None or backfill_max_run_id is not None
+ ):
+ raise ConnectorConfigurationError(
+ "GLANCE_BACKFILL_RUN_IDS cannot be combined with min/max bounds"
+ )
+ if (
+ backfill_min_run_id is not None
+ and backfill_max_run_id is not None
+ and backfill_min_run_id > backfill_max_run_id
+ ):
+ raise ConnectorConfigurationError(
+ "GLANCE backfill minimum exceeds maximum"
+ )
+ max_runs_per_poll = int(os.getenv("GLANCE_MAX_RUNS_PER_POLL", "20"))
+ if max_runs_per_poll < 1 or max_runs_per_poll > 100:
+ raise ConnectorConfigurationError(
+ "GLANCE_MAX_RUNS_PER_POLL must be between 1 and 100"
+ )
+ if len(backfill_run_ids) > max_runs_per_poll:
+ raise ConnectorConfigurationError(
+ "GLANCE_BACKFILL_RUN_IDS contains more IDs than "
+ "GLANCE_MAX_RUNS_PER_POLL"
+ )
+ handoff_run_batch_size = int(
+ os.getenv("GLANCE_HANDOFF_RUN_BATCH_SIZE", "1")
+ )
+ if (
+ handoff_run_batch_size < 1
+ or handoff_run_batch_size > max_runs_per_poll
+ ):
+ raise ConnectorConfigurationError(
+ "GLANCE_HANDOFF_RUN_BATCH_SIZE must be between 1 and "
+ "GLANCE_MAX_RUNS_PER_POLL"
+ )
+ max_samples_per_run = int(
+ os.getenv("GLANCE_MAX_SAMPLES_PER_RUN", "100000")
+ )
+ if max_samples_per_run < 1 or max_samples_per_run > 10_000_000:
+ raise ConnectorConfigurationError(
+ "GLANCE_MAX_SAMPLES_PER_RUN must be between 1 and 10000000"
+ )
+ max_values_per_run = int(
+ os.getenv("GLANCE_MAX_VALUES_PER_RUN", "1000000")
+ )
+ if max_values_per_run < 1 or max_values_per_run > 100_000_000:
+ raise ConnectorConfigurationError(
+ "GLANCE_MAX_VALUES_PER_RUN must be between 1 and 100000000"
+ )
+
+ return cls(
+ postgrest_url=postgrest_url,
+ postgrest_token=postgrest_token,
+ ingestion_url=ingestion_url,
+ ingestion_token=_required_env("INGESTION_TOKEN"),
+ storage_connection=storage_connection,
+ cursor_container=os.getenv(
+ "GLANCE_CURSOR_CONTAINER", "glance-ingestion-state"
+ ).strip(),
+ cursor_blob=os.getenv(
+ "GLANCE_CURSOR_BLOB", "production-trace-cursors.json"
+ ).strip(),
+ mappings=tuple(mappings),
+ overlap_run_count=max(
+ 0, int(os.getenv("GLANCE_RUN_OVERLAP_COUNT", "5"))
+ ),
+ page_size=max(1, int(os.getenv("GLANCE_PAGE_SIZE", "1000"))),
+ timeout=(
+ max(1, int(os.getenv("GLANCE_CONNECT_TIMEOUT_SECONDS", "10"))),
+ max(1, int(os.getenv("GLANCE_READ_TIMEOUT_SECONDS", "120"))),
+ ),
+ data_batch_size=max(
+ 1, int(os.getenv("GLANCE_DATA_BATCH_SIZE", "25"))
+ ),
+ backfill_min_run_id=backfill_min_run_id,
+ backfill_max_run_id=backfill_max_run_id,
+ source_mode=source_mode,
+ db_uri=db_uri,
+ db_retries=max(0, int(os.getenv("GLANCE_DB_RETRIES", "3"))),
+ cursor_backend=cursor_backend,
+ cursor_file=cursor_file,
+ backfill_run_ids=backfill_run_ids,
+ max_runs_per_poll=max_runs_per_poll,
+ handoff_run_batch_size=handoff_run_batch_size,
+ max_samples_per_run=max_samples_per_run,
+ max_values_per_run=max_values_per_run,
+ failure_audit_file=failure_audit_file,
+ failure_audit_prefix=failure_audit_prefix,
+ )
+
+
+def _redacted_error(exc: Exception) -> str:
+ text = str(exc)
+ text = re.sub(
+ r"(?i)(postgres(?:ql)?://)[^@\s]+@",
+ r"\1[redacted]@",
+ text,
+ )
+ text = re.sub(
+ r"(?i)(password\s*=\s*)[^\s]+",
+ r"\1[redacted]",
+ text,
+ )
+ return text[:1000]
+
+
+class BlobCursorStore:
+ """One JSON blob containing independent committed cursors per source tool."""
+
+ def __init__(self, settings: ConnectorSettings):
+ from azure.storage.blob import BlobServiceClient
+
+ service = BlobServiceClient.from_connection_string(
+ settings.storage_connection
+ )
+ container = service.get_container_client(settings.cursor_container)
+ self._blob = container.get_blob_client(settings.cursor_blob)
+ self._etag: str | None = None
+
+ def load(self) -> dict[str, int]:
+ from azure.core.exceptions import ResourceNotFoundError
+
+ try:
+ download = self._blob.download_blob()
+ raw = download.readall().decode("utf-8")
+ properties = download.properties
+ etag = (
+ properties.get("etag")
+ if isinstance(properties, dict)
+ else properties.etag
+ )
+ self._etag = str(etag)
+ except ResourceNotFoundError:
+ self._etag = None
+ return {}
+ try:
+ parsed = json.loads(raw)
+ except json.JSONDecodeError as exc:
+ raise ConnectorConfigurationError(
+ "GLANCE cursor blob is not valid JSON"
+ ) from exc
+ if not isinstance(parsed, dict):
+ raise ConnectorConfigurationError("GLANCE cursor blob must be an object")
+ try:
+ return {str(key): int(value) for key, value in parsed.items()}
+ except (TypeError, ValueError) as exc:
+ raise ConnectorConfigurationError(
+ "GLANCE cursor blob contains a non-integer cursor"
+ ) from exc
+
+ def save(self, cursors: dict[str, int]) -> None:
+ from azure.core import MatchConditions
+
+ payload = json.dumps(cursors, sort_keys=True, separators=(",", ":"))
+ if self._etag is None:
+ response = self._blob.upload_blob(payload, overwrite=False)
+ else:
+ response = self._blob.upload_blob(
+ payload,
+ overwrite=True,
+ etag=self._etag,
+ match_condition=MatchConditions.IfNotModified,
+ )
+ response_etag = (
+ response.get("etag")
+ if isinstance(response, dict)
+ else getattr(response, "etag", None)
+ )
+ if response_etag is None:
+ response_etag = self._blob.get_blob_properties().etag
+ self._etag = str(response_etag)
+
+
+class CursorStore(Protocol):
+ def load(self) -> dict[str, int]:
+ ...
+
+ def save(self, cursors: dict[str, int]) -> None:
+ ...
+
+
+class FileCursorStore:
+ """Atomically persisted cursor state for a single non-concurrent CronJob."""
+
+ def __init__(self, settings: ConnectorSettings):
+ if not settings.cursor_file:
+ raise ConnectorConfigurationError("GLANCE_CURSOR_FILE is empty")
+ self._path = Path(settings.cursor_file)
+
+ def load(self) -> dict[str, int]:
+ try:
+ raw = self._path.read_text(encoding="utf-8")
+ except FileNotFoundError:
+ return {}
+ try:
+ parsed = json.loads(raw)
+ except json.JSONDecodeError as exc:
+ raise ConnectorConfigurationError(
+ f"GLANCE cursor file {self._path} is not valid JSON"
+ ) from exc
+ if not isinstance(parsed, dict):
+ raise ConnectorConfigurationError(
+ f"GLANCE cursor file {self._path} must contain an object"
+ )
+ try:
+ return {str(key): int(value) for key, value in parsed.items()}
+ except (TypeError, ValueError) as exc:
+ raise ConnectorConfigurationError(
+ f"GLANCE cursor file {self._path} contains a non-integer cursor"
+ ) from exc
+
+ def save(self, cursors: dict[str, int]) -> None:
+ self._path.parent.mkdir(parents=True, exist_ok=True)
+ payload = json.dumps(cursors, sort_keys=True, separators=(",", ":"))
+ descriptor, temporary_name = tempfile.mkstemp(
+ prefix=f".{self._path.name}.",
+ dir=str(self._path.parent),
+ text=True,
+ )
+ try:
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
+ handle.write(payload)
+ handle.flush()
+ os.fsync(handle.fileno())
+ os.chmod(temporary_name, 0o600)
+ os.replace(temporary_name, self._path)
+ except Exception:
+ try:
+ os.unlink(temporary_name)
+ except FileNotFoundError:
+ pass
+ raise
+
+
+class FailureAuditStore(Protocol):
+ def record(self, entry: dict[str, Any]) -> None:
+ ...
+
+
+class FileFailureAuditStore:
+ """Append-only local failure records for the single-concurrency CronJob."""
+
+ def __init__(self, settings: ConnectorSettings):
+ if not settings.failure_audit_file:
+ raise ConnectorConfigurationError(
+ "GLANCE_FAILURE_AUDIT_FILE is empty"
+ )
+ self._path = Path(settings.failure_audit_file)
+
+ def record(self, entry: dict[str, Any]) -> None:
+ self._path.parent.mkdir(parents=True, exist_ok=True)
+ payload = (
+ json.dumps(
+ entry,
+ sort_keys=True,
+ separators=(",", ":"),
+ default=str,
+ )
+ + "\n"
+ )
+ with self._path.open("a", encoding="utf-8") as handle:
+ os.chmod(self._path, 0o600)
+ handle.write(payload)
+ handle.flush()
+ os.fsync(handle.fileno())
+
+
+class BlobFailureAuditStore:
+ """Write one immutable audit object per failure in Azure Blob Storage."""
+
+ def __init__(self, settings: ConnectorSettings):
+ from azure.storage.blob import BlobServiceClient
+
+ service = BlobServiceClient.from_connection_string(
+ settings.storage_connection
+ )
+ self._container = service.get_container_client(
+ settings.cursor_container
+ )
+ self._prefix = settings.failure_audit_prefix
+
+ def record(self, entry: dict[str, Any]) -> None:
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
+ blob_name = f"{self._prefix}/{timestamp}-{uuid.uuid4()}.json"
+ payload = json.dumps(
+ entry,
+ sort_keys=True,
+ separators=(",", ":"),
+ default=str,
+ )
+ self._container.get_blob_client(blob_name).upload_blob(
+ payload,
+ overwrite=False,
+ )
+
+
+class GlanceSourceClient(Protocol):
+ def affected_runs(
+ self,
+ mapping: ToolMapping,
+ *,
+ cursor: int,
+ ) -> list[dict]:
+ ...
+
+ def complete_run(
+ self,
+ run: dict[str, Any],
+ mapping: ToolMapping,
+ ) -> dict[str, Any]:
+ ...
+
+
+class PostgrestGlanceClient:
+ def __init__(
+ self,
+ settings: ConnectorSettings,
+ *,
+ session: requests.Session | None = None,
+ ):
+ self.settings = settings
+ self.session = session or create_http_session(
+ {
+ "RETRIES": int(os.getenv("GLANCE_HTTP_RETRIES", "3")),
+ "BACKOFF": float(os.getenv("GLANCE_HTTP_BACKOFF", "0.5")),
+ }
+ )
+
+ def get_all(self, endpoint: str, params: dict[str, Any]) -> list[dict]:
+ return api_get_all(
+ self.session,
+ self.settings.postgrest_url,
+ endpoint,
+ params,
+ self.settings.postgrest_token,
+ page_size=self.settings.page_size,
+ timeout=self.settings.timeout,
+ )
+
+ def affected_runs(
+ self,
+ mapping: ToolMapping,
+ *,
+ cursor: int,
+ ) -> list[dict]:
+ if self.settings.backfill_run_ids:
+ pending_ids = [
+ value
+ for value in self.settings.backfill_run_ids
+ if value > cursor
+ ]
+ if not pending_ids:
+ return []
+ rows = self.get_all(
+ "/runs",
+ {
+ "select": "*",
+ "idtools": f"eq.{mapping.source_tool_id}",
+ "idruns": (
+ "in.("
+ + ",".join(
+ str(value)
+ for value in pending_ids
+ )
+ + ")"
+ ),
+ "order": "idruns.asc",
+ "limit": str(self.settings.max_runs_per_poll),
+ },
+ )
+ by_run_id = {int(row["idruns"]): row for row in rows}
+ return [
+ by_run_id.get(
+ run_id,
+ {
+ "idruns": run_id,
+ "idtools": mapping.source_tool_id,
+ "_missing_exact_run": True,
+ },
+ )
+ for run_id in pending_ids
+ ]
+
+ lower = (
+ self.settings.backfill_min_run_id
+ if self.settings.backfill_min_run_id is not None
+ else mapping.initial_run_id
+ )
+ upper = self.settings.backfill_max_run_id
+ overlap_limit = min(
+ self.settings.overlap_run_count,
+ max(0, self.settings.max_runs_per_poll - 1),
+ )
+ overlap_rows: list[dict] = []
+ if overlap_limit and cursor >= lower:
+ overlap_upper = min(cursor, upper) if upper is not None else cursor
+ overlap_rows = self.get_all(
+ "/runs",
+ {
+ "select": "*",
+ "idtools": f"eq.{mapping.source_tool_id}",
+ "and": (
+ f"(idruns.gte.{lower},"
+ f"idruns.lte.{overlap_upper})"
+ ),
+ "order": "idruns.desc",
+ "limit": str(overlap_limit),
+ },
+ )
+
+ new_limit = self.settings.max_runs_per_poll - len(overlap_rows)
+ clauses = [f"idruns.gte.{lower}", f"idruns.gt.{cursor}"]
+ if upper is not None:
+ clauses.append(f"idruns.lte.{upper}")
+ new_rows = self.get_all(
+ "/runs",
+ {
+ "select": "*",
+ "idtools": f"eq.{mapping.source_tool_id}",
+ "and": "(" + ",".join(clauses) + ")",
+ "order": "idruns.asc",
+ "limit": str(max(1, new_limit)),
+ },
+ )
+ by_run_id = {
+ int(row["idruns"]): row
+ for row in [*overlap_rows, *new_rows]
+ }
+ return [by_run_id[run_id] for run_id in sorted(by_run_id)]
+
+ def _recipe(self, recipe_id: Any) -> dict[str, Any]:
+ if recipe_id in (None, ""):
+ return {}
+ rows = self.get_all(
+ "/recipes",
+ {"select": "*", "idrecipes": f"eq.{int(recipe_id)}"},
+ )
+ return rows[0] if rows else {}
+
+ def _parameter_rows(self, parameter_ids: Iterable[int]) -> list[dict]:
+ ids = sorted(set(parameter_ids))
+ if not ids:
+ return []
+ rows = []
+ for batch in chunked(ids, self.settings.data_batch_size):
+ rows.extend(
+ self.get_all(
+ "/parameters",
+ {
+ "select": "*",
+ "idparameters": (
+ "in.(" + ",".join(str(value) for value in batch) + ")"
+ ),
+ },
+ )
+ )
+ return rows
+
+ def complete_run(
+ self,
+ run: dict[str, Any],
+ mapping: ToolMapping,
+ ) -> dict[str, Any]:
+ run_id = int(run["idruns"])
+ if int(run.get("idtools")) != mapping.source_tool_id:
+ raise ValueError(
+ f"Run {run_id} belongs to tool {run.get('idtools')}, "
+ f"expected {mapping.source_tool_id}"
+ )
+ sample_rows = self.get_all(
+ "/samplerecord",
+ {
+ # Avoid large source-only fields on this high-volume table.
+ # These are the complete fields used to preserve sample
+ # identity, ordering, tool provenance, and absolute time.
+ "select": "idsamplerecord,idruns,idtools,time",
+ "idruns": f"eq.{run_id}",
+ "order": "time.asc,idsamplerecord.asc",
+ },
+ )
+ if not sample_rows:
+ raise ValueError(f"Run {run_id} has no sample records")
+ if len(sample_rows) > self.settings.max_samples_per_run:
+ raise ValueError(
+ f"Run {run_id} sample count exceeds configured limit "
+ f"{self.settings.max_samples_per_run}"
+ )
+
+ sample_ids = [int(row["idsamplerecord"]) for row in sample_rows]
+ # The live GLANCE PostgREST schema exposes the data -> samplerecord
+ # relationship. Use disjoint numeric sample-ID ranges plus the run
+ # foreign key: large IN clauses return HTTP 503, while sorting the
+ # entire joined run before pagination is too slow.
+ values_by_sample: dict[int, dict[str, Any]] = {
+ sample_id: {} for sample_id in sample_ids
+ }
+ parameter_ids: set[int] = set()
+ value_count = 0
+ for batch in chunked(sorted(sample_ids), self.settings.data_batch_size):
+ lower_sample_id = min(batch)
+ upper_sample_id = max(batch)
+ batch_rows = self.get_all(
+ "/data",
+ {
+ "select": (
+ "idsamplerecord,idparameters,value,"
+ "samplerecord!inner(idruns)"
+ ),
+ "samplerecord.idruns": f"eq.{run_id}",
+ "and": (
+ f"(idsamplerecord.gte.{lower_sample_id},"
+ f"idsamplerecord.lte.{upper_sample_id})"
+ ),
+ "order": "idsamplerecord.asc,idparameters.asc",
+ },
+ )
+ value_count += len(batch_rows)
+ if value_count > self.settings.max_values_per_run:
+ raise ValueError(
+ f"Run {run_id} value count exceeds configured limit "
+ f"{self.settings.max_values_per_run}"
+ )
+ for row in batch_rows:
+ sample_id = int(row["idsamplerecord"])
+ parameter_id = int(row["idparameters"])
+ key = f"p{parameter_id}"
+ if key in values_by_sample.setdefault(sample_id, {}):
+ raise ValueError(
+ f"Sample {sample_id} contains duplicate parameter "
+ f"{parameter_id}"
+ )
+ values_by_sample[sample_id][key] = row.get("value")
+ parameter_ids.add(parameter_id)
+
+ parameters = []
+ parameter_by_id = {
+ int(row["idparameters"]): row
+ for row in self._parameter_rows(parameter_ids)
+ }
+ for parameter_id in sorted(parameter_ids):
+ row = parameter_by_id.get(parameter_id, {})
+ parameters.append(
+ {
+ "key": f"p{parameter_id}",
+ "source_parameter_id": parameter_id,
+ "name": row.get("name") or f"p{parameter_id}",
+ "source_name": row.get("name"),
+ "unit": row.get("unit") or "",
+ }
+ )
+
+ start_time = run.get("starttime")
+ end_time = run.get("endtime")
+ events = []
+ if start_time not in (None, "") and end_time not in (None, ""):
+ event_rows = self.get_all(
+ "/events",
+ {
+ "select": "*",
+ "tool": f"eq.{mapping.source_tool_id}",
+ "time": f"gte.{start_time}",
+ "and": f"(time.gte.{start_time},time.lte.{end_time})",
+ "order": "time.asc,idevents.asc",
+ },
+ )
+ event_types = self.get_all("/eventtypes", {"select": "*"})
+ category_by_type = {
+ str(row.get("event")): row.get("category")
+ for row in event_types
+ }
+ for event in event_rows:
+ events.append(
+ {
+ "source_event_id": int(event["idevents"]),
+ "source_tool_id": int(event.get("tool")),
+ "timestamp": event.get("time"),
+ "event_type": str(event.get("type") or ""),
+ "category": str(
+ category_by_type.get(str(event.get("type")), "")
+ ),
+ "description": str(event.get("description") or ""),
+ }
+ )
+
+ recipe = self._recipe(run.get("idrecipes"))
+ recipe_file = recipe.get("recipefile")
+ if isinstance(recipe_file, str):
+ try:
+ recipe_file_bytes = base64.b64decode(recipe_file, validate=True)
+ except (ValueError, TypeError):
+ recipe_file_bytes = recipe_file.encode("utf-8")
+ recipe_file_sha256 = hashlib.sha256(recipe_file_bytes).hexdigest()
+ recipe_file_size = len(recipe_file_bytes)
+ else:
+ recipe_file_sha256 = None
+ recipe_file_size = None
+ recipe_provenance = {
+ "source_recipe_id": run.get("idrecipes"),
+ "name": recipe.get("recipename"),
+ "source_hash": recipe.get("hash"),
+ "timestamp": recipe.get("timestamp"),
+ "file_sha256": recipe_file_sha256,
+ "file_size_bytes": recipe_file_size,
+ "raw": recipe,
+ }
+
+ samples = []
+ for index, sample in enumerate(sample_rows):
+ sample_id = int(sample["idsamplerecord"])
+ samples.append(
+ {
+ "source_sample_id": sample_id,
+ "sample_index": index,
+ "source_tool_id": (
+ int(sample["idtools"])
+ if sample.get("idtools") not in (None, "")
+ else mapping.source_tool_id
+ ),
+ "timestamp": sample.get("time"),
+ "values": values_by_sample.get(sample_id, {}),
+ }
+ )
+
+ return {
+ "source_run_id": run_id,
+ "source_updated_at": (
+ run.get("updated_at")
+ or run.get("updatedat")
+ or run.get("modified_at")
+ ),
+ "lotname": str(run.get("lotname") or ""),
+ "material": str(run.get("materialname") or ""),
+ "run_start_time": start_time,
+ "run_end_time": end_time,
+ "source_status": str(run.get("status") or ""),
+ "recipe": recipe_provenance,
+ "inputs": {},
+ "outputs": {},
+ "parameters": parameters,
+ "samples": samples,
+ "events": events,
+ "raw": {"glance": {"run": run, "recipe": recipe_provenance}},
+ "timestamp_timezone": None,
+ }
+
+
+def _json_safe(value: Any) -> Any:
+ if isinstance(value, datetime):
+ return value.isoformat()
+ if isinstance(value, Decimal):
+ return str(value)
+ if isinstance(value, float) and not math.isfinite(value):
+ if math.isnan(value):
+ return "NaN"
+ return "Infinity" if value > 0 else "-Infinity"
+ if isinstance(value, bytes):
+ return base64.b64encode(value).decode("ascii")
+ if isinstance(value, dict):
+ return {str(key): _json_safe(item) for key, item in value.items()}
+ if isinstance(value, (list, tuple)):
+ return [_json_safe(item) for item in value]
+ return value
+
+
+class PostgresGlanceClient(PostgrestGlanceClient):
+ """Read authoritative GLANCE rows through parameterized, read-only SQL."""
+
+ def __init__(
+ self,
+ settings: ConnectorSettings,
+ *,
+ connection_factory: Callable[..., Any] | None = None,
+ ):
+ # Do not construct an HTTP session for the SQL implementation.
+ self.settings = settings
+ self.session = None
+ self._sql_cursor: Any | None = None
+ if connection_factory is None:
+ import psycopg2
+ from psycopg2.extras import RealDictCursor
+
+ self._connection_factory = psycopg2.connect
+ self._dict_cursor_factory: Any | None = RealDictCursor
+ else:
+ self._connection_factory = connection_factory
+ self._dict_cursor_factory = None
+
+ @staticmethod
+ def _is_transient_database_error(exc: Exception) -> bool:
+ try:
+ import psycopg2
+ except ImportError:
+ return False
+ return isinstance(
+ exc,
+ (psycopg2.InterfaceError, psycopg2.OperationalError),
+ )
+
+ def _execute_snapshot(self, operation: Callable[[], _T]) -> _T:
+ attempts = self.settings.db_retries + 1
+ for attempt in range(attempts):
+ connection = None
+ cursor = None
+ try:
+ connection = self._connection_factory(
+ self.settings.db_uri,
+ connect_timeout=self.settings.timeout[0],
+ application_name="dt-glance-readonly",
+ )
+ connection.set_session(
+ readonly=True,
+ isolation_level="REPEATABLE READ",
+ autocommit=False,
+ )
+ if self._dict_cursor_factory is None:
+ cursor = connection.cursor()
+ else:
+ cursor = connection.cursor(
+ cursor_factory=self._dict_cursor_factory
+ )
+ self._sql_cursor = cursor
+ cursor.execute(
+ "SELECT set_config('statement_timeout', %s, true)",
+ (str(self.settings.timeout[1] * 1000),),
+ )
+ cursor.fetchone()
+ result = operation()
+ connection.rollback()
+ return result
+ except Exception as exc:
+ if connection is not None:
+ connection.rollback()
+ if (
+ not self._is_transient_database_error(exc)
+ or attempt + 1 >= attempts
+ ):
+ raise
+ delay = min(
+ 8.0,
+ float(os.getenv("GLANCE_DB_RETRY_BACKOFF", "0.5"))
+ * (2**attempt),
+ )
+ logger.warning(
+ "Transient GLANCE database error; retrying in %.1fs",
+ delay,
+ )
+ time.sleep(delay)
+ finally:
+ self._sql_cursor = None
+ if cursor is not None:
+ cursor.close()
+ if connection is not None:
+ connection.close()
+ raise AssertionError("unreachable")
+
+ def _query(self, query: str, parameters: tuple[Any, ...] = ()) -> list[dict]:
+ if self._sql_cursor is None:
+ raise RuntimeError("GLANCE SQL query attempted outside a snapshot")
+ self._sql_cursor.execute(query, parameters)
+ return [
+ _json_safe(dict(row))
+ for row in self._sql_cursor.fetchall()
+ ]
+
+ @staticmethod
+ def _eq_value(params: dict[str, Any], key: str) -> str:
+ value = str(params.get(key, ""))
+ if not value.startswith("eq."):
+ raise ValueError(f"Expected an equality filter for {key}")
+ return value[3:]
+
+ @staticmethod
+ def _in_values(params: dict[str, Any], key: str) -> list[int]:
+ value = str(params.get(key, ""))
+ if not value.startswith("in.(") or not value.endswith(")"):
+ raise ValueError(f"Expected an IN filter for {key}")
+ raw = value[4:-1]
+ return [int(item) for item in raw.split(",") if item]
+
+ @staticmethod
+ def _bounded_values(
+ params: dict[str, Any],
+ key: str,
+ ) -> tuple[str, str]:
+ raw = str(params.get("and", ""))
+ match = re.fullmatch(
+ rf"\({re.escape(key)}\.gte\.([^,]+),"
+ rf"{re.escape(key)}\.lte\.([^)]+)\)",
+ raw,
+ )
+ if match is None:
+ raise ValueError(f"Expected bounded {key} filter")
+ return match.group(1), match.group(2)
+
+ def get_all(self, endpoint: str, params: dict[str, Any]) -> list[dict]:
+ if endpoint == "/samplerecord":
+ run_id = int(self._eq_value(params, "idruns"))
+ return self._query(
+ """
+ SELECT idsamplerecord, idruns, idtools, time
+ FROM public.samplerecord
+ WHERE idruns = %s
+ ORDER BY time ASC NULLS LAST, idsamplerecord ASC
+ """,
+ (run_id,),
+ )
+ if endpoint == "/data":
+ run_id = int(self._eq_value(params, "samplerecord.idruns"))
+ lower, upper = self._bounded_values(params, "idsamplerecord")
+ return self._query(
+ """
+ SELECT d.idsamplerecord, d.idparameters, d.value
+ FROM public.data AS d
+ JOIN public.samplerecord AS s
+ ON s.idsamplerecord = d.idsamplerecord
+ WHERE s.idruns = %s
+ AND d.idsamplerecord >= %s
+ AND d.idsamplerecord <= %s
+ ORDER BY d.idsamplerecord ASC, d.idparameters ASC
+ """,
+ (run_id, int(lower), int(upper)),
+ )
+ if endpoint == "/parameters":
+ parameter_ids = self._in_values(params, "idparameters")
+ return self._query(
+ """
+ SELECT idparameters, name, unit
+ FROM public.parameters
+ WHERE idparameters = ANY(%s)
+ ORDER BY idparameters ASC
+ """,
+ (parameter_ids,),
+ )
+ if endpoint == "/events":
+ tool_id = int(self._eq_value(params, "tool"))
+ start_time, end_time = self._bounded_values(params, "time")
+ return self._query(
+ """
+ SELECT idevents, time, tool, type, description
+ FROM public.events
+ WHERE tool = %s
+ AND time >= %s
+ AND time <= %s
+ ORDER BY time ASC, idevents ASC
+ """,
+ (tool_id, start_time, end_time),
+ )
+ if endpoint == "/eventtypes":
+ return self._query(
+ """
+ SELECT event, category
+ FROM public.eventtypes
+ ORDER BY event ASC
+ """
+ )
+ if endpoint == "/recipes":
+ recipe_id = int(self._eq_value(params, "idrecipes"))
+ return self._query(
+ """
+ SELECT idrecipes, idtools, recipename, recipefile, hash, timestamp
+ FROM public.recipes
+ WHERE idrecipes = %s
+ """,
+ (recipe_id,),
+ )
+ raise ValueError(f"Unsupported GLANCE SQL endpoint: {endpoint}")
+
+ def affected_runs(
+ self,
+ mapping: ToolMapping,
+ *,
+ cursor: int,
+ ) -> list[dict]:
+ def operation() -> list[dict]:
+ if self.settings.backfill_run_ids:
+ pending_ids = [
+ value
+ for value in self.settings.backfill_run_ids
+ if value > cursor
+ ]
+ if not pending_ids:
+ return []
+ rows = self._query(
+ """
+ SELECT *
+ FROM public.runs
+ WHERE idtools = %s
+ AND idruns = ANY(%s)
+ ORDER BY idruns ASC
+ LIMIT %s
+ """,
+ (
+ mapping.source_tool_id,
+ pending_ids,
+ self.settings.max_runs_per_poll,
+ ),
+ )
+ by_run_id = {int(row["idruns"]): row for row in rows}
+ return [
+ by_run_id.get(
+ run_id,
+ {
+ "idruns": run_id,
+ "idtools": mapping.source_tool_id,
+ "_missing_exact_run": True,
+ },
+ )
+ for run_id in pending_ids
+ ]
+
+ lower = (
+ self.settings.backfill_min_run_id
+ if self.settings.backfill_min_run_id is not None
+ else mapping.initial_run_id
+ )
+ upper = self.settings.backfill_max_run_id
+ overlap_limit = min(
+ self.settings.overlap_run_count,
+ max(0, self.settings.max_runs_per_poll - 1),
+ )
+ overlap_rows: list[dict] = []
+ if overlap_limit and cursor >= lower:
+ overlap_upper = min(cursor, upper) if upper is not None else cursor
+ overlap_rows = self._query(
+ """
+ SELECT *
+ FROM public.runs
+ WHERE idtools = %s
+ AND idruns >= %s
+ AND idruns <= %s
+ ORDER BY idruns DESC
+ LIMIT %s
+ """,
+ (
+ mapping.source_tool_id,
+ lower,
+ overlap_upper,
+ overlap_limit,
+ ),
+ )
+ new_limit = max(
+ 1,
+ self.settings.max_runs_per_poll - len(overlap_rows),
+ )
+ if upper is None:
+ new_rows = self._query(
+ """
+ SELECT *
+ FROM public.runs
+ WHERE idtools = %s
+ AND idruns >= %s
+ AND idruns > %s
+ ORDER BY idruns ASC
+ LIMIT %s
+ """,
+ (
+ mapping.source_tool_id,
+ lower,
+ cursor,
+ new_limit,
+ ),
+ )
+ else:
+ new_rows = self._query(
+ """
+ SELECT *
+ FROM public.runs
+ WHERE idtools = %s
+ AND idruns >= %s
+ AND idruns > %s
+ AND idruns <= %s
+ ORDER BY idruns ASC
+ LIMIT %s
+ """,
+ (
+ mapping.source_tool_id,
+ lower,
+ cursor,
+ upper,
+ new_limit,
+ ),
+ )
+ by_run_id = {
+ int(row["idruns"]): row
+ for row in [*overlap_rows, *new_rows]
+ }
+ return [by_run_id[run_id] for run_id in sorted(by_run_id)]
+
+ return self._execute_snapshot(operation)
+
+ def complete_run(
+ self,
+ run: dict[str, Any],
+ mapping: ToolMapping,
+ ) -> dict[str, Any]:
+ run_id = int(run["idruns"])
+
+ def operation() -> dict[str, Any]:
+ current_rows = self._query(
+ """
+ SELECT *
+ FROM public.runs
+ WHERE idruns = %s
+ AND idtools = %s
+ """,
+ (run_id, mapping.source_tool_id),
+ )
+ if len(current_rows) != 1:
+ raise ValueError(
+ f"GLANCE run {run_id} for tool "
+ f"{mapping.source_tool_id} no longer exists"
+ )
+ current_run = current_rows[0]
+ if (
+ int(current_run.get("idruns")) != run_id
+ or int(current_run.get("idtools")) != mapping.source_tool_id
+ ):
+ raise ValueError(
+ f"GLANCE returned an inconsistent identity for run {run_id}"
+ )
+ return super(PostgresGlanceClient, self).complete_run(
+ current_run,
+ mapping,
+ )
+
+ return self._execute_snapshot(operation)
+
+
+def build_glance_client(settings: ConnectorSettings) -> GlanceSourceClient:
+ if settings.source_mode == "postgres":
+ return PostgresGlanceClient(settings)
+ return PostgrestGlanceClient(settings)
+
+
+def build_cursor_store(settings: ConnectorSettings) -> CursorStore:
+ if settings.cursor_backend == "file":
+ return FileCursorStore(settings)
+ return BlobCursorStore(settings)
+
+
+def build_failure_audit_store(
+ settings: ConnectorSettings,
+) -> FailureAuditStore:
+ if settings.cursor_backend == "file":
+ return FileFailureAuditStore(settings)
+ return BlobFailureAuditStore(settings)
+
+
+class GlancePoller:
+ def __init__(
+ self,
+ settings: ConnectorSettings,
+ *,
+ client: GlanceSourceClient | None = None,
+ cursor_store: CursorStore | None = None,
+ failure_audit_store: FailureAuditStore | None = None,
+ handoff_session: requests.Session | None = None,
+ ):
+ self.settings = settings
+ self.client = client or build_glance_client(settings)
+ self.cursor_store = cursor_store or build_cursor_store(settings)
+ self.failure_audit_store = (
+ failure_audit_store or build_failure_audit_store(settings)
+ )
+ self.handoff_session = handoff_session or create_http_session(
+ {
+ "RETRIES": int(os.getenv("GLANCE_HTTP_RETRIES", "3")),
+ "BACKOFF": float(os.getenv("GLANCE_HTTP_BACKOFF", "0.5")),
+ }
+ )
+
+ def _record_local_failure(
+ self,
+ *,
+ mapping: ToolMapping,
+ run_id: int | None,
+ cursor: int | None,
+ phase: str,
+ exc: Exception,
+ ) -> str:
+ error_text = _redacted_error(exc)
+ entry = {
+ "created_at": datetime.now(timezone.utc).isoformat(),
+ "equipment_id": mapping.equipment_id,
+ "source_tool_id": mapping.source_tool_id,
+ "source_run_id": run_id,
+ "cursor": cursor,
+ "phase": phase,
+ "status": "failed",
+ "error": error_text,
+ }
+ try:
+ self.failure_audit_store.record(entry)
+ except Exception as audit_exc:
+ logger.error(
+ "Could not persist GLANCE local failure audit: %s",
+ _redacted_error(audit_exc),
+ )
+ return error_text
+
+ def _cursor_key(self, mapping: ToolMapping) -> str:
+ base = f"{mapping.equipment_id}:{mapping.source_tool_id}"
+ if self.settings.backfill_run_ids:
+ digest = hashlib.sha256(
+ ",".join(
+ str(value) for value in self.settings.backfill_run_ids
+ ).encode("ascii")
+ ).hexdigest()[:16]
+ return f"{base}:backfill:ids:{digest}"
+ if (
+ self.settings.backfill_min_run_id is not None
+ or self.settings.backfill_max_run_id is not None
+ ):
+ lower = (
+ self.settings.backfill_min_run_id
+ if self.settings.backfill_min_run_id is not None
+ else mapping.initial_run_id
+ )
+ upper = (
+ str(self.settings.backfill_max_run_id)
+ if self.settings.backfill_max_run_id is not None
+ else "open"
+ )
+ return f"{base}:backfill:range:{lower}:{upper}"
+ return base
+
+ def _initial_cursor(self, mapping: ToolMapping) -> int:
+ if self.settings.backfill_run_ids:
+ return min(self.settings.backfill_run_ids) - 1
+ if self.settings.backfill_min_run_id is not None:
+ return self.settings.backfill_min_run_id - 1
+ return mapping.initial_run_id - 1
+
+ def _handoff(self, payload: dict[str, Any]) -> dict[str, Any]:
+ response = self.handoff_session.post(
+ self.settings.ingestion_url,
+ json=payload,
+ headers={"X-Ingestion-Token": self.settings.ingestion_token},
+ timeout=self.settings.timeout,
+ )
+ if response.status_code not in (200, 207):
+ raise ConnectorHandoffError(
+ f"DT trace ingestion returned HTTP {response.status_code}: "
+ f"{response.text[:500]}"
+ )
+ try:
+ result = response.json()
+ except ValueError as exc:
+ raise ConnectorHandoffError(
+ "DT trace ingestion returned a non-JSON response"
+ ) from exc
+ if not isinstance(result, dict):
+ raise ConnectorHandoffError(
+ "DT trace ingestion returned an invalid response object"
+ )
+ return result
+
+ def poll_mapping(
+ self,
+ mapping: ToolMapping,
+ cursors: dict[str, int],
+ ) -> dict[str, Any]:
+ key = self._cursor_key(mapping)
+ previous_cursor = int(cursors.get(key, self._initial_cursor(mapping)))
+ affected = self.client.affected_runs(mapping, cursor=previous_cursor)
+ if not affected:
+ return {
+ "equipment_id": mapping.equipment_id,
+ "source_tool_id": mapping.source_tool_id,
+ "status": "no_data",
+ "cursor": previous_cursor,
+ }
+ affected = sorted(affected, key=lambda run: int(run["idruns"]))
+ current_cursor = previous_cursor
+ last_proposed_cursor = previous_cursor
+ total_run_count = 0
+ total_rejected_count = 0
+ batch_count = 0
+ poll_started_at = datetime.now(timezone.utc).isoformat()
+ last_status = "success"
+ cursor_blocked = False
+ local_failures: list[dict[str, Any]] = []
+
+ for run_batch in chunked(
+ affected,
+ self.settings.handoff_run_batch_size,
+ ):
+ complete_runs = []
+ local_rejected_count = 0
+ for run in run_batch:
+ try:
+ complete_runs.append(self.client.complete_run(run, mapping))
+ except Exception as exc:
+ local_rejected_count += 1
+ cursor_blocked = True
+ try:
+ failed_run_id = int(run.get("idruns"))
+ except (TypeError, ValueError):
+ failed_run_id = None
+ error_text = self._record_local_failure(
+ mapping=mapping,
+ run_id=failed_run_id,
+ cursor=current_cursor,
+ phase="complete_run",
+ exc=exc,
+ )
+ local_failures.append(
+ {
+ "source_run_id": failed_run_id,
+ "error": error_text,
+ }
+ )
+ logger.error(
+ "Could not build complete GLANCE run %s: %s",
+ run.get("idruns"),
+ error_text,
+ )
+ last_proposed_cursor = max(
+ last_proposed_cursor,
+ max(int(run["idruns"]) for run in run_batch),
+ )
+ total_rejected_count += local_rejected_count
+ batch_count += 1
+ if not complete_runs:
+ last_status = "partial"
+ continue
+
+ payload_proposed_cursor = (
+ current_cursor if cursor_blocked else last_proposed_cursor
+ )
+ payload = {
+ "version": "1",
+ "source_system": "glance",
+ "batch_id": str(uuid.uuid4()),
+ "equipment_id": mapping.equipment_id,
+ "source_tool_id": mapping.source_tool_id,
+ "source_tool_name": mapping.source_tool_name,
+ "project_id": mapping.project_id,
+ "previous_cursor": str(current_cursor),
+ "proposed_cursor": str(payload_proposed_cursor),
+ "poll_started_at": poll_started_at,
+ "runs": complete_runs,
+ }
+ response = self._handoff(payload)
+ total_run_count += int(response.get("run_count", 0))
+ total_rejected_count += int(response.get("rejected_count", 0))
+ last_status = str(response.get("status", "unknown"))
+ if response.get("cursor_acknowledged") is not True:
+ cursor_blocked = True
+ last_status = "partial"
+ continue
+ if not cursor_blocked:
+ current_cursor = payload_proposed_cursor
+ cursors[key] = current_cursor
+ self.cursor_store.save(cursors)
+
+ return {
+ "equipment_id": mapping.equipment_id,
+ "source_tool_id": mapping.source_tool_id,
+ "status": "partial" if cursor_blocked else last_status,
+ "cursor_acknowledged": not cursor_blocked,
+ "previous_cursor": previous_cursor,
+ "cursor": current_cursor,
+ "proposed_cursor": last_proposed_cursor,
+ "run_count": total_run_count,
+ "rejected_count": total_rejected_count,
+ "batch_count": batch_count,
+ "local_failures": local_failures,
+ }
+
+ def poll_all(self) -> list[dict[str, Any]]:
+ try:
+ cursors = self.cursor_store.load()
+ except Exception as exc:
+ results = []
+ for mapping in self.settings.mappings:
+ error_text = self._record_local_failure(
+ mapping=mapping,
+ run_id=None,
+ cursor=None,
+ phase="cursor_load",
+ exc=exc,
+ )
+ logger.error(
+ "GLANCE cursor load failed for equipment=%s tool=%s: %s",
+ mapping.equipment_id,
+ mapping.source_tool_id,
+ error_text,
+ )
+ results.append(
+ {
+ "equipment_id": mapping.equipment_id,
+ "source_tool_id": mapping.source_tool_id,
+ "status": "failed",
+ "error": error_text,
+ }
+ )
+ return results
+ results = []
+ for mapping in self.settings.mappings:
+ try:
+ results.append(self.poll_mapping(mapping, cursors))
+ except Exception as exc:
+ key = self._cursor_key(mapping)
+ current_cursor = int(
+ cursors.get(key, self._initial_cursor(mapping))
+ )
+ error_text = self._record_local_failure(
+ mapping=mapping,
+ run_id=None,
+ cursor=current_cursor,
+ phase="poll_mapping",
+ exc=exc,
+ )
+ logger.error(
+ "GLANCE poll failed for equipment=%s tool=%s: %s",
+ mapping.equipment_id,
+ mapping.source_tool_id,
+ error_text,
+ )
+ results.append(
+ {
+ "equipment_id": mapping.equipment_id,
+ "source_tool_id": mapping.source_tool_id,
+ "status": "failed",
+ "error": error_text,
+ }
+ )
+ return results
diff --git a/azure/common/glance_mirror.py b/azure/common/glance_mirror.py
new file mode 100644
index 0000000..8831fba
--- /dev/null
+++ b/azure/common/glance_mirror.py
@@ -0,0 +1,1163 @@
+"""Bounded, pull-based synchronization from GLANCE into a modern mirror."""
+
+from __future__ import annotations
+
+import logging
+import os
+import re
+import time
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from hashlib import sha256
+from typing import Any, Callable, Iterable, Protocol, TypeVar
+
+
+logger = logging.getLogger("dt.azure.glance_mirror")
+_T = TypeVar("_T")
+
+
+class MirrorConfigurationError(RuntimeError):
+ pass
+
+
+def _required_env(name: str) -> str:
+ value = os.getenv(name, "").strip()
+ if not value:
+ raise MirrorConfigurationError(
+ f"Missing required environment variable: {name}"
+ )
+ return value
+
+
+def _integer_list(name: str, *, required: bool = False) -> tuple[int, ...]:
+ raw = os.getenv(name, "").strip()
+ if not raw:
+ if required:
+ raise MirrorConfigurationError(f"{name} cannot be empty")
+ return ()
+ try:
+ values = tuple(
+ sorted({int(item.strip()) for item in raw.split(",") if item.strip()})
+ )
+ except ValueError as exc:
+ raise MirrorConfigurationError(
+ f"{name} must be a comma-separated integer list"
+ ) from exc
+ if any(value < 0 for value in values):
+ raise MirrorConfigurationError(f"{name} cannot contain negative IDs")
+ if required and not values:
+ raise MirrorConfigurationError(f"{name} cannot be empty")
+ return values
+
+
+@dataclass(frozen=True)
+class MirrorSettings:
+ source_db_uri: str
+ destination_db_uri: str
+ tool_ids: tuple[int, ...]
+ initial_run_id: int
+ overlap_run_ids: int
+ max_runs_per_tool: int
+ max_samples_per_run: int
+ max_values_per_run: int
+ insert_batch_size: int
+ connect_timeout_seconds: int
+ statement_timeout_seconds: int
+ source_retries: int
+ backfill_min_run_id: int | None
+ backfill_max_run_id: int | None
+ backfill_run_ids: tuple[int, ...]
+
+ @classmethod
+ def from_env(cls) -> "MirrorSettings":
+ def optional_int(name: str) -> int | None:
+ value = os.getenv(name, "").strip()
+ return int(value) if value else None
+
+ backfill_min = optional_int("GLANCE_MIRROR_BACKFILL_MIN_RUN_ID")
+ backfill_max = optional_int("GLANCE_MIRROR_BACKFILL_MAX_RUN_ID")
+ backfill_ids = _integer_list("GLANCE_MIRROR_BACKFILL_RUN_IDS")
+ if backfill_ids and (backfill_min is not None or backfill_max is not None):
+ raise MirrorConfigurationError(
+ "GLANCE_MIRROR_BACKFILL_RUN_IDS cannot be combined with "
+ "min/max bounds"
+ )
+ if (
+ backfill_min is not None
+ and backfill_max is not None
+ and backfill_min > backfill_max
+ ):
+ raise MirrorConfigurationError(
+ "GLANCE mirror backfill minimum exceeds maximum"
+ )
+
+ max_runs = int(os.getenv("GLANCE_MIRROR_MAX_RUNS_PER_TOOL", "5"))
+ if max_runs < 1 or max_runs > 100:
+ raise MirrorConfigurationError(
+ "GLANCE_MIRROR_MAX_RUNS_PER_TOOL must be between 1 and 100"
+ )
+ if len(backfill_ids) > max_runs:
+ raise MirrorConfigurationError(
+ "GLANCE_MIRROR_BACKFILL_RUN_IDS contains more IDs than "
+ "GLANCE_MIRROR_MAX_RUNS_PER_TOOL"
+ )
+ batch_size = int(os.getenv("GLANCE_MIRROR_INSERT_BATCH_SIZE", "5000"))
+ if batch_size < 1 or batch_size > 50000:
+ raise MirrorConfigurationError(
+ "GLANCE_MIRROR_INSERT_BATCH_SIZE must be between 1 and 50000"
+ )
+ max_samples = int(
+ os.getenv("GLANCE_MIRROR_MAX_SAMPLES_PER_RUN", "100000")
+ )
+ if max_samples < 1 or max_samples > 10_000_000:
+ raise MirrorConfigurationError(
+ "GLANCE_MIRROR_MAX_SAMPLES_PER_RUN must be between 1 and "
+ "10000000"
+ )
+ max_values = int(
+ os.getenv("GLANCE_MIRROR_MAX_VALUES_PER_RUN", "1000000")
+ )
+ if max_values < 1 or max_values > 100_000_000:
+ raise MirrorConfigurationError(
+ "GLANCE_MIRROR_MAX_VALUES_PER_RUN must be between 1 and "
+ "100000000"
+ )
+
+ return cls(
+ source_db_uri=_required_env("GLANCE_DB_URI"),
+ destination_db_uri=_required_env("GLANCE_MIRROR_DB_URI"),
+ tool_ids=_integer_list("GLANCE_MIRROR_TOOL_IDS", required=True),
+ initial_run_id=max(
+ 0, int(os.getenv("GLANCE_MIRROR_INITIAL_RUN_ID", "0"))
+ ),
+ overlap_run_ids=max(
+ 0, int(os.getenv("GLANCE_MIRROR_OVERLAP_RUN_IDS", "5"))
+ ),
+ max_runs_per_tool=max_runs,
+ max_samples_per_run=max_samples,
+ max_values_per_run=max_values,
+ insert_batch_size=batch_size,
+ connect_timeout_seconds=max(
+ 1, int(os.getenv("GLANCE_MIRROR_CONNECT_TIMEOUT_SECONDS", "10"))
+ ),
+ statement_timeout_seconds=max(
+ 1,
+ int(os.getenv("GLANCE_MIRROR_STATEMENT_TIMEOUT_SECONDS", "300")),
+ ),
+ source_retries=max(
+ 0, int(os.getenv("GLANCE_MIRROR_SOURCE_RETRIES", "3"))
+ ),
+ backfill_min_run_id=backfill_min,
+ backfill_max_run_id=backfill_max,
+ backfill_run_ids=backfill_ids,
+ )
+
+
+def _sync_state_context(settings: MirrorSettings) -> tuple[str, int]:
+ """Return an independent state key and initial cursor for this sync mode."""
+ if settings.backfill_run_ids:
+ encoded_ids = ",".join(str(run_id) for run_id in settings.backfill_run_ids)
+ digest = sha256(encoded_ids.encode("ascii")).hexdigest()[:16]
+ return f"backfill:ids:{digest}", min(settings.backfill_run_ids) - 1
+ if (
+ settings.backfill_min_run_id is not None
+ or settings.backfill_max_run_id is not None
+ ):
+ lower = settings.backfill_min_run_id
+ if lower is None:
+ lower = settings.initial_run_id
+ upper = (
+ str(settings.backfill_max_run_id)
+ if settings.backfill_max_run_id is not None
+ else "open"
+ )
+ return f"backfill:range:{lower}:{upper}", lower - 1
+ return "live", settings.initial_run_id - 1
+
+
+@dataclass(frozen=True)
+class RunSnapshot:
+ run: dict[str, Any]
+ tool: dict[str, Any]
+ recipe: dict[str, Any] | None
+ samples: tuple[dict[str, Any], ...]
+ data: tuple[dict[str, Any], ...]
+ parameters: tuple[dict[str, Any], ...]
+ events: tuple[dict[str, Any], ...]
+ event_types: tuple[dict[str, Any], ...]
+
+ @property
+ def run_id(self) -> int:
+ return int(self.run["idruns"])
+
+ @property
+ def tool_id(self) -> int:
+ return int(self.run["idtools"])
+
+
+def _redacted_error(exc: Exception) -> str:
+ text = str(exc)
+ text = re.sub(
+ r"(?i)(postgres(?:ql)?://)[^@\s]+@",
+ r"\1[redacted]@",
+ text,
+ )
+ text = re.sub(
+ r"(?i)(password\s*=\s*)[^\s]+",
+ r"\1[redacted]",
+ text,
+ )
+ return text[:1000]
+
+
+class MirrorSource(Protocol):
+ def list_runs(self, tool_id: int, cursor: int) -> list[dict[str, Any]]:
+ ...
+
+ def load_run(self, run: dict[str, Any]) -> RunSnapshot:
+ ...
+
+
+class MirrorDestination(Protocol):
+ def get_cursor(self, tool_id: int, state_key: str, default: int) -> int:
+ ...
+
+ def replace_run(
+ self,
+ snapshot: RunSnapshot,
+ previous_cursor: int,
+ state_key: str,
+ *,
+ advance_cursor: bool,
+ ) -> dict[str, Any]:
+ ...
+
+ def record_failure(
+ self,
+ *,
+ tool_id: int,
+ run_id: int | None,
+ state_key: str,
+ previous_cursor: int,
+ duration_ms: int,
+ error_text: str,
+ ) -> None:
+ ...
+
+
+class PsycopgMirrorSource:
+ """Read complete source runs from GLANCE in repeatable-read snapshots."""
+
+ def __init__(
+ self,
+ settings: MirrorSettings,
+ *,
+ connection_factory: Callable[..., Any] | None = None,
+ ):
+ self.settings = settings
+ if connection_factory is None:
+ import psycopg2
+ from psycopg2.extras import RealDictCursor
+
+ self._connection_factory = psycopg2.connect
+ self._dict_cursor_factory: Any | None = RealDictCursor
+ else:
+ self._connection_factory = connection_factory
+ self._dict_cursor_factory = None
+
+ @staticmethod
+ def _is_transient(exc: Exception) -> bool:
+ try:
+ import psycopg2
+ except ImportError:
+ return False
+ return isinstance(
+ exc,
+ (psycopg2.InterfaceError, psycopg2.OperationalError),
+ )
+
+ def _read(self, operation: Callable[[Any], _T]) -> _T:
+ attempts = self.settings.source_retries + 1
+ for attempt in range(attempts):
+ connection = None
+ cursor = None
+ try:
+ connection = self._connection_factory(
+ self.settings.source_db_uri,
+ connect_timeout=self.settings.connect_timeout_seconds,
+ application_name="dt-glance-mirror-source",
+ )
+ connection.set_session(
+ readonly=True,
+ isolation_level="REPEATABLE READ",
+ autocommit=False,
+ )
+ if self._dict_cursor_factory is None:
+ cursor = connection.cursor()
+ else:
+ cursor = connection.cursor(
+ cursor_factory=self._dict_cursor_factory
+ )
+ cursor.execute(
+ "SELECT set_config('statement_timeout', %s, true)",
+ (str(self.settings.statement_timeout_seconds * 1000),),
+ )
+ cursor.fetchone()
+ result = operation(cursor)
+ connection.rollback()
+ return result
+ except Exception as exc:
+ if connection is not None:
+ connection.rollback()
+ if not self._is_transient(exc) or attempt + 1 >= attempts:
+ raise
+ delay = min(8.0, 0.5 * (2**attempt))
+ logger.warning(
+ "Transient GLANCE mirror source error; retrying in %.1fs",
+ delay,
+ )
+ time.sleep(delay)
+ finally:
+ if cursor is not None:
+ cursor.close()
+ if connection is not None:
+ connection.close()
+ raise AssertionError("unreachable")
+
+ @staticmethod
+ def _rows(cursor: Any) -> list[dict[str, Any]]:
+ return [dict(row) for row in cursor.fetchall()]
+
+ @staticmethod
+ def _bounded_rows(
+ cursor: Any,
+ *,
+ maximum: int,
+ label: str,
+ fetch_size: int = 5000,
+ ) -> list[dict[str, Any]]:
+ rows: list[dict[str, Any]] = []
+ while True:
+ batch = cursor.fetchmany(fetch_size)
+ if not batch:
+ return rows
+ rows.extend(dict(row) for row in batch)
+ if len(rows) > maximum:
+ raise ValueError(
+ f"GLANCE {label} count exceeds configured per-run limit "
+ f"{maximum}"
+ )
+
+ def list_runs(self, tool_id: int, cursor: int) -> list[dict[str, Any]]:
+ def operation(sql_cursor: Any) -> list[dict[str, Any]]:
+ if self.settings.backfill_run_ids:
+ pending_ids = [
+ run_id
+ for run_id in self.settings.backfill_run_ids
+ if run_id > cursor
+ ][: self.settings.max_runs_per_tool]
+ if not pending_ids:
+ return []
+ sql_cursor.execute(
+ """
+ SELECT *
+ FROM public.runs
+ WHERE idtools = %s
+ AND idruns = ANY(%s)
+ AND idruns > %s
+ ORDER BY idruns ASC
+ LIMIT %s
+ """,
+ (
+ tool_id,
+ pending_ids,
+ cursor,
+ len(pending_ids),
+ ),
+ )
+ rows = self._rows(sql_cursor)
+ by_run_id = {int(row["idruns"]): row for row in rows}
+ return [
+ by_run_id.get(
+ run_id,
+ {
+ "idruns": run_id,
+ "idtools": tool_id,
+ "_missing_exact_run": True,
+ },
+ )
+ for run_id in pending_ids
+ ]
+
+ lower = (
+ self.settings.backfill_min_run_id
+ if self.settings.backfill_min_run_id is not None
+ else self.settings.initial_run_id
+ )
+ upper = self.settings.backfill_max_run_id
+
+ overlap_rows: list[dict[str, Any]] = []
+ if self.settings.overlap_run_ids and cursor >= lower:
+ if upper is None:
+ sql_cursor.execute(
+ """
+ SELECT *
+ FROM public.runs
+ WHERE idtools = %s
+ AND idruns >= %s
+ AND idruns <= %s
+ ORDER BY idruns DESC
+ LIMIT %s
+ """,
+ (
+ tool_id,
+ lower,
+ cursor,
+ self.settings.overlap_run_ids,
+ ),
+ )
+ else:
+ sql_cursor.execute(
+ """
+ SELECT *
+ FROM public.runs
+ WHERE idtools = %s
+ AND idruns >= %s
+ AND idruns <= LEAST(%s, %s)
+ ORDER BY idruns DESC
+ LIMIT %s
+ """,
+ (
+ tool_id,
+ lower,
+ cursor,
+ upper,
+ self.settings.overlap_run_ids,
+ ),
+ )
+ overlap_rows = self._rows(sql_cursor)
+
+ if upper is None:
+ sql_cursor.execute(
+ """
+ SELECT *
+ FROM public.runs
+ WHERE idtools = %s
+ AND idruns >= %s
+ AND idruns > %s
+ ORDER BY idruns ASC
+ LIMIT %s
+ """,
+ (
+ tool_id,
+ lower,
+ cursor,
+ self.settings.max_runs_per_tool,
+ ),
+ )
+ else:
+ sql_cursor.execute(
+ """
+ SELECT *
+ FROM public.runs
+ WHERE idtools = %s
+ AND idruns >= %s
+ AND idruns > %s
+ AND idruns <= %s
+ ORDER BY idruns ASC
+ LIMIT %s
+ """,
+ (
+ tool_id,
+ lower,
+ cursor,
+ upper,
+ self.settings.max_runs_per_tool,
+ ),
+ )
+ new_rows = self._rows(sql_cursor)
+ by_run_id = {
+ int(row["idruns"]): row
+ for row in [*overlap_rows, *new_rows]
+ }
+ return [by_run_id[run_id] for run_id in sorted(by_run_id)]
+
+ return self._read(operation)
+
+ def load_run(self, run: dict[str, Any]) -> RunSnapshot:
+ run_id = int(run["idruns"])
+ tool_id = int(run["idtools"])
+
+ def operation(sql_cursor: Any) -> RunSnapshot:
+ sql_cursor.execute(
+ """
+ SELECT *
+ FROM public.runs
+ WHERE idruns = %s
+ AND idtools = %s
+ """,
+ (run_id, tool_id),
+ )
+ current_run_rows = self._rows(sql_cursor)
+ if len(current_run_rows) != 1:
+ raise ValueError(
+ f"GLANCE run {run_id} for tool {tool_id} no longer exists"
+ )
+ current_run = current_run_rows[0]
+
+ sql_cursor.execute(
+ "SELECT idtools, name, description FROM public.tools "
+ "WHERE idtools = %s",
+ (tool_id,),
+ )
+ tool_rows = self._rows(sql_cursor)
+ if len(tool_rows) != 1:
+ raise ValueError(
+ f"GLANCE run {run_id} references missing tool {tool_id}"
+ )
+
+ recipe = None
+ recipe_id = current_run.get("idrecipes")
+ if recipe_id is not None:
+ sql_cursor.execute(
+ """
+ SELECT idrecipes, idtools, recipename, recipefile, hash, timestamp
+ FROM public.recipes
+ WHERE idrecipes = %s
+ """,
+ (int(recipe_id),),
+ )
+ recipe_rows = self._rows(sql_cursor)
+ if len(recipe_rows) != 1:
+ raise ValueError(
+ f"GLANCE run {run_id} references missing recipe {recipe_id}"
+ )
+ recipe = recipe_rows[0]
+ if int(recipe["idtools"]) != tool_id:
+ raise ValueError(
+ f"GLANCE run {run_id} references recipe {recipe_id} "
+ f"for tool {recipe['idtools']}"
+ )
+
+ sql_cursor.execute(
+ """
+ SELECT idsamplerecord, idruns, idtools, time
+ FROM public.samplerecord
+ WHERE idruns = %s
+ ORDER BY idsamplerecord ASC
+ """,
+ (run_id,),
+ )
+ samples = self._bounded_rows(
+ sql_cursor,
+ maximum=self.settings.max_samples_per_run,
+ label="sample",
+ )
+ invalid_sample_tools = sorted(
+ {
+ int(row["idtools"])
+ for row in samples
+ if int(row["idtools"]) != tool_id
+ }
+ )
+ if invalid_sample_tools:
+ raise ValueError(
+ f"GLANCE run {run_id} has sample records for tools "
+ f"{invalid_sample_tools}"
+ )
+
+ sql_cursor.execute(
+ """
+ SELECT d.idsamplerecord, d.idparameters, d.value
+ FROM public.data AS d
+ JOIN public.samplerecord AS s
+ ON s.idsamplerecord = d.idsamplerecord
+ WHERE s.idruns = %s
+ ORDER BY d.idsamplerecord ASC, d.idparameters ASC
+ """,
+ (run_id,),
+ )
+ data_rows = self._bounded_rows(
+ sql_cursor,
+ maximum=self.settings.max_values_per_run,
+ label="value",
+ )
+ parameter_ids = sorted(
+ {int(row["idparameters"]) for row in data_rows}
+ )
+ if parameter_ids:
+ sql_cursor.execute(
+ """
+ SELECT idparameters, name, unit
+ FROM public.parameters
+ WHERE idparameters = ANY(%s)
+ ORDER BY idparameters ASC
+ """,
+ (parameter_ids,),
+ )
+ parameters = self._rows(sql_cursor)
+ found_parameter_ids = {
+ int(row["idparameters"]) for row in parameters
+ }
+ missing = set(parameter_ids) - found_parameter_ids
+ if missing:
+ raise ValueError(
+ f"GLANCE run {run_id} references missing parameters "
+ f"{sorted(missing)}"
+ )
+ else:
+ parameters = []
+
+ events: list[dict[str, Any]] = []
+ start_time = current_run.get("starttime")
+ end_time = current_run.get("endtime")
+ if start_time is not None and end_time is not None:
+ sql_cursor.execute(
+ """
+ SELECT idevents, time, tool, type, description
+ FROM public.events
+ WHERE tool = %s
+ AND time >= %s
+ AND time <= %s
+ ORDER BY time ASC, idevents ASC
+ """,
+ (tool_id, start_time, end_time),
+ )
+ events = self._rows(sql_cursor)
+
+ event_names = sorted(
+ {
+ str(row["type"])
+ for row in events
+ if row.get("type") is not None
+ }
+ )
+ if event_names:
+ sql_cursor.execute(
+ """
+ SELECT event, category
+ FROM public.eventtypes
+ WHERE event = ANY(%s)
+ ORDER BY event ASC
+ """,
+ (event_names,),
+ )
+ event_types = self._rows(sql_cursor)
+ else:
+ event_types = []
+
+ return RunSnapshot(
+ run=current_run,
+ tool=tool_rows[0],
+ recipe=recipe,
+ samples=tuple(samples),
+ data=tuple(data_rows),
+ parameters=tuple(parameters),
+ events=tuple(events),
+ event_types=tuple(event_types),
+ )
+
+ return self._read(operation)
+
+
+class PsycopgMirrorDestination:
+ """Atomically replace run-scoped rows in the modern destination mirror."""
+
+ def __init__(
+ self,
+ settings: MirrorSettings,
+ *,
+ connection_factory: Callable[..., Any] | None = None,
+ execute_values_function: Callable[..., Any] | None = None,
+ ):
+ self.settings = settings
+ if connection_factory is None:
+ import psycopg2
+ from psycopg2.extras import execute_values
+
+ self._connection_factory = psycopg2.connect
+ self._execute_values = execute_values
+ else:
+ if execute_values_function is None:
+ raise ValueError(
+ "execute_values_function is required with a connection fixture"
+ )
+ self._connection_factory = connection_factory
+ self._execute_values = execute_values_function
+
+ def _connect(self) -> Any:
+ connection = self._connection_factory(
+ self.settings.destination_db_uri,
+ connect_timeout=self.settings.connect_timeout_seconds,
+ application_name="dt-glance-mirror-destination",
+ )
+ connection.autocommit = False
+ return connection
+
+ def get_cursor(self, tool_id: int, state_key: str, default: int) -> int:
+ connection = self._connect()
+ try:
+ with connection.cursor() as sql_cursor:
+ sql_cursor.execute(
+ """
+ SELECT last_run_id
+ FROM glance_mirror.sync_state
+ WHERE idtools = %s
+ AND state_key = %s
+ """,
+ (tool_id, state_key),
+ )
+ row = sql_cursor.fetchone()
+ connection.rollback()
+ return int(row[0]) if row is not None else int(default)
+ finally:
+ connection.close()
+
+ def _insert_values(
+ self,
+ sql_cursor: Any,
+ query: str,
+ rows: Iterable[tuple[Any, ...]],
+ ) -> None:
+ batch: list[tuple[Any, ...]] = []
+ for row in rows:
+ batch.append(row)
+ if len(batch) < self.settings.insert_batch_size:
+ continue
+ self._execute_values(
+ sql_cursor,
+ query,
+ batch,
+ page_size=self.settings.insert_batch_size,
+ )
+ batch = []
+ if batch:
+ self._execute_values(
+ sql_cursor,
+ query,
+ batch,
+ page_size=self.settings.insert_batch_size,
+ )
+
+ def replace_run(
+ self,
+ snapshot: RunSnapshot,
+ previous_cursor: int,
+ state_key: str,
+ *,
+ advance_cursor: bool,
+ ) -> dict[str, Any]:
+ started = time.monotonic()
+ run_id = snapshot.run_id
+ tool_id = snapshot.tool_id
+ connection = self._connect()
+ try:
+ with connection.cursor() as sql_cursor:
+ sql_cursor.execute(
+ """
+ INSERT INTO glance_mirror.tools (idtools, name, description)
+ VALUES (%s, %s, %s)
+ ON CONFLICT (idtools) DO UPDATE
+ SET name = EXCLUDED.name,
+ description = EXCLUDED.description
+ """,
+ (
+ snapshot.tool["idtools"],
+ snapshot.tool["name"],
+ snapshot.tool["description"],
+ ),
+ )
+
+ if snapshot.recipe is not None:
+ recipe = snapshot.recipe
+ sql_cursor.execute(
+ """
+ INSERT INTO glance_mirror.recipes (
+ idrecipes, idtools, recipename, recipefile, hash, timestamp
+ )
+ VALUES (%s, %s, %s, %s, %s, %s)
+ ON CONFLICT (idrecipes) DO UPDATE
+ SET idtools = EXCLUDED.idtools,
+ recipename = EXCLUDED.recipename,
+ recipefile = EXCLUDED.recipefile,
+ hash = EXCLUDED.hash,
+ timestamp = EXCLUDED.timestamp
+ """,
+ (
+ recipe["idrecipes"],
+ recipe["idtools"],
+ recipe["recipename"],
+ recipe.get("recipefile"),
+ recipe["hash"],
+ recipe.get("timestamp"),
+ ),
+ )
+
+ self._insert_values(
+ sql_cursor,
+ """
+ INSERT INTO glance_mirror.parameters (
+ idparameters, name, unit
+ ) VALUES %s
+ ON CONFLICT (idparameters) DO UPDATE
+ SET name = EXCLUDED.name,
+ unit = EXCLUDED.unit
+ """,
+ (
+ (
+ row["idparameters"],
+ row["name"],
+ row.get("unit"),
+ )
+ for row in snapshot.parameters
+ ),
+ )
+ self._insert_values(
+ sql_cursor,
+ """
+ INSERT INTO glance_mirror.eventtypes (event, category)
+ VALUES %s
+ ON CONFLICT (event) DO UPDATE
+ SET category = EXCLUDED.category
+ """,
+ (
+ (row["event"], row["category"])
+ for row in snapshot.event_types
+ ),
+ )
+
+ run = snapshot.run
+ sql_cursor.execute(
+ """
+ INSERT INTO glance_mirror.runs (
+ idruns, idtools, lotname, idrecipes, starttime,
+ endtime, status, materialname
+ )
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
+ ON CONFLICT (idruns) DO UPDATE
+ SET idtools = EXCLUDED.idtools,
+ lotname = EXCLUDED.lotname,
+ idrecipes = EXCLUDED.idrecipes,
+ starttime = EXCLUDED.starttime,
+ endtime = EXCLUDED.endtime,
+ status = EXCLUDED.status,
+ materialname = EXCLUDED.materialname
+ """,
+ (
+ run["idruns"],
+ run["idtools"],
+ run.get("lotname"),
+ run.get("idrecipes"),
+ run["starttime"],
+ run.get("endtime"),
+ run.get("status"),
+ run.get("materialname"),
+ ),
+ )
+
+ sql_cursor.execute(
+ """
+ DELETE FROM glance_mirror.data AS d
+ USING glance_mirror.samplerecord AS s
+ WHERE d.idsamplerecord = s.idsamplerecord
+ AND s.idruns = %s
+ """,
+ (run_id,),
+ )
+ sql_cursor.execute(
+ "DELETE FROM glance_mirror.samplerecord WHERE idruns = %s",
+ (run_id,),
+ )
+ self._insert_values(
+ sql_cursor,
+ """
+ INSERT INTO glance_mirror.samplerecord (
+ idsamplerecord, idruns, idtools, time
+ ) VALUES %s
+ """,
+ (
+ (
+ row["idsamplerecord"],
+ row["idruns"],
+ row["idtools"],
+ row.get("time"),
+ )
+ for row in snapshot.samples
+ ),
+ )
+ self._insert_values(
+ sql_cursor,
+ """
+ INSERT INTO glance_mirror.data (
+ idsamplerecord, idparameters, value
+ ) VALUES %s
+ """,
+ (
+ (
+ row["idsamplerecord"],
+ row["idparameters"],
+ row["value"],
+ )
+ for row in snapshot.data
+ ),
+ )
+
+ sql_cursor.execute(
+ """
+ SELECT idevents
+ FROM glance_mirror.run_events
+ WHERE idruns = %s
+ """,
+ (run_id,),
+ )
+ old_event_ids = {
+ int(row[0]) for row in sql_cursor.fetchall()
+ }
+ sql_cursor.execute(
+ "DELETE FROM glance_mirror.run_events WHERE idruns = %s",
+ (run_id,),
+ )
+ self._insert_values(
+ sql_cursor,
+ """
+ INSERT INTO glance_mirror.events (
+ idevents, time, tool, type, description
+ ) VALUES %s
+ ON CONFLICT (idevents) DO UPDATE
+ SET time = EXCLUDED.time,
+ tool = EXCLUDED.tool,
+ type = EXCLUDED.type,
+ description = EXCLUDED.description
+ """,
+ (
+ (
+ row["idevents"],
+ row["time"],
+ row["tool"],
+ row.get("type"),
+ row["description"],
+ )
+ for row in snapshot.events
+ ),
+ )
+ self._insert_values(
+ sql_cursor,
+ """
+ INSERT INTO glance_mirror.run_events (idruns, idevents)
+ VALUES %s
+ ON CONFLICT (idruns, idevents) DO NOTHING
+ """,
+ (
+ (run_id, row["idevents"])
+ for row in snapshot.events
+ ),
+ )
+ current_event_ids = {
+ int(row["idevents"]) for row in snapshot.events
+ }
+ stale_event_ids = sorted(old_event_ids - current_event_ids)
+ if stale_event_ids:
+ sql_cursor.execute(
+ """
+ DELETE FROM glance_mirror.events AS event
+ WHERE event.idevents = ANY(%s)
+ AND NOT EXISTS (
+ SELECT 1
+ FROM glance_mirror.run_events AS association
+ WHERE association.idevents = event.idevents
+ )
+ """,
+ (stale_event_ids,),
+ )
+
+ new_cursor = (
+ max(previous_cursor, run_id)
+ if advance_cursor
+ else previous_cursor
+ )
+ now = datetime.now(timezone.utc)
+ sql_cursor.execute(
+ """
+ INSERT INTO glance_mirror.sync_state (
+ idtools, state_key, last_run_id, updated_at
+ )
+ VALUES (%s, %s, %s, %s)
+ ON CONFLICT (idtools, state_key) DO UPDATE
+ SET last_run_id = GREATEST(
+ glance_mirror.sync_state.last_run_id,
+ EXCLUDED.last_run_id
+ ),
+ updated_at = EXCLUDED.updated_at
+ """,
+ (tool_id, state_key, new_cursor, now),
+ )
+ duration_ms = int((time.monotonic() - started) * 1000)
+ sql_cursor.execute(
+ """
+ INSERT INTO glance_mirror.sync_audit (
+ idtools, idruns, state_key, previous_cursor,
+ committed_cursor,
+ sample_count, value_count, event_count, status,
+ duration_ms, error_text, created_at
+ )
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'success',
+ %s, '', %s)
+ """,
+ (
+ tool_id,
+ run_id,
+ state_key,
+ previous_cursor,
+ new_cursor,
+ len(snapshot.samples),
+ len(snapshot.data),
+ len(snapshot.events),
+ duration_ms,
+ now,
+ ),
+ )
+ connection.commit()
+ return {
+ "tool_id": tool_id,
+ "run_id": run_id,
+ "cursor": new_cursor,
+ "sample_count": len(snapshot.samples),
+ "value_count": len(snapshot.data),
+ "event_count": len(snapshot.events),
+ "duration_ms": duration_ms,
+ }
+ except Exception:
+ connection.rollback()
+ raise
+ finally:
+ connection.close()
+
+ def record_failure(
+ self,
+ *,
+ tool_id: int,
+ run_id: int | None,
+ state_key: str,
+ previous_cursor: int,
+ duration_ms: int,
+ error_text: str,
+ ) -> None:
+ connection = self._connect()
+ try:
+ with connection.cursor() as sql_cursor:
+ sql_cursor.execute(
+ """
+ INSERT INTO glance_mirror.sync_audit (
+ idtools, idruns, state_key, previous_cursor,
+ committed_cursor,
+ sample_count, value_count, event_count, status,
+ duration_ms, error_text, created_at
+ )
+ VALUES (%s, %s, %s, %s, %s, 0, 0, 0, 'failed',
+ %s, %s, %s)
+ """,
+ (
+ tool_id,
+ run_id,
+ state_key,
+ previous_cursor,
+ previous_cursor,
+ duration_ms,
+ error_text[:1000],
+ datetime.now(timezone.utc),
+ ),
+ )
+ connection.commit()
+ except Exception:
+ connection.rollback()
+ logger.exception("Could not persist GLANCE mirror failure audit")
+ finally:
+ connection.close()
+
+
+class GlanceMirrorCoordinator:
+ def __init__(
+ self,
+ settings: MirrorSettings,
+ *,
+ source: MirrorSource | None = None,
+ destination: MirrorDestination | None = None,
+ ):
+ self.settings = settings
+ self.source = source or PsycopgMirrorSource(settings)
+ self.destination = destination or PsycopgMirrorDestination(settings)
+
+ def sync_tool(self, tool_id: int) -> dict[str, Any]:
+ state_key, default_cursor = _sync_state_context(self.settings)
+ cursor = self.destination.get_cursor(
+ tool_id,
+ state_key,
+ default_cursor,
+ )
+ runs = self.source.list_runs(tool_id, cursor)
+ result: dict[str, Any] = {
+ "tool_id": tool_id,
+ "state_key": state_key,
+ "previous_cursor": cursor,
+ "cursor": cursor,
+ "status": "no_data" if not runs else "success",
+ "runs": [],
+ }
+ cursor_blocked = False
+ for run in runs:
+ run_id = int(run["idruns"])
+ started = time.monotonic()
+ try:
+ snapshot = self.source.load_run(run)
+ if snapshot.tool_id != tool_id:
+ raise ValueError(
+ f"GLANCE run {run_id} belongs to tool "
+ f"{snapshot.tool_id}, expected {tool_id}"
+ )
+ stats = self.destination.replace_run(
+ snapshot,
+ cursor,
+ state_key,
+ advance_cursor=not cursor_blocked,
+ )
+ if not cursor_blocked:
+ cursor = int(stats["cursor"])
+ result["cursor"] = cursor
+ result["runs"].append(stats)
+ except Exception as exc:
+ duration_ms = int((time.monotonic() - started) * 1000)
+ error_text = _redacted_error(exc)
+ self.destination.record_failure(
+ tool_id=tool_id,
+ run_id=run_id,
+ state_key=state_key,
+ previous_cursor=cursor,
+ duration_ms=duration_ms,
+ error_text=error_text,
+ )
+ cursor_blocked = True
+ result["status"] = "partial_failure"
+ result.setdefault("failures", []).append(
+ {"run_id": run_id, "error": error_text}
+ )
+ result.setdefault("failed_run_id", run_id)
+ result.setdefault("error", error_text)
+ result["run_count"] = len(result["runs"])
+ return result
+
+ def sync_all(self) -> list[dict[str, Any]]:
+ results = []
+ for tool_id in self.settings.tool_ids:
+ try:
+ results.append(self.sync_tool(tool_id))
+ except Exception as exc:
+ logger.exception("GLANCE mirror failed for tool %s", tool_id)
+ results.append(
+ {
+ "tool_id": tool_id,
+ "status": "failed",
+ "error": _redacted_error(exc),
+ "run_count": 0,
+ }
+ )
+ return results
diff --git a/azure/local.settings.json.example b/azure/local.settings.json.example
index a325262..6280a9a 100644
--- a/azure/local.settings.json.example
+++ b/azure/local.settings.json.example
@@ -14,8 +14,28 @@
"POSTGREST_TOKEN": "YOUR_POSTGREST_JWT_TOKEN",
"POSTGREST_URL": "https://YOUR_POSTGREST_SERVER_URL",
"INGESTION_API_URL": "https://YOUR_DT_API_HOST/api/dataset/v2/runs/sync",
+ "GLANCE_INGESTION_API_URL": "https://YOUR_DT_API_HOST/api/dataset/v2/glance/traces/sync",
"INGESTION_TOKEN": "YOUR_SHARED_INGESTION_TOKEN",
+ "GLANCE_SOURCE_MODE": "postgres",
+ "GLANCE_DB_URI": "postgresql://READ_ONLY_USER:URL_ENCODED_PASSWORD@GLANCE_HOST:5432/logger?sslmode=disable",
"INGESTION_EQUIPMENT_ID": "etcher",
+ "GLANCE_POLL_SCHEDULE": "0 */10 * * * *",
+ "GLANCE_PRODUCTION_MAPPINGS_JSON": "{\"YOUR_EQUIPMENT_ID\":{\"source_tool_id\":123,\"source_tool_name\":\"YOUR_GLANCE_TOOL_NAME\",\"project_id\":\"YOUR_PROJECT_ID\",\"initial_run_id\":1}}",
+ "GLANCE_CURSOR_BACKEND": "file",
+ "GLANCE_CURSOR_FILE": "/state/cursors.json",
+ "GLANCE_FAILURE_AUDIT_FILE": "/state/failures.jsonl",
+ "GLANCE_FAILURE_AUDIT_PREFIX": "production-trace-failures",
+ "GLANCE_CURSOR_CONTAINER": "glance-ingestion-state",
+ "GLANCE_CURSOR_BLOB": "production-trace-cursors.json",
+ "GLANCE_RUN_OVERLAP_COUNT": "5",
+ "GLANCE_PAGE_SIZE": "1000",
+ "GLANCE_DATA_BATCH_SIZE": "25",
+ "GLANCE_DB_RETRIES": "3",
+ "GLANCE_MAX_RUNS_PER_POLL": "20",
+ "GLANCE_MAX_SAMPLES_PER_RUN": "100000",
+ "GLANCE_MAX_VALUES_PER_RUN": "1000000",
+ "GLANCE_HANDOFF_RUN_BATCH_SIZE": "1",
+ "GLANCE_BACKFILL_RUN_IDS": "",
"PROCESSING_MODE": "incremental",
"TEST_INCREMENTAL_K": "7",
"BLOB_CONTAINER": "etcher-data",
diff --git a/azure/process_glance_traces/__init__.py b/azure/process_glance_traces/__init__.py
new file mode 100644
index 0000000..aca39cc
--- /dev/null
+++ b/azure/process_glance_traces/__init__.py
@@ -0,0 +1,16 @@
+"""Ten-minute production GLANCE full-trace polling entry point."""
+
+import json
+import logging
+
+import azure.functions as func
+
+from common.glance_connector import ConnectorSettings, GlancePoller
+
+
+def main(timer: func.TimerRequest) -> None:
+ if timer.past_due:
+ logging.warning("Production GLANCE trace poll is running late")
+ settings = ConnectorSettings.from_env()
+ results = GlancePoller(settings).poll_all()
+ logging.info("Production GLANCE trace poll results: %s", json.dumps(results))
diff --git a/azure/process_glance_traces/function.json b/azure/process_glance_traces/function.json
new file mode 100644
index 0000000..187736a
--- /dev/null
+++ b/azure/process_glance_traces/function.json
@@ -0,0 +1,13 @@
+{
+ "scriptFile": "__init__.py",
+ "bindings": [
+ {
+ "name": "timer",
+ "type": "timerTrigger",
+ "direction": "in",
+ "schedule": "%GLANCE_POLL_SCHEDULE%",
+ "runOnStartup": false,
+ "useMonitor": true
+ }
+ ]
+}
diff --git a/azure/requirements-glance.txt b/azure/requirements-glance.txt
new file mode 100644
index 0000000..70c1b9a
--- /dev/null
+++ b/azure/requirements-glance.txt
@@ -0,0 +1,3 @@
+psycopg2-binary>=2.9.0
+requests>=2.32.0
+urllib3>=2.2.0
diff --git a/azure/scripts/run_glance_mirror.py b/azure/scripts/run_glance_mirror.py
new file mode 100644
index 0000000..11d2d9e
--- /dev/null
+++ b/azure/scripts/run_glance_mirror.py
@@ -0,0 +1,30 @@
+#!/usr/bin/env python3
+"""Run one bounded GLANCE raw-mirror synchronization."""
+
+from __future__ import annotations
+
+import json
+import logging
+
+from common.glance_mirror import GlanceMirrorCoordinator, MirrorSettings
+
+
+def main() -> int:
+ logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s %(levelname)s %(name)s %(message)s",
+ )
+ results = GlanceMirrorCoordinator(MirrorSettings.from_env()).sync_all()
+ print(json.dumps(results, sort_keys=True))
+ return (
+ 1
+ if any(
+ item.get("status") not in {"no_data", "success"}
+ for item in results
+ )
+ else 0
+ )
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/azure/scripts/run_glance_poll.py b/azure/scripts/run_glance_poll.py
new file mode 100644
index 0000000..666e65c
--- /dev/null
+++ b/azure/scripts/run_glance_poll.py
@@ -0,0 +1,32 @@
+#!/usr/bin/env python3
+"""Run one scheduled GLANCE-to-DT ingestion poll."""
+
+from __future__ import annotations
+
+import json
+import logging
+
+from common.glance_connector import ConnectorSettings, GlancePoller
+
+
+def main() -> int:
+ logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s %(levelname)s %(name)s %(message)s",
+ )
+ settings = ConnectorSettings.from_env()
+ results = GlancePoller(settings).poll_all()
+ print(json.dumps(results, sort_keys=True))
+ failed = any(
+ result.get("status") == "failed"
+ or (
+ result.get("status") not in {"no_data", "success"}
+ and result.get("cursor_acknowledged") is not True
+ )
+ for result in results
+ )
+ return 1 if failed else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/azure/test_scripts/shadow_glance_poll.py b/azure/test_scripts/shadow_glance_poll.py
new file mode 100644
index 0000000..a68f1d4
--- /dev/null
+++ b/azure/test_scripts/shadow_glance_poll.py
@@ -0,0 +1,541 @@
+#!/usr/bin/env python3
+"""Read-only GLANCE trace audit for explicitly named tools.
+
+This utility deliberately does not construct ``GlancePoller``. It therefore
+cannot hand data to Digital Twin or load/save a production cursor. Complete
+run payloads exist only in memory long enough to calculate integrity counts
+and a canonical SHA-256 digest.
+"""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import sys
+import time
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+
+
+AZURE_ROOT = Path(__file__).resolve().parents[1]
+if str(AZURE_ROOT) not in sys.path:
+ sys.path.insert(0, str(AZURE_ROOT))
+
+from common.glance_connector import ( # noqa: E402
+ ConnectorSettings,
+ PostgrestGlanceClient,
+ ToolMapping,
+)
+
+
+DEFAULT_TOOL_NAMES = (
+ "VLN-11304-CTC-PM1",
+ "VLN-11303-CTC-PM1",
+)
+FACILITY_TOOL_IDS = (1, 2, 3, 4)
+
+
+def _load_credentials(settings_path: Path) -> tuple[str, str]:
+ try:
+ settings = json.loads(settings_path.read_text(encoding="utf-8"))
+ values = settings["Values"]
+ url = str(values["POSTGREST_URL"]).strip()
+ token = str(values["POSTGREST_TOKEN"]).strip()
+ except (OSError, KeyError, TypeError, json.JSONDecodeError) as exc:
+ raise RuntimeError(
+ f"Could not load POSTGREST_URL and POSTGREST_TOKEN from "
+ f"{settings_path}"
+ ) from exc
+ if not url or not token:
+ raise RuntimeError("GLANCE PostgREST credentials are empty")
+ return url.rstrip("/"), token
+
+
+def _settings(
+ postgrest_url: str,
+ postgrest_token: str,
+ *,
+ data_batch_size: int,
+) -> ConnectorSettings:
+ return ConnectorSettings(
+ postgrest_url=postgrest_url,
+ postgrest_token=postgrest_token,
+ ingestion_url="disabled://read-only-shadow-audit",
+ ingestion_token="disabled",
+ storage_connection="disabled",
+ cursor_container="disabled",
+ cursor_blob="disabled",
+ mappings=(),
+ overlap_run_count=0,
+ page_size=1000,
+ timeout=(10, 120),
+ data_batch_size=data_batch_size,
+ backfill_min_run_id=None,
+ backfill_max_run_id=None,
+ )
+
+
+def _canonical_sha256(payload: dict[str, Any]) -> str:
+ encoded = json.dumps(
+ payload,
+ sort_keys=True,
+ separators=(",", ":"),
+ ensure_ascii=False,
+ default=str,
+ ).encode("utf-8")
+ return hashlib.sha256(encoded).hexdigest()
+
+
+def _is_zero(value: Any) -> bool:
+ return isinstance(value, (int, float)) and not isinstance(value, bool) and value == 0
+
+
+def _count_rows(
+ client: PostgrestGlanceClient,
+ endpoint: str,
+ params: dict[str, str],
+) -> int:
+ headers = {
+ "Authorization": f"Bearer {client.settings.postgrest_token}",
+ "Accept": "application/json",
+ "Prefer": "count=exact",
+ }
+ request_params = dict(params)
+ request_params["limit"] = "1"
+ request_params["offset"] = "0"
+ response = client.session.get(
+ client.settings.postgrest_url.rstrip("/") + endpoint,
+ headers=headers,
+ params=request_params,
+ timeout=(10, 30),
+ )
+ response.raise_for_status()
+ content_range = response.headers.get("Content-Range", "")
+ if "/" not in content_range:
+ raise RuntimeError(
+ f"{endpoint} did not return a PostgREST Content-Range count"
+ )
+ total = content_range.rsplit("/", 1)[1]
+ if total == "*":
+ raise RuntimeError(f"{endpoint} returned an indeterminate row count")
+ return int(total)
+
+
+def _metadata_sha256(run: dict[str, Any]) -> str:
+ return _canonical_sha256({"run": run})
+
+
+def _probe_run_scoped_data(
+ client: PostgrestGlanceClient,
+ run_id: int,
+) -> dict[str, Any]:
+ try:
+ count = _count_rows(
+ client,
+ "/data",
+ {
+ "select": (
+ "idsamplerecord,idparameters,value,"
+ "samplerecord!inner(idruns)"
+ ),
+ "samplerecord.idruns": f"eq.{run_id}",
+ },
+ )
+ except Exception as exc:
+ status_code = getattr(getattr(exc, "response", None), "status_code", None)
+ return {
+ "supported": False,
+ "http_status": status_code,
+ "error_type": type(exc).__name__,
+ }
+ return {
+ "supported": True,
+ "value_count": count,
+ }
+
+
+def _audit_tool_summary(
+ client: PostgrestGlanceClient,
+ tool_name: str,
+) -> dict[str, Any]:
+ tool_rows = client.get_all(
+ "/tools",
+ {
+ "select": "idtools,name",
+ "name": f"eq.{tool_name}",
+ },
+ )
+ if len(tool_rows) != 1:
+ return {
+ "requested_tool_name": tool_name,
+ "status": "tool_resolution_failed",
+ "matching_tool_count": len(tool_rows),
+ }
+
+ source_tool_id = int(tool_rows[0]["idtools"])
+ run_rows = client.get_all(
+ "/runs",
+ {
+ "select": "*",
+ "idtools": f"eq.{source_tool_id}",
+ "order": "idruns.desc",
+ "limit": "1",
+ },
+ )
+ if not run_rows:
+ return {
+ "requested_tool_name": tool_name,
+ "source_tool_id": source_tool_id,
+ "status": "no_runs",
+ }
+
+ run = run_rows[0]
+ started = time.monotonic()
+ run_id = int(run["idruns"])
+ start_time = run.get("starttime")
+ end_time = run.get("endtime")
+ sample_count = _count_rows(
+ client,
+ "/samplerecord",
+ {
+ "select": "idsamplerecord",
+ "idruns": f"eq.{run_id}",
+ },
+ )
+ event_count = None
+ if start_time not in (None, "") and end_time not in (None, ""):
+ event_count = _count_rows(
+ client,
+ "/events",
+ {
+ "select": "idevents",
+ "tool": f"eq.{source_tool_id}",
+ "and": f"(time.gte.{start_time},time.lte.{end_time})",
+ },
+ )
+ recipe_id = run.get("idrecipes")
+ recipe_count = 0
+ if recipe_id not in (None, ""):
+ recipe_count = _count_rows(
+ client,
+ "/recipes",
+ {
+ "select": "idrecipes",
+ "idrecipes": f"eq.{int(recipe_id)}",
+ },
+ )
+ run_scoped_data = _probe_run_scoped_data(client, run_id)
+ return {
+ "requested_tool_name": tool_name,
+ "resolved_tool_name": tool_rows[0].get("name"),
+ "source_tool_id": source_tool_id,
+ "source_run_id": run_id,
+ "run_start_time": start_time,
+ "run_end_time": end_time,
+ "source_status": str(run.get("status") or ""),
+ "source_recipe_id": recipe_id,
+ "recipe_record_count": recipe_count,
+ "sample_count": sample_count,
+ "event_count": event_count,
+ "run_scoped_data_query": run_scoped_data,
+ "run_metadata_sha256": _metadata_sha256(run),
+ "audit_elapsed_seconds": round(time.monotonic() - started, 3),
+ "status": "verified_summary",
+ }
+
+
+def _audit_tool_complete(
+ client: PostgrestGlanceClient,
+ tool_name: str,
+) -> dict[str, Any]:
+ tool_rows = client.get_all(
+ "/tools",
+ {
+ "select": "idtools,name",
+ "name": f"eq.{tool_name}",
+ },
+ )
+ if len(tool_rows) != 1:
+ return {
+ "requested_tool_name": tool_name,
+ "status": "tool_resolution_failed",
+ "matching_tool_count": len(tool_rows),
+ }
+
+ source_tool_id = int(tool_rows[0]["idtools"])
+ run_rows = client.get_all(
+ "/runs",
+ {
+ "select": "*",
+ "idtools": f"eq.{source_tool_id}",
+ "order": "idruns.desc",
+ "limit": "1",
+ },
+ )
+ if not run_rows:
+ return {
+ "requested_tool_name": tool_name,
+ "source_tool_id": source_tool_id,
+ "status": "no_runs",
+ }
+
+ run = run_rows[0]
+ mapping = ToolMapping(
+ equipment_id="read-only-shadow-audit",
+ source_tool_id=source_tool_id,
+ source_tool_name=tool_name,
+ project_id="read-only-shadow-audit",
+ initial_run_id=int(run["idruns"]),
+ )
+ try:
+ payload = client.complete_run(run, mapping)
+ except Exception as exc:
+ return {
+ "requested_tool_name": tool_name,
+ "source_tool_id": source_tool_id,
+ "source_run_id": int(run["idruns"]),
+ "run_start_time": run.get("starttime"),
+ "run_end_time": run.get("endtime"),
+ "source_status": str(run.get("status") or ""),
+ "status": "trace_build_failed",
+ "error_type": type(exc).__name__,
+ "error": "Complete-trace PostgREST query failed",
+ }
+
+ values = [
+ value
+ for sample in payload["samples"]
+ for value in sample["values"].values()
+ ]
+ return {
+ "requested_tool_name": tool_name,
+ "resolved_tool_name": tool_rows[0].get("name"),
+ "source_tool_id": source_tool_id,
+ "source_run_id": payload["source_run_id"],
+ "run_start_time": payload.get("run_start_time"),
+ "run_end_time": payload.get("run_end_time"),
+ "source_status": payload.get("source_status"),
+ "source_recipe_id": payload["recipe"].get("source_recipe_id"),
+ "sample_count": len(payload["samples"]),
+ "value_count": len(values),
+ "null_value_count": sum(value is None for value in values),
+ "literal_zero_value_count": sum(_is_zero(value) for value in values),
+ "parameter_count": len(payload["parameters"]),
+ "event_count": len(payload["events"]),
+ "payload_sha256": _canonical_sha256(payload),
+ "status": "verified",
+ }
+
+
+def _inventory(client: PostgrestGlanceClient) -> dict[str, Any]:
+ tool_rows = client.get_all(
+ "/tools",
+ {
+ "select": "idtools,name",
+ "idtools": "in.(1,2,3,4)",
+ "order": "idtools.asc",
+ },
+ )
+ tool_name_by_id = {
+ int(row["idtools"]): str(row.get("name") or "")
+ for row in tool_rows
+ }
+ tools = []
+ for tool_id in FACILITY_TOOL_IDS:
+ run_count = _count_rows(
+ client,
+ "/runs",
+ {
+ "select": "idruns",
+ "idtools": f"eq.{tool_id}",
+ },
+ )
+ latest = client.get_all(
+ "/runs",
+ {
+ "select": (
+ "idruns,idtools,lotname,idrecipes,starttime,endtime,status"
+ ),
+ "idtools": f"eq.{tool_id}",
+ "order": "idruns.desc",
+ "limit": "1",
+ },
+ )
+ tools.append(
+ {
+ "source_tool_id": tool_id,
+ "source_tool_name": tool_name_by_id.get(tool_id),
+ "visible_run_count": run_count,
+ "latest_visible_run": latest[0] if latest else None,
+ }
+ )
+
+ pilot_params = {
+ "idtools": "eq.3",
+ "and": (
+ "(starttime.gte.2024-07-30T00:00:00,"
+ "starttime.lt.2024-08-16T00:00:00)"
+ ),
+ }
+ pilot_rows = client.get_all(
+ "/runs",
+ {
+ **pilot_params,
+ "select": (
+ "idruns,idtools,lotname,idrecipes,starttime,endtime,status"
+ ),
+ "order": "idruns.asc",
+ "limit": "100",
+ },
+ )
+ named_rows = {}
+ for label, pattern in (("cln", "*cln*"), ("FM-2", "*FM-2*")):
+ named_rows[label] = client.get_all(
+ "/runs",
+ {
+ "select": (
+ "idruns,idtools,lotname,idrecipes,starttime,endtime,status"
+ ),
+ "idtools": "eq.3",
+ "lotname": f"ilike.{pattern}",
+ "order": "idruns.desc",
+ "limit": "20",
+ },
+ )
+
+ return {
+ "mode": "read_only_glance_inventory",
+ "source_interface": "postgrest",
+ "checked_at": datetime.now(timezone.utc).isoformat(),
+ "digital_twin_handoff_performed": False,
+ "cursor_read_or_advanced": False,
+ "total_visible_run_count": _count_rows(
+ client,
+ "/runs",
+ {"select": "idruns"},
+ ),
+ "tools": tools,
+ "tool_3_pilot_window": {
+ "start_inclusive": "2024-07-30T00:00:00",
+ "end_exclusive": "2024-08-16T00:00:00",
+ "visible_run_count": _count_rows(
+ client,
+ "/runs",
+ {
+ "select": "idruns",
+ **pilot_params,
+ },
+ ),
+ "runs": pilot_rows,
+ },
+ "tool_3_null_recipe_count": _count_rows(
+ client,
+ "/runs",
+ {
+ "select": "idruns",
+ "idtools": "eq.3",
+ "idrecipes": "is.null",
+ },
+ ),
+ "tool_3_named_run_matches": named_rows,
+ }
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "--settings",
+ type=Path,
+ default=AZURE_ROOT / "local.settings.json",
+ help="Local Azure settings JSON containing GLANCE credentials.",
+ )
+ parser.add_argument(
+ "--tool",
+ action="append",
+ dest="tools",
+ help=(
+ "Exact GLANCE tool name. Repeat for multiple tools. Defaults to "
+ "the two approved Versaline tools."
+ ),
+ )
+ parser.add_argument(
+ "--complete-trace",
+ action="store_true",
+ help=(
+ "Fetch every sample and value and hash the complete in-memory "
+ "payload. Default mode uses bounded exact-count requests only."
+ ),
+ )
+ parser.add_argument(
+ "--data-batch-size",
+ type=int,
+ default=25,
+ help="Sample IDs per /data request in complete-trace mode (default: 25).",
+ )
+ parser.add_argument(
+ "--inventory",
+ action="store_true",
+ help=(
+ "Check visible run counts, latest runs, the proposed Tool 3 pilot "
+ "window, null recipes, and named recent-run matches."
+ ),
+ )
+ args = parser.parse_args()
+ if args.data_batch_size < 1:
+ parser.error("--data-batch-size must be at least 1")
+
+ postgrest_url, postgrest_token = _load_credentials(args.settings)
+ client = PostgrestGlanceClient(
+ _settings(
+ postgrest_url,
+ postgrest_token,
+ data_batch_size=args.data_batch_size,
+ )
+ )
+ if args.inventory:
+ print(json.dumps(_inventory(client), indent=2, sort_keys=True))
+ return 0
+
+ tool_names = tuple(args.tools or DEFAULT_TOOL_NAMES)
+ started_at = datetime.now(timezone.utc)
+ audit = _audit_tool_complete if args.complete_trace else _audit_tool_summary
+ results = []
+ for tool_name in tool_names:
+ print(f"Auditing exact tool {tool_name}...", file=sys.stderr, flush=True)
+ try:
+ results.append(audit(client, tool_name))
+ except Exception as exc:
+ results.append(
+ {
+ "requested_tool_name": tool_name,
+ "status": "audit_failed",
+ "error_type": type(exc).__name__,
+ "error": "Read-only PostgREST audit failed",
+ }
+ )
+ finished_at = datetime.now(timezone.utc)
+ expected_status = "verified" if args.complete_trace else "verified_summary"
+
+ report = {
+ "mode": "read_only_shadow_audit",
+ "scope": "complete_trace" if args.complete_trace else "bounded_summary",
+ "source_interface": "postgrest",
+ "started_at": started_at.isoformat(),
+ "finished_at": finished_at.isoformat(),
+ "digital_twin_handoff_performed": False,
+ "cursor_read_or_advanced": False,
+ "raw_payload_persisted": False,
+ "requested_tool_count": len(tool_names),
+ "verified_tool_count": sum(
+ result.get("status") == expected_status for result in results
+ ),
+ "results": results,
+ }
+ print(json.dumps(report, indent=2, sort_keys=True))
+ return 0 if report["verified_tool_count"] == len(tool_names) else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/azure/tests/test_common_config.py b/azure/tests/test_common_config.py
new file mode 100644
index 0000000..45b9e7a
--- /dev/null
+++ b/azure/tests/test_common_config.py
@@ -0,0 +1,83 @@
+"""Focused tests for shared PostgREST pagination."""
+
+from __future__ import annotations
+
+import sys
+import unittest
+from pathlib import Path
+
+
+AZURE_ROOT = Path(__file__).resolve().parents[1]
+if str(AZURE_ROOT) not in sys.path:
+ sys.path.insert(0, str(AZURE_ROOT))
+
+from common.config import api_get_all # noqa: E402
+
+
+class _Response:
+ def __init__(self, rows):
+ self._rows = rows
+
+ def raise_for_status(self):
+ return None
+
+ def json(self):
+ return self._rows
+
+
+class _CappedSession:
+ def __init__(self, rows, cap=2):
+ self.rows = rows
+ self.cap = cap
+ self.calls = []
+
+ def get(self, url, *, headers, params, timeout):
+ self.calls.append(dict(params))
+ offset = int(params["offset"])
+ limit = min(int(params["limit"]), self.cap)
+ return _Response(self.rows[offset : offset + limit])
+
+
+class PostgrestPaginationTests(unittest.TestCase):
+ def test_advances_by_rows_returned_when_server_caps_page_size(self):
+ session = _CappedSession([{"id": 1}, {"id": 2}, {"id": 3}])
+ params = {"select": "id", "order": "id.asc"}
+
+ rows = api_get_all(
+ session,
+ "https://example.invalid",
+ "/items",
+ params,
+ "token",
+ page_size=5,
+ )
+
+ self.assertEqual(rows, [{"id": 1}, {"id": 2}, {"id": 3}])
+ self.assertEqual(
+ [(call["offset"], call["limit"]) for call in session.calls],
+ [(0, 5), (2, 5), (3, 5)],
+ )
+ self.assertEqual(params, {"select": "id", "order": "id.asc"})
+
+ def test_respects_caller_limit_and_offset(self):
+ session = _CappedSession(
+ [{"id": 1}, {"id": 2}, {"id": 3}],
+ cap=10,
+ )
+
+ rows = api_get_all(
+ session,
+ "https://example.invalid",
+ "/items",
+ {"select": "id", "limit": "1", "offset": "1"},
+ "token",
+ page_size=5,
+ )
+
+ self.assertEqual(rows, [{"id": 2}])
+ self.assertEqual(session.calls[0]["limit"], 1)
+ self.assertEqual(session.calls[0]["offset"], 1)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/azure/tests/test_glance_connector.py b/azure/tests/test_glance_connector.py
new file mode 100644
index 0000000..54ac14d
--- /dev/null
+++ b/azure/tests/test_glance_connector.py
@@ -0,0 +1,735 @@
+"""Focused source-fidelity tests for the production GLANCE connector."""
+
+from __future__ import annotations
+
+import json
+import sys
+import tempfile
+import unittest
+from dataclasses import replace
+from datetime import datetime
+from decimal import Decimal
+from pathlib import Path
+from types import ModuleType, SimpleNamespace
+from unittest.mock import patch
+
+
+AZURE_ROOT = Path(__file__).resolve().parents[1]
+if str(AZURE_ROOT) not in sys.path:
+ sys.path.insert(0, str(AZURE_ROOT))
+
+from common.glance_connector import ( # noqa: E402
+ BlobCursorStore,
+ ConnectorSettings,
+ FileFailureAuditStore,
+ FileCursorStore,
+ GlancePoller,
+ PostgresGlanceClient,
+ PostgrestGlanceClient,
+ ToolMapping,
+ _json_safe,
+ _redacted_error,
+)
+
+
+def _settings() -> ConnectorSettings:
+ return ConnectorSettings(
+ postgrest_url="https://glance.invalid",
+ postgrest_token="read-token",
+ ingestion_url="disabled://test",
+ ingestion_token="disabled",
+ storage_connection="disabled",
+ cursor_container="disabled",
+ cursor_blob="disabled",
+ mappings=(),
+ overlap_run_count=0,
+ page_size=1000,
+ timeout=(1, 1),
+ data_batch_size=2,
+ backfill_min_run_id=None,
+ backfill_max_run_id=None,
+ )
+
+
+class _ResourceNotFoundError(Exception):
+ pass
+
+
+def _azure_core_modules() -> dict[str, ModuleType]:
+ azure_module = ModuleType("azure")
+ core_module = ModuleType("azure.core")
+ exception_module = ModuleType("azure.core.exceptions")
+ core_module.MatchConditions = SimpleNamespace(IfNotModified="if-not-modified")
+ exception_module.ResourceNotFoundError = _ResourceNotFoundError
+ azure_module.core = core_module
+ return {
+ "azure": azure_module,
+ "azure.core": core_module,
+ "azure.core.exceptions": exception_module,
+ }
+
+
+class _FixtureClient(PostgrestGlanceClient):
+ def __init__(self):
+ super().__init__(_settings())
+ self.data_queries = []
+
+ def get_all(self, endpoint, params):
+ if endpoint == "/samplerecord":
+ return [
+ {
+ "idsamplerecord": 20,
+ "idruns": 99,
+ "idtools": 3,
+ "time": "2026-07-01T00:00:02",
+ },
+ {
+ "idsamplerecord": 10,
+ "idruns": 99,
+ "idtools": 3,
+ "time": "2026-07-01T00:00:00",
+ },
+ {
+ "idsamplerecord": 11,
+ "idruns": 99,
+ "idtools": 3,
+ "time": "2026-07-01T00:00:01",
+ },
+ ]
+ if endpoint == "/data":
+ self.data_queries.append(dict(params))
+ if "idsamplerecord.gte.10" in params["and"]:
+ return [
+ {
+ "idsamplerecord": 10,
+ "idparameters": 7,
+ "value": 0,
+ },
+ {
+ "idsamplerecord": 11,
+ "idparameters": 7,
+ "value": None,
+ },
+ ]
+ return [
+ {
+ "idsamplerecord": 20,
+ "idparameters": 7,
+ "value": 1.5,
+ }
+ ]
+ if endpoint == "/parameters":
+ return [
+ {
+ "idparameters": 7,
+ "name": "P Chamber Pressure",
+ "unit": "mTorr",
+ }
+ ]
+ if endpoint == "/events":
+ return [
+ {
+ "idevents": 8,
+ "tool": 3,
+ "time": "2026-07-01T00:00:02",
+ "type": "Complete",
+ "description": "Process complete",
+ }
+ ]
+ if endpoint == "/eventtypes":
+ return [{"event": "Complete", "category": "Process Complete"}]
+ if endpoint == "/recipes":
+ return [{"idrecipes": 4, "recipename": "Recipe 4"}]
+ raise AssertionError(f"Unexpected endpoint: {endpoint}")
+
+
+class _SqlCursor:
+ def __init__(self):
+ self.executions = []
+ self.rows = []
+
+ def execute(self, query, parameters=()):
+ normalized = " ".join(query.split())
+ self.executions.append((normalized, parameters))
+ if "set_config('statement_timeout'" in normalized:
+ self.rows = [{"set_config": "1000"}]
+ elif "FROM public.runs" in normalized:
+ self.rows = [
+ {
+ "idruns": 99,
+ "idtools": 3,
+ "idrecipes": None,
+ "starttime": datetime(2026, 7, 1, 0, 0, 0),
+ "endtime": datetime(2026, 7, 1, 0, 0, 2),
+ "status": "processed",
+ }
+ ]
+ elif "FROM public.samplerecord" in normalized:
+ self.rows = [
+ {
+ "idsamplerecord": 10,
+ "idruns": 99,
+ "idtools": 3,
+ "time": datetime(2026, 7, 1, 0, 0, 0),
+ },
+ {
+ "idsamplerecord": 11,
+ "idruns": 99,
+ "idtools": 3,
+ "time": datetime(2026, 7, 1, 0, 0, 1),
+ },
+ ]
+ elif "FROM public.data AS d" in normalized:
+ self.rows = [
+ {
+ "idsamplerecord": 10,
+ "idparameters": 7,
+ "value": 0.0,
+ },
+ {
+ "idsamplerecord": 11,
+ "idparameters": 7,
+ "value": 1.5,
+ },
+ ]
+ elif "FROM public.parameters" in normalized:
+ self.rows = [
+ {
+ "idparameters": 7,
+ "name": "P Chamber Pressure",
+ "unit": "mTorr",
+ }
+ ]
+ elif "FROM public.events" in normalized:
+ self.rows = [
+ {
+ "idevents": 8,
+ "tool": 3,
+ "time": datetime(2026, 7, 1, 0, 0, 2),
+ "type": "Process Completed",
+ "description": "Process complete",
+ }
+ ]
+ elif "FROM public.eventtypes" in normalized:
+ self.rows = [{"event": "Process Completed", "category": 1}]
+ elif "FROM public.recipes" in normalized:
+ self.rows = []
+ else:
+ raise AssertionError(f"Unexpected SQL: {normalized}")
+
+ def fetchone(self):
+ return self.rows[0] if self.rows else None
+
+ def fetchall(self):
+ return list(self.rows)
+
+ def close(self):
+ return None
+
+
+class _SqlConnection:
+ def __init__(self):
+ self.cursor_instance = _SqlCursor()
+ self.session_options = None
+ self.rollback_count = 0
+ self.closed = False
+
+ def set_session(self, **options):
+ self.session_options = options
+
+ def cursor(self, **_kwargs):
+ return self.cursor_instance
+
+ def rollback(self):
+ self.rollback_count += 1
+
+ def close(self):
+ self.closed = True
+
+
+class _PollClient:
+ def affected_runs(self, _mapping, *, cursor):
+ self.cursor = cursor
+ return [
+ {"idruns": 1, "idtools": 3},
+ {"idruns": 5, "idtools": 3},
+ {"idruns": 7, "idtools": 3},
+ ]
+
+ def complete_run(self, run, _mapping):
+ return {"source_run_id": run["idruns"]}
+
+
+class _CursorStore:
+ def __init__(self):
+ self.cursors = {}
+ self.saved = []
+
+ def load(self):
+ return dict(self.cursors)
+
+ def save(self, cursors):
+ self.cursors = dict(cursors)
+ self.saved.append(dict(cursors))
+
+
+class _FailureAuditStore:
+ def __init__(self):
+ self.entries = []
+
+ def record(self, entry):
+ self.entries.append(dict(entry))
+
+
+class _HandoffResponse:
+ def __init__(self, *, acknowledged, status):
+ self.status_code = 200 if acknowledged else 207
+ self.text = ""
+ self._payload = {
+ "cursor_acknowledged": acknowledged,
+ "status": status,
+ "run_count": 1 if acknowledged else 0,
+ "rejected_count": 0 if acknowledged else 1,
+ }
+
+ def json(self):
+ return dict(self._payload)
+
+
+class _HandoffSession:
+ def __init__(self):
+ self.payloads = []
+
+ def post(self, _url, *, json, headers, timeout):
+ self.payloads.append(json)
+ acknowledged = len(self.payloads) == 1
+ return _HandoffResponse(
+ acknowledged=acknowledged,
+ status="success" if acknowledged else "partial",
+ )
+
+
+class GlanceConnectorTests(unittest.TestCase):
+ def test_postgres_file_configuration_does_not_require_postgrest_or_azure(self):
+ environment = {
+ "GLANCE_SOURCE_MODE": "postgres",
+ "GLANCE_DB_URI": "postgresql://readonly.invalid/logger",
+ "GLANCE_CURSOR_BACKEND": "file",
+ "GLANCE_CURSOR_FILE": "/state/cursors.json",
+ "GLANCE_INGESTION_API_URL": (
+ "https://dt.invalid/api/dataset/v2/glance/traces/sync"
+ ),
+ "INGESTION_TOKEN": "token",
+ "GLANCE_PRODUCTION_MAPPINGS_JSON": (
+ '{"equipment":{"source_tool_id":3,"source_tool_name":"Tool 3",'
+ '"project_id":"project","initial_run_id":1}}'
+ ),
+ "GLANCE_BACKFILL_RUN_IDS": "169,1,5,5",
+ }
+ with patch.dict("os.environ", environment, clear=True):
+ settings = ConnectorSettings.from_env()
+
+ self.assertEqual(settings.source_mode, "postgres")
+ self.assertEqual(settings.cursor_backend, "file")
+ self.assertEqual(settings.backfill_run_ids, (1, 5, 169))
+ self.assertEqual(settings.postgrest_url, "")
+ self.assertEqual(settings.storage_connection, "")
+
+ def test_complete_run_uses_disjoint_run_scoped_ranges_and_preserves_values(self):
+ client = _FixtureClient()
+ payload = client.complete_run(
+ {
+ "idruns": 99,
+ "idtools": 3,
+ "idrecipes": 4,
+ "starttime": "2026-07-01T00:00:00",
+ "endtime": "2026-07-01T00:00:02",
+ "status": "processed",
+ },
+ ToolMapping("equipment", 3, "Tool 3", "project", 99),
+ )
+
+ self.assertEqual(len(client.data_queries), 2)
+ self.assertEqual(
+ [query["and"] for query in client.data_queries],
+ [
+ "(idsamplerecord.gte.10,idsamplerecord.lte.11)",
+ "(idsamplerecord.gte.20,idsamplerecord.lte.20)",
+ ],
+ )
+ for query in client.data_queries:
+ self.assertEqual(query["samplerecord.idruns"], "eq.99")
+ self.assertIn("samplerecord!inner(idruns)", query["select"])
+ self.assertNotIn("idsamplerecord", {
+ key for key in query if key != "select"
+ })
+
+ values = {
+ sample["source_sample_id"]: sample["values"]["p7"]
+ for sample in payload["samples"]
+ }
+ self.assertEqual(values, {20: 1.5, 10: 0, 11: None})
+ self.assertEqual(payload["events"][0]["source_event_id"], 8)
+
+ def test_complete_run_rejects_source_run_above_sample_limit(self):
+ client = _FixtureClient()
+ client.settings = replace(client.settings, max_samples_per_run=2)
+
+ with self.assertRaisesRegex(
+ ValueError,
+ "sample count exceeds configured limit 2",
+ ):
+ client.complete_run(
+ {
+ "idruns": 99,
+ "idtools": 3,
+ "idrecipes": None,
+ "starttime": "2026-07-01T00:00:00",
+ "endtime": "2026-07-01T00:00:02",
+ "status": "processed",
+ },
+ ToolMapping("equipment", 3, "Tool 3", "project", 99),
+ )
+
+ def test_postgres_client_uses_read_only_parameterized_snapshot(self):
+ connections = []
+
+ def connect(*_args, **_kwargs):
+ connection = _SqlConnection()
+ connections.append(connection)
+ return connection
+
+ settings = replace(
+ _settings(),
+ source_mode="postgres",
+ db_uri="postgresql://read-only.invalid/logger",
+ backfill_run_ids=(1, 5, 99),
+ )
+ client = PostgresGlanceClient(
+ settings,
+ connection_factory=connect,
+ )
+ mapping = ToolMapping("equipment", 3, "Tool 3", "project", 1)
+
+ runs = client.affected_runs(mapping, cursor=1)
+ self.assertTrue(runs[0]["_missing_exact_run"])
+ self.assertEqual(runs[0]["idruns"], 5)
+ payload = client.complete_run(runs[-1], mapping)
+
+ self.assertEqual(len(connections), 2)
+ for connection in connections:
+ self.assertEqual(
+ connection.session_options,
+ {
+ "readonly": True,
+ "isolation_level": "REPEATABLE READ",
+ "autocommit": False,
+ },
+ )
+ self.assertGreaterEqual(connection.rollback_count, 1)
+ self.assertTrue(connection.closed)
+
+ runs_query, runs_parameters = next(
+ execution
+ for execution in connections[0].cursor_instance.executions
+ if "FROM public.runs" in execution[0]
+ )
+ self.assertIn("idruns = ANY(%s)", runs_query)
+ self.assertEqual(runs_parameters, (3, [5, 99], 20))
+ self.assertNotIn("99", runs_query)
+
+ self.assertIsNone(payload["recipe"]["source_recipe_id"])
+ self.assertEqual(
+ payload["samples"][0]["timestamp"],
+ "2026-07-01T00:00:00",
+ )
+ self.assertEqual(payload["samples"][0]["values"]["p7"], 0.0)
+ self.assertEqual(
+ payload["events"][0]["event_type"],
+ "Process Completed",
+ )
+ complete_run_queries = [
+ query
+ for query, _parameters in connections[1].cursor_instance.executions
+ if "FROM public.runs" in query
+ ]
+ self.assertEqual(len(complete_run_queries), 1)
+ self.assertIn("idruns = %s", complete_run_queries[0])
+ self.assertIn("idtools = %s", complete_run_queries[0])
+
+ def test_postgres_overlap_reads_actual_sparse_tool_rows(self):
+ class SparseCursor(_SqlCursor):
+ def execute(self, query, parameters=()):
+ normalized = " ".join(query.split())
+ self.executions.append((normalized, parameters))
+ if "set_config('statement_timeout'" in normalized:
+ self.rows = [{"set_config": "1000"}]
+ elif "ORDER BY idruns DESC" in normalized:
+ self.rows = [
+ {"idruns": 100, "idtools": 3},
+ {"idruns": 20, "idtools": 3},
+ ]
+ elif "ORDER BY idruns ASC" in normalized:
+ self.rows = [{"idruns": 150, "idtools": 3}]
+ else:
+ raise AssertionError(f"Unexpected SQL: {normalized}")
+
+ class SparseConnection(_SqlConnection):
+ def __init__(self):
+ super().__init__()
+ self.cursor_instance = SparseCursor()
+
+ connection = SparseConnection()
+ client = PostgresGlanceClient(
+ replace(
+ _settings(),
+ source_mode="postgres",
+ db_uri="postgresql://read-only.invalid/logger",
+ overlap_run_count=2,
+ max_runs_per_poll=5,
+ ),
+ connection_factory=lambda *_args, **_kwargs: connection,
+ )
+
+ rows = client.affected_runs(
+ ToolMapping("equipment", 3, "Tool 3", "project", 1),
+ cursor=100,
+ )
+
+ self.assertEqual(
+ [row["idruns"] for row in rows],
+ [20, 100, 150],
+ )
+ overlap_query, overlap_parameters = next(
+ execution
+ for execution in connection.cursor_instance.executions
+ if "ORDER BY idruns DESC" in execution[0]
+ )
+ self.assertNotIn("idruns -", overlap_query)
+ self.assertEqual(overlap_parameters, (3, 1, 100, 2))
+
+ def test_blob_cursor_does_not_treat_access_failure_as_empty_state(self):
+ class Blob:
+ def download_blob(self):
+ raise RuntimeError("authorization failed")
+
+ store = BlobCursorStore.__new__(BlobCursorStore)
+ store._blob = Blob()
+ store._etag = None
+
+ with (
+ patch.dict(sys.modules, _azure_core_modules()),
+ self.assertRaisesRegex(RuntimeError, "authorization failed"),
+ ):
+ store.load()
+
+ def test_blob_cursor_rejects_corrupt_or_non_integer_state(self):
+ class Download:
+ properties = SimpleNamespace(etag="etag-1")
+
+ def __init__(self, payload):
+ self.payload = payload
+
+ def readall(self):
+ return self.payload
+
+ class Blob:
+ def __init__(self, payload):
+ self.payload = payload
+
+ def download_blob(self):
+ return Download(self.payload)
+
+ store = BlobCursorStore.__new__(BlobCursorStore)
+ store._blob = Blob(b"{not-json")
+ store._etag = None
+ with (
+ patch.dict(sys.modules, _azure_core_modules()),
+ self.assertRaisesRegex(
+ RuntimeError,
+ "cursor blob is not valid JSON",
+ ),
+ ):
+ store.load()
+
+ store._blob = Blob(b'{"equipment:3":"not-an-integer"}')
+ with (
+ patch.dict(sys.modules, _azure_core_modules()),
+ self.assertRaisesRegex(
+ RuntimeError,
+ "non-integer cursor",
+ ),
+ ):
+ store.load()
+
+ def test_blob_cursor_uses_etag_for_conditional_update(self):
+ class Blob:
+ def __init__(self):
+ self.calls = []
+
+ def upload_blob(self, payload, **kwargs):
+ self.calls.append((payload, kwargs))
+ return {"etag": "etag-2"}
+
+ blob = Blob()
+ store = BlobCursorStore.__new__(BlobCursorStore)
+ store._blob = blob
+ store._etag = "etag-1"
+
+ with patch.dict(sys.modules, _azure_core_modules()):
+ store.save({"equipment:3": 5})
+
+ self.assertEqual(blob.calls[0][1]["etag"], "etag-1")
+ self.assertTrue(blob.calls[0][1]["overwrite"])
+ self.assertEqual(store._etag, "etag-2")
+
+ def test_json_safe_preserves_decimal_and_non_finite_values_explicitly(self):
+ self.assertEqual(
+ _json_safe(
+ {
+ "decimal": Decimal("1.2300"),
+ "nan": float("nan"),
+ "positive": float("inf"),
+ "negative": float("-inf"),
+ }
+ ),
+ {
+ "decimal": "1.2300",
+ "nan": "NaN",
+ "positive": "Infinity",
+ "negative": "-Infinity",
+ },
+ )
+
+ def test_file_cursor_store_round_trip(self):
+ with tempfile.TemporaryDirectory() as temporary_directory:
+ cursor_path = Path(temporary_directory) / "state" / "cursors.json"
+ store = FileCursorStore(
+ replace(
+ _settings(),
+ cursor_backend="file",
+ cursor_file=str(cursor_path),
+ )
+ )
+
+ self.assertEqual(store.load(), {})
+ store.save({"equipment:3": 169})
+
+ self.assertEqual(store.load(), {"equipment:3": 169})
+ self.assertEqual(cursor_path.stat().st_mode & 0o777, 0o600)
+
+ def test_file_failure_audit_is_append_only_and_private(self):
+ with tempfile.TemporaryDirectory() as temporary_directory:
+ audit_path = Path(temporary_directory) / "state" / "failures.jsonl"
+ store = FileFailureAuditStore(
+ replace(
+ _settings(),
+ cursor_backend="file",
+ failure_audit_file=str(audit_path),
+ )
+ )
+
+ store.record({"source_run_id": 5, "error": "first"})
+ store.record({"source_run_id": 7, "error": "second"})
+
+ entries = [
+ json.loads(line)
+ for line in audit_path.read_text(encoding="utf-8").splitlines()
+ ]
+ self.assertEqual(
+ [entry["source_run_id"] for entry in entries],
+ [5, 7],
+ )
+ self.assertEqual(audit_path.stat().st_mode & 0o777, 0o600)
+
+ def test_poller_hands_off_small_batches_and_stops_on_unacknowledged_cursor(self):
+ client = _PollClient()
+ cursor_store = _CursorStore()
+ handoff_session = _HandoffSession()
+ poller = GlancePoller(
+ replace(
+ _settings(),
+ handoff_run_batch_size=1,
+ max_runs_per_poll=20,
+ ),
+ client=client,
+ cursor_store=cursor_store,
+ failure_audit_store=_FailureAuditStore(),
+ handoff_session=handoff_session,
+ )
+
+ result = poller.poll_mapping(
+ ToolMapping("equipment", 3, "Tool 3", "project", 1),
+ {},
+ )
+
+ self.assertEqual(client.cursor, 0)
+ self.assertEqual(len(handoff_session.payloads), 3)
+ self.assertEqual(
+ [
+ (
+ payload["previous_cursor"],
+ payload["proposed_cursor"],
+ payload["runs"][0]["source_run_id"],
+ )
+ for payload in handoff_session.payloads
+ ],
+ [("0", "1", 1), ("1", "5", 5), ("1", "1", 7)],
+ )
+ self.assertEqual(cursor_store.saved, [{"equipment:3": 1}])
+ self.assertFalse(result["cursor_acknowledged"])
+ self.assertEqual(result["cursor"], 1)
+ self.assertEqual(result["proposed_cursor"], 7)
+
+ def test_poller_redacts_and_durably_audits_local_run_failure(self):
+ class Client(_PollClient):
+ def complete_run(self, run, mapping):
+ if run["idruns"] == 5:
+ raise RuntimeError(
+ "postgresql://nanohubdt:secret@glance/logger "
+ "password=also-secret"
+ )
+ return super().complete_run(run, mapping)
+
+ failure_store = _FailureAuditStore()
+ poller = GlancePoller(
+ replace(
+ _settings(),
+ handoff_run_batch_size=1,
+ max_runs_per_poll=20,
+ ),
+ client=Client(),
+ cursor_store=_CursorStore(),
+ failure_audit_store=failure_store,
+ handoff_session=_HandoffSession(),
+ )
+
+ result = poller.poll_mapping(
+ ToolMapping("equipment", 3, "Tool 3", "project", 1),
+ {},
+ )
+
+ self.assertEqual(len(failure_store.entries), 1)
+ self.assertEqual(failure_store.entries[0]["source_run_id"], 5)
+ serialized = json.dumps(
+ {"audit": failure_store.entries, "result": result}
+ )
+ self.assertNotIn("secret", serialized)
+ self.assertIn("[redacted]", serialized)
+ self.assertEqual(result["status"], "partial")
+
+ def test_redacts_database_credentials(self):
+ redacted = _redacted_error(
+ RuntimeError(
+ "postgresql://reader:topsecret@source/logger "
+ "password=anothersecret"
+ )
+ )
+
+ self.assertNotIn("topsecret", redacted)
+ self.assertNotIn("anothersecret", redacted)
+ self.assertIn("[redacted]", redacted)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/azure/tests/test_glance_mirror.py b/azure/tests/test_glance_mirror.py
new file mode 100644
index 0000000..41c8c09
--- /dev/null
+++ b/azure/tests/test_glance_mirror.py
@@ -0,0 +1,499 @@
+"""Focused safety tests for the bounded GLANCE mirror."""
+
+from __future__ import annotations
+
+import sys
+import unittest
+from dataclasses import replace
+from datetime import datetime
+from pathlib import Path
+from unittest.mock import patch
+
+
+AZURE_ROOT = Path(__file__).resolve().parents[1]
+if str(AZURE_ROOT) not in sys.path:
+ sys.path.insert(0, str(AZURE_ROOT))
+
+from common.glance_mirror import ( # noqa: E402
+ GlanceMirrorCoordinator,
+ MirrorConfigurationError,
+ MirrorSettings,
+ PsycopgMirrorDestination,
+ PsycopgMirrorSource,
+ RunSnapshot,
+ _redacted_error,
+ _sync_state_context,
+)
+
+
+def _settings() -> MirrorSettings:
+ return MirrorSettings(
+ source_db_uri="postgresql://source.invalid/logger",
+ destination_db_uri="postgresql://destination.invalid/glance_mirror",
+ tool_ids=(3,),
+ initial_run_id=1,
+ overlap_run_ids=2,
+ max_runs_per_tool=5,
+ max_samples_per_run=100,
+ max_values_per_run=1000,
+ insert_batch_size=2,
+ connect_timeout_seconds=1,
+ statement_timeout_seconds=1,
+ source_retries=0,
+ backfill_min_run_id=None,
+ backfill_max_run_id=None,
+ backfill_run_ids=(),
+ )
+
+
+def _snapshot(run_id: int) -> RunSnapshot:
+ return RunSnapshot(
+ run={
+ "idruns": run_id,
+ "idtools": 3,
+ "lotname": f"run-{run_id}",
+ "idrecipes": None,
+ "starttime": datetime(2026, 7, 1, 0, 0, 0),
+ "endtime": datetime(2026, 7, 1, 0, 0, 1),
+ "status": "processed",
+ "materialname": None,
+ },
+ tool={"idtools": 3, "name": "Tool 3", "description": "DSE"},
+ recipe=None,
+ samples=(
+ {
+ "idsamplerecord": run_id * 10,
+ "idruns": run_id,
+ "idtools": 3,
+ "time": datetime(2026, 7, 1, 0, 0, 0),
+ },
+ ),
+ data=(
+ {
+ "idsamplerecord": run_id * 10,
+ "idparameters": 7,
+ "value": 0.0,
+ },
+ ),
+ parameters=(
+ {"idparameters": 7, "name": "Pressure", "unit": "mTorr"},
+ ),
+ events=(),
+ event_types=(),
+ )
+
+
+class _CoordinatorSource:
+ def __init__(self, run_ids):
+ self.run_ids = run_ids
+ self.list_calls = []
+ self.load_calls = []
+
+ def list_runs(self, tool_id, cursor):
+ self.list_calls.append((tool_id, cursor))
+ return [{"idruns": run_id, "idtools": tool_id} for run_id in self.run_ids]
+
+ def load_run(self, run):
+ self.load_calls.append(run["idruns"])
+ return _snapshot(run["idruns"])
+
+
+class _CoordinatorDestination:
+ def __init__(self, *, fail_on=None):
+ self.fail_on = fail_on
+ self.replaced = []
+ self.failures = []
+
+ def get_cursor(self, _tool_id, state_key, default):
+ self.state_key = state_key
+ return default
+
+ def replace_run(
+ self,
+ snapshot,
+ previous_cursor,
+ state_key,
+ *,
+ advance_cursor,
+ ):
+ if snapshot.run_id == self.fail_on:
+ raise RuntimeError("password=secret source failed")
+ self.replaced.append(snapshot.run_id)
+ return {
+ "run_id": snapshot.run_id,
+ "state_key": state_key,
+ "cursor": (
+ max(previous_cursor, snapshot.run_id)
+ if advance_cursor
+ else previous_cursor
+ ),
+ }
+
+ def record_failure(self, **record):
+ self.failures.append(record)
+
+
+class _SourceCursor:
+ def __init__(self):
+ self.executions = []
+ self.rows = []
+
+ def execute(self, query, parameters=()):
+ normalized = " ".join(query.split())
+ self.executions.append((normalized, parameters))
+ if "set_config('statement_timeout'" in normalized:
+ self.rows = [{"set_config": "1000"}]
+ elif "ORDER BY idruns DESC" in normalized:
+ self.rows = [{"idruns": 10, "idtools": 3}]
+ elif "FROM public.runs" in normalized:
+ self.rows = [{"idruns": 20, "idtools": 3}]
+ else:
+ raise AssertionError(f"Unexpected SQL: {normalized}")
+
+ def fetchone(self):
+ return self.rows[0] if self.rows else None
+
+ def fetchall(self):
+ return list(self.rows)
+
+ def close(self):
+ return None
+
+
+class _SourceConnection:
+ def __init__(self):
+ self.cursor_instance = _SourceCursor()
+
+ def set_session(self, **_options):
+ return None
+
+ def cursor(self, **_kwargs):
+ return self.cursor_instance
+
+ def rollback(self):
+ return None
+
+ def close(self):
+ return None
+
+
+class _DestinationCursor:
+ def __init__(self):
+ self.executions = []
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *_args):
+ return False
+
+ def execute(self, query, parameters=()):
+ self.executions.append((" ".join(query.split()), parameters))
+
+ def fetchone(self):
+ return None
+
+ def fetchall(self):
+ return []
+
+
+class _DestinationConnection:
+ def __init__(self):
+ self.autocommit = None
+ self.cursor_instance = _DestinationCursor()
+ self.committed = False
+ self.rolled_back = False
+ self.closed = False
+
+ def cursor(self):
+ return self.cursor_instance
+
+ def commit(self):
+ self.committed = True
+
+ def rollback(self):
+ self.rolled_back = True
+
+ def close(self):
+ self.closed = True
+
+
+class GlanceMirrorTests(unittest.TestCase):
+ def test_coordinator_advances_only_after_each_committed_run(self):
+ source = _CoordinatorSource([1, 5])
+ destination = _CoordinatorDestination()
+ coordinator = GlanceMirrorCoordinator(
+ _settings(),
+ source=source,
+ destination=destination,
+ )
+
+ result = coordinator.sync_tool(3)
+
+ self.assertEqual(source.list_calls, [(3, 0)])
+ self.assertEqual(destination.replaced, [1, 5])
+ self.assertEqual(result["cursor"], 5)
+ self.assertEqual(result["run_count"], 2)
+ self.assertEqual(result["status"], "success")
+ self.assertEqual(result["state_key"], "live")
+
+ def test_coordinator_copies_later_run_without_advancing_after_failure(self):
+ source = _CoordinatorSource([1, 5, 7])
+ destination = _CoordinatorDestination(fail_on=5)
+ coordinator = GlanceMirrorCoordinator(
+ _settings(),
+ source=source,
+ destination=destination,
+ )
+
+ result = coordinator.sync_tool(3)
+
+ self.assertEqual(source.load_calls, [1, 5, 7])
+ self.assertEqual(destination.replaced, [1, 7])
+ self.assertEqual(result["cursor"], 1)
+ self.assertEqual(result["failed_run_id"], 5)
+ self.assertEqual(result["status"], "partial_failure")
+ self.assertEqual(destination.failures[0]["previous_cursor"], 1)
+ self.assertEqual(destination.failures[0]["state_key"], "live")
+ self.assertNotIn("secret", result["error"])
+
+ def test_explicit_backfill_has_separate_state_and_initial_cursor(self):
+ settings = replace(
+ _settings(),
+ backfill_run_ids=(29, 32, 73),
+ )
+ state_key, default = _sync_state_context(settings)
+
+ self.assertTrue(state_key.startswith("backfill:ids:"))
+ self.assertEqual(default, 28)
+ self.assertNotEqual(state_key, "live")
+
+ def test_bounded_backfill_has_separate_state_and_initial_cursor(self):
+ settings = replace(
+ _settings(),
+ backfill_min_run_id=100,
+ backfill_max_run_id=200,
+ )
+
+ self.assertEqual(
+ _sync_state_context(settings),
+ ("backfill:range:100:200", 99),
+ )
+
+ def test_historical_backfill_range_overrides_live_initial_cursor(self):
+ settings = replace(
+ _settings(),
+ initial_run_id=8000,
+ backfill_min_run_id=100,
+ backfill_max_run_id=200,
+ )
+
+ self.assertEqual(
+ _sync_state_context(settings),
+ ("backfill:range:100:200", 99),
+ )
+
+ connection = _SourceConnection()
+ source = PsycopgMirrorSource(
+ settings,
+ connection_factory=lambda *_args, **_kwargs: connection,
+ )
+ source.list_runs(3, 99)
+ range_parameters = next(
+ parameters
+ for query, parameters in connection.cursor_instance.executions
+ if "idruns <= %s" in query and "ORDER BY idruns ASC" in query
+ )
+ self.assertEqual(range_parameters, (3, 100, 99, 200, 5))
+
+ def test_configuration_rejects_allowlist_larger_than_run_cap(self):
+ environment = {
+ "GLANCE_DB_URI": "postgresql://source.invalid/logger",
+ "GLANCE_MIRROR_DB_URI": (
+ "postgresql://destination.invalid/glance_mirror"
+ ),
+ "GLANCE_MIRROR_TOOL_IDS": "3",
+ "GLANCE_MIRROR_MAX_RUNS_PER_TOOL": "2",
+ "GLANCE_MIRROR_BACKFILL_RUN_IDS": "1,5,7",
+ }
+
+ with (
+ patch.dict("os.environ", environment, clear=True),
+ self.assertRaisesRegex(
+ MirrorConfigurationError,
+ "contains more IDs",
+ ),
+ ):
+ MirrorSettings.from_env()
+
+ def test_source_fetches_overlap_and_new_runs_without_stalling(self):
+ connection = _SourceConnection()
+ source = PsycopgMirrorSource(
+ _settings(),
+ connection_factory=lambda *_args, **_kwargs: connection,
+ )
+
+ rows = source.list_runs(3, 10)
+
+ self.assertEqual([row["idruns"] for row in rows], [10, 20])
+ overlap_query = next(
+ execution
+ for execution in connection.cursor_instance.executions
+ if "ORDER BY idruns DESC" in execution[0]
+ )
+ new_query = next(
+ execution
+ for execution in connection.cursor_instance.executions
+ if "idruns > %s" in execution[0]
+ and "ORDER BY idruns ASC" in execution[0]
+ )
+ self.assertEqual(overlap_query[1], (3, 1, 10, 2))
+ self.assertEqual(new_query[1], (3, 1, 10, 5))
+ self.assertNotIn("10", new_query[0])
+
+ def test_exact_backfill_returns_retryable_placeholder_for_missing_run(self):
+ connection = _SourceConnection()
+ source = PsycopgMirrorSource(
+ replace(_settings(), backfill_run_ids=(1, 5)),
+ connection_factory=lambda *_args, **_kwargs: connection,
+ )
+
+ rows = source.list_runs(3, 0)
+
+ self.assertEqual([row["idruns"] for row in rows], [1, 5])
+ self.assertTrue(all(row["_missing_exact_run"] for row in rows))
+
+ def test_exact_backfill_queries_only_current_bounded_id_slice(self):
+ connection = _SourceConnection()
+ source = PsycopgMirrorSource(
+ replace(
+ _settings(),
+ backfill_run_ids=(1, 5, 7),
+ max_runs_per_tool=2,
+ ),
+ connection_factory=lambda *_args, **_kwargs: connection,
+ )
+
+ source.list_runs(3, 0)
+
+ query_parameters = next(
+ parameters
+ for query, parameters in connection.cursor_instance.executions
+ if "idruns = ANY(%s)" in query
+ )
+ self.assertEqual(query_parameters, (3, [1, 5], 0, 2))
+
+ def test_destination_commits_state_and_audit_with_run(self):
+ connection = _DestinationConnection()
+ inserted_batches = []
+
+ def execute_values(_cursor, query, rows, page_size):
+ inserted_batches.append(
+ (" ".join(query.split()), list(rows), page_size)
+ )
+
+ destination = PsycopgMirrorDestination(
+ _settings(),
+ connection_factory=lambda *_args, **_kwargs: connection,
+ execute_values_function=execute_values,
+ )
+ snapshot = replace(
+ _snapshot(5),
+ events=(
+ {
+ "idevents": 50,
+ "time": datetime(2026, 7, 1, 0, 0, 0),
+ "tool": 3,
+ "type": "Process Start",
+ "description": "start",
+ },
+ ),
+ )
+
+ result = destination.replace_run(
+ snapshot,
+ previous_cursor=1,
+ state_key="live",
+ advance_cursor=True,
+ )
+
+ self.assertTrue(connection.committed)
+ self.assertFalse(connection.rolled_back)
+ self.assertTrue(connection.closed)
+ self.assertEqual(result["cursor"], 5)
+ self.assertEqual(result["sample_count"], 1)
+ self.assertEqual(result["value_count"], 1)
+ executed_sql = [
+ query for query, _params in connection.cursor_instance.executions
+ ]
+ self.assertTrue(
+ any("INSERT INTO glance_mirror.sync_state" in query for query in executed_sql)
+ )
+ self.assertTrue(
+ any("INSERT INTO glance_mirror.sync_audit" in query for query in executed_sql)
+ )
+ self.assertTrue(
+ any("INSERT INTO glance_mirror.data" in query for query, *_ in inserted_batches)
+ )
+ self.assertTrue(
+ any(
+ "INSERT INTO glance_mirror.run_events" in query
+ for query, *_ in inserted_batches
+ )
+ )
+
+ def test_destination_can_copy_without_advancing_blocked_cursor(self):
+ connection = _DestinationConnection()
+ destination = PsycopgMirrorDestination(
+ _settings(),
+ connection_factory=lambda *_args, **_kwargs: connection,
+ execute_values_function=lambda *_args, **_kwargs: None,
+ )
+
+ result = destination.replace_run(
+ _snapshot(7),
+ previous_cursor=1,
+ state_key="live",
+ advance_cursor=False,
+ )
+
+ self.assertEqual(result["cursor"], 1)
+ state_parameters = next(
+ parameters
+ for query, parameters in connection.cursor_instance.executions
+ if "INSERT INTO glance_mirror.sync_state" in query
+ )
+ self.assertEqual(state_parameters[0:3], (3, "live", 1))
+
+ def test_bounded_rows_rejects_oversized_source_run(self):
+ class Cursor:
+ def __init__(self):
+ self.calls = 0
+
+ def fetchmany(self, _size):
+ self.calls += 1
+ if self.calls == 1:
+ return [{"id": 1}, {"id": 2}]
+ return []
+
+ with self.assertRaisesRegex(ValueError, "configured per-run limit 1"):
+ PsycopgMirrorSource._bounded_rows(
+ Cursor(),
+ maximum=1,
+ label="sample",
+ )
+
+ def test_redacts_database_credentials(self):
+ error = RuntimeError(
+ "could not use postgresql://reader:topsecret@source/logger "
+ "password=anothersecret"
+ )
+ redacted = _redacted_error(error)
+
+ self.assertNotIn("topsecret", redacted)
+ self.assertNotIn("anothersecret", redacted)
+ self.assertIn("[redacted]", redacted)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/geddes/k8s/01-secrets.yaml.example b/geddes/k8s/01-secrets.yaml.example
index 89f4b45..a016aaa 100644
--- a/geddes/k8s/01-secrets.yaml.example
+++ b/geddes/k8s/01-secrets.yaml.example
@@ -49,6 +49,9 @@ stringData:
# Shared secret between Azure Function and Geddes API
# Generate with: openssl rand -hex 32
INGESTION_TOKEN: "REPLACE_WITH_SHARED_INGESTION_TOKEN"
+ # Must exactly match the Azure connector mapping. Do not reuse the temporary
+ # DB3 pilot project IDs unless those projects are explicitly promoted.
+ GLANCE_PRODUCTION_MAPPINGS_JSON: '{"REPLACE_EQUIPMENT_ID":{"source_tool_id":0,"source_tool_name":"REPLACE_TOOL_NAME","project_id":"REPLACE_PRODUCTION_PROJECT_ID"}}'
# Shared secret for trusted requests coming from the web app
DT_SYSTEM_TOKEN: "REPLACE_WITH_SHARED_SYSTEM_TOKEN"
diff --git a/geddes/k8s/02-api.yaml b/geddes/k8s/02-api.yaml
index d301e5d..3eba378 100644
--- a/geddes/k8s/02-api.yaml
+++ b/geddes/k8s/02-api.yaml
@@ -36,6 +36,14 @@ spec:
value: "random_forest"
- name: ML_PROPOSAL_CANDIDATES
value: "1024"
+ - name: ML_SHADOW_EVALUATION_ENABLED
+ value: "false"
+ - name: GLANCE_PRODUCTION_MAPPINGS_JSON
+ valueFrom:
+ secretKeyRef:
+ name: dt-api-secret
+ key: GLANCE_PRODUCTION_MAPPINGS_JSON
+ optional: true
envFrom:
- secretRef:
name: dt-api-secret
diff --git a/geddes/k8s/05-postgres.yaml b/geddes/k8s/05-postgres.yaml
index 8a80970..45a928d 100644
--- a/geddes/k8s/05-postgres.yaml
+++ b/geddes/k8s/05-postgres.yaml
@@ -136,6 +136,11 @@ data:
row_index INT,
upload_filename VARCHAR(255) DEFAULT '',
source VARCHAR(100) DEFAULT 'data_upload',
+ source_system VARCHAR(100) NOT NULL DEFAULT 'data_upload',
+ source_tool_id BIGINT,
+ source_run_id BIGINT,
+ source_updated_at TIMESTAMP WITH TIME ZONE,
+ ingested_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
@@ -148,6 +153,11 @@ data:
CREATE UNIQUE INDEX IF NOT EXISTS uq_equipment_runs_upload_row
ON equipment_runs(upload_id, row_index)
WHERE upload_id IS NOT NULL;
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_equipment_runs_source_identity
+ ON equipment_runs(
+ source_system, equipment_id, source_tool_id, source_run_id
+ )
+ WHERE source_tool_id IS NOT NULL AND source_run_id IS NOT NULL;
ALTER TABLE equipment_runs ENABLE ROW LEVEL SECURITY;
GRANT SELECT ON equipment_runs TO api_client;
@@ -178,8 +188,8 @@ data:
sample_index INT NOT NULL,
source_sample_id BIGINT NOT NULL,
source_tool_id BIGINT,
- sample_time TIMESTAMP WITHOUT TIME ZONE NOT NULL,
- sample_time_raw TEXT NOT NULL,
+ sample_time TIMESTAMP WITHOUT TIME ZONE,
+ sample_time_raw TEXT,
values_json JSONB NOT NULL DEFAULT '{}'::jsonb,
PRIMARY KEY (equipment_run_id, sample_index),
UNIQUE (equipment_run_id, source_sample_id),
@@ -189,6 +199,11 @@ data:
CREATE INDEX IF NOT EXISTS idx_equipment_run_trace_samples_time
ON equipment_run_trace_samples(equipment_run_id, sample_time, sample_index);
+ ALTER TABLE equipment_run_trace_samples
+ ALTER COLUMN sample_time DROP NOT NULL;
+ ALTER TABLE equipment_run_trace_samples
+ ALTER COLUMN sample_time_raw DROP NOT NULL;
+
ALTER TABLE equipment_run_trace_samples ENABLE ROW LEVEL SECURITY;
GRANT SELECT ON equipment_run_trace_samples TO api_client;
@@ -213,6 +228,77 @@ data:
)
);
+ CREATE TABLE IF NOT EXISTS equipment_run_trace_events (
+ equipment_run_id BIGINT NOT NULL
+ REFERENCES equipment_runs(id) ON DELETE CASCADE,
+ source_event_id BIGINT NOT NULL,
+ source_tool_id BIGINT,
+ event_time TIMESTAMP WITHOUT TIME ZONE NOT NULL,
+ event_time_raw TEXT NOT NULL,
+ event_type TEXT NOT NULL DEFAULT '',
+ category TEXT NOT NULL DEFAULT '',
+ description TEXT NOT NULL DEFAULT '',
+ raw_event_json JSONB NOT NULL DEFAULT '{}'::jsonb,
+ PRIMARY KEY (equipment_run_id, source_event_id),
+ CHECK (jsonb_typeof(raw_event_json) = 'object')
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_equipment_run_trace_events_time
+ ON equipment_run_trace_events(
+ equipment_run_id, event_time, source_event_id
+ );
+
+ ALTER TABLE equipment_run_trace_events ENABLE ROW LEVEL SECURITY;
+ GRANT SELECT ON equipment_run_trace_events TO api_client;
+
+ CREATE POLICY equipment_run_trace_event_visibility_policy
+ ON equipment_run_trace_events
+ FOR SELECT
+ USING (
+ EXISTS (
+ SELECT 1
+ FROM equipment_runs r
+ JOIN projects p ON p.id = r.project_id
+ WHERE r.id = equipment_run_trace_events.equipment_run_id
+ AND (
+ p.access_mode = 'open'
+ OR EXISTS (
+ SELECT 1
+ FROM project_members pm
+ WHERE pm.project_id = p.id
+ AND pm.nanohub_user_id =
+ current_setting('app.current_user', true)
+ )
+ )
+ )
+ );
+
+ CREATE TABLE IF NOT EXISTS glance_ingestion_audit (
+ id BIGSERIAL PRIMARY KEY,
+ batch_id VARCHAR(255) NOT NULL,
+ source_system VARCHAR(100) NOT NULL DEFAULT 'glance',
+ equipment_id VARCHAR(255) NOT NULL,
+ source_tool_id BIGINT NOT NULL,
+ project_id VARCHAR(50),
+ previous_cursor TEXT,
+ proposed_cursor TEXT,
+ poll_started_at TIMESTAMP WITH TIME ZONE,
+ poll_completed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+ status VARCHAR(32) NOT NULL,
+ run_count INT NOT NULL DEFAULT 0,
+ sample_count INT NOT NULL DEFAULT 0,
+ event_count INT NOT NULL DEFAULT 0,
+ warning_count INT NOT NULL DEFAULT 0,
+ payload_sha256 VARCHAR(64),
+ error_text TEXT,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_glance_ingestion_audit_tool_time
+ ON glance_ingestion_audit(
+ equipment_id, source_tool_id, created_at DESC
+ );
+
CREATE TABLE IF NOT EXISTS users (
id VARCHAR(255) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
diff --git a/geddes/k8s/glance-ingestion-cronjob.yaml b/geddes/k8s/glance-ingestion-cronjob.yaml
new file mode 100644
index 0000000..351960d
--- /dev/null
+++ b/geddes/k8s/glance-ingestion-cronjob.yaml
@@ -0,0 +1,111 @@
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: dt-glance-ingestion-state
+ namespace: ncn-digitaltwins-zchen
+spec:
+ accessModes:
+ - ReadWriteOnce
+ storageClassName: geddes-standard-singlenode
+ resources:
+ requests:
+ storage: 1Gi
+---
+apiVersion: batch/v1
+kind: CronJob
+metadata:
+ name: dt-glance-ingestion
+ namespace: ncn-digitaltwins-zchen
+spec:
+ # Repository resources are deliberately inactive. Replace the image digest,
+ # review the equipment/project mapping, and approve the bounded pilot before
+ # changing this value.
+ suspend: true
+ schedule: "*/10 * * * *"
+ concurrencyPolicy: Forbid
+ startingDeadlineSeconds: 300
+ successfulJobsHistoryLimit: 3
+ failedJobsHistoryLimit: 5
+ jobTemplate:
+ spec:
+ backoffLimit: 2
+ activeDeadlineSeconds: 540
+ template:
+ metadata:
+ labels:
+ app: dt-glance-ingestion
+ spec:
+ restartPolicy: Never
+ imagePullSecrets:
+ - name: sdx-registry-secret
+ securityContext:
+ runAsNonRoot: true
+ runAsUser: 10001
+ runAsGroup: 10001
+ fsGroup: 10001
+ containers:
+ - name: connector
+ image: geddes-registry.rcac.purdue.edu/sdx/dt-glance-ingestion:REPLACE_WITH_DIGEST
+ imagePullPolicy: IfNotPresent
+ env:
+ - name: GLANCE_SOURCE_MODE
+ value: postgres
+ - name: GLANCE_DB_URI
+ valueFrom:
+ secretKeyRef:
+ name: glance-postgrest-credentials
+ key: PGRST_DB_URI
+ - name: GLANCE_INGESTION_API_URL
+ value: http://dt-api:8000/api/dataset/v2/glance/traces/sync
+ - name: INGESTION_TOKEN
+ valueFrom:
+ secretKeyRef:
+ name: dt-api-secret
+ key: INGESTION_TOKEN
+ - name: GLANCE_PRODUCTION_MAPPINGS_JSON
+ valueFrom:
+ secretKeyRef:
+ name: dt-api-secret
+ key: GLANCE_PRODUCTION_MAPPINGS_JSON
+ - name: GLANCE_CURSOR_BACKEND
+ value: file
+ - name: GLANCE_CURSOR_FILE
+ value: /state/cursors.json
+ - name: GLANCE_FAILURE_AUDIT_FILE
+ value: /state/failures.jsonl
+ - name: GLANCE_RUN_OVERLAP_COUNT
+ value: "5"
+ - name: GLANCE_DATA_BATCH_SIZE
+ value: "25"
+ - name: GLANCE_CONNECT_TIMEOUT_SECONDS
+ value: "10"
+ - name: GLANCE_READ_TIMEOUT_SECONDS
+ value: "120"
+ - name: GLANCE_DB_RETRIES
+ value: "3"
+ - name: GLANCE_MAX_RUNS_PER_POLL
+ value: "20"
+ - name: GLANCE_MAX_SAMPLES_PER_RUN
+ value: "100000"
+ - name: GLANCE_MAX_VALUES_PER_RUN
+ value: "1000000"
+ - name: GLANCE_HANDOFF_RUN_BATCH_SIZE
+ value: "1"
+ # Approved Tool 3 pilot only. Remove this allowlist before
+ # enabling normal ten-minute incremental polling.
+ - name: GLANCE_BACKFILL_RUN_IDS
+ value: "1,5,7,8,29,32,73,161,162,164,165,166,169"
+ resources:
+ requests:
+ cpu: 250m
+ memory: 512Mi
+ limits:
+ cpu: "2"
+ memory: 2Gi
+ volumeMounts:
+ - name: state
+ mountPath: /state
+ volumes:
+ - name: state
+ persistentVolumeClaim:
+ claimName: dt-glance-ingestion-state
diff --git a/geddes/k8s/glance-mirror/01-secret.yaml.example b/geddes/k8s/glance-mirror/01-secret.yaml.example
new file mode 100644
index 0000000..69baf93
--- /dev/null
+++ b/geddes/k8s/glance-mirror/01-secret.yaml.example
@@ -0,0 +1,13 @@
+apiVersion: v1
+kind: Secret
+metadata:
+ name: glance-mirror-secret
+ namespace: ncn-digitaltwins-zchen
+type: Opaque
+stringData:
+ POSTGRES_PASSWORD: "REPLACE_WITH_RANDOM_POSTGRES_ADMIN_PASSWORD"
+ MIRROR_WRITER_PASSWORD: "REPLACE_WITH_RANDOM_WRITER_PASSWORD"
+ MIRROR_AUTHENTICATOR_PASSWORD: "REPLACE_WITH_RANDOM_AUTHENTICATOR_PASSWORD"
+ # URL-encode reserved characters in passwords used in these URIs.
+ GLANCE_MIRROR_DB_URI: "postgresql://glance_mirror_writer:URL_ENCODED_WRITER_PASSWORD@dt-glance-mirror-db:5432/glance_mirror?sslmode=disable"
+ PGRST_DB_URI: "postgresql://glance_mirror_authenticator:URL_ENCODED_AUTHENTICATOR_PASSWORD@dt-glance-mirror-db:5432/glance_mirror?sslmode=disable"
diff --git a/geddes/k8s/glance-mirror/02-postgres.yaml b/geddes/k8s/glance-mirror/02-postgres.yaml
new file mode 100644
index 0000000..4fc5741
--- /dev/null
+++ b/geddes/k8s/glance-mirror/02-postgres.yaml
@@ -0,0 +1,300 @@
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: dt-glance-mirror-db
+ namespace: ncn-digitaltwins-zchen
+spec:
+ accessModes:
+ - ReadWriteOnce
+ storageClassName: geddes-standard-singlenode
+ resources:
+ requests:
+ storage: 150Gi
+---
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: dt-glance-mirror-init
+ namespace: ncn-digitaltwins-zchen
+data:
+ 00-roles.sh: |
+ #!/bin/sh
+ set -eu
+
+ psql \
+ --set=ON_ERROR_STOP=1 \
+ --set=writer_password="$MIRROR_WRITER_PASSWORD" \
+ --set=authenticator_password="$MIRROR_AUTHENTICATOR_PASSWORD" \
+ --username "$POSTGRES_USER" \
+ --dbname "$POSTGRES_DB" <<'EOSQL'
+ SELECT 'CREATE ROLE glance_mirror_reader NOLOGIN'
+ WHERE NOT EXISTS (
+ SELECT 1 FROM pg_roles WHERE rolname = 'glance_mirror_reader'
+ ) \gexec
+
+ SELECT format(
+ 'CREATE ROLE glance_mirror_writer LOGIN PASSWORD %L',
+ :'writer_password'
+ )
+ WHERE NOT EXISTS (
+ SELECT 1 FROM pg_roles WHERE rolname = 'glance_mirror_writer'
+ ) \gexec
+ SELECT format(
+ 'ALTER ROLE glance_mirror_writer LOGIN PASSWORD %L',
+ :'writer_password'
+ ) \gexec
+
+ SELECT format(
+ 'CREATE ROLE glance_mirror_authenticator LOGIN PASSWORD %L',
+ :'authenticator_password'
+ )
+ WHERE NOT EXISTS (
+ SELECT 1 FROM pg_roles WHERE rolname = 'glance_mirror_authenticator'
+ ) \gexec
+ SELECT format(
+ 'ALTER ROLE glance_mirror_authenticator LOGIN PASSWORD %L',
+ :'authenticator_password'
+ ) \gexec
+
+ GRANT CONNECT ON DATABASE glance_mirror
+ TO glance_mirror_writer, glance_mirror_authenticator;
+ GRANT glance_mirror_reader TO glance_mirror_authenticator;
+ EOSQL
+
+ 10-schema.sql: |
+ CREATE SCHEMA IF NOT EXISTS glance_mirror;
+ REVOKE ALL ON SCHEMA glance_mirror FROM PUBLIC;
+
+ CREATE TABLE IF NOT EXISTS glance_mirror.tools (
+ idtools INTEGER PRIMARY KEY,
+ name TEXT NOT NULL,
+ description TEXT NOT NULL
+ );
+
+ CREATE TABLE IF NOT EXISTS glance_mirror.recipes (
+ idrecipes INTEGER PRIMARY KEY,
+ idtools INTEGER NOT NULL
+ REFERENCES glance_mirror.tools(idtools),
+ recipename TEXT NOT NULL,
+ recipefile TEXT,
+ hash TEXT NOT NULL,
+ timestamp TIMESTAMP WITHOUT TIME ZONE
+ );
+
+ CREATE TABLE IF NOT EXISTS glance_mirror.runs (
+ idruns INTEGER PRIMARY KEY,
+ idtools INTEGER NOT NULL
+ REFERENCES glance_mirror.tools(idtools),
+ lotname TEXT,
+ idrecipes INTEGER
+ REFERENCES glance_mirror.recipes(idrecipes),
+ starttime TIMESTAMP WITHOUT TIME ZONE NOT NULL,
+ endtime TIMESTAMP WITHOUT TIME ZONE,
+ status TEXT,
+ materialname TEXT
+ );
+
+ CREATE INDEX IF NOT EXISTS runs_tool_id_idx
+ ON glance_mirror.runs(idtools, idruns);
+
+ CREATE TABLE IF NOT EXISTS glance_mirror.samplerecord (
+ idsamplerecord INTEGER PRIMARY KEY,
+ idruns INTEGER NOT NULL
+ REFERENCES glance_mirror.runs(idruns) ON DELETE CASCADE,
+ idtools INTEGER NOT NULL
+ REFERENCES glance_mirror.tools(idtools),
+ time TIMESTAMP WITHOUT TIME ZONE
+ );
+
+ CREATE INDEX IF NOT EXISTS samplerecord_run_idx
+ ON glance_mirror.samplerecord(idruns, idsamplerecord);
+
+ CREATE TABLE IF NOT EXISTS glance_mirror.parameters (
+ idparameters INTEGER PRIMARY KEY,
+ name TEXT NOT NULL,
+ unit TEXT
+ );
+
+ CREATE TABLE IF NOT EXISTS glance_mirror.data (
+ idsamplerecord INTEGER NOT NULL
+ REFERENCES glance_mirror.samplerecord(idsamplerecord)
+ ON DELETE CASCADE,
+ idparameters INTEGER NOT NULL
+ REFERENCES glance_mirror.parameters(idparameters),
+ value DOUBLE PRECISION NOT NULL,
+ PRIMARY KEY (idsamplerecord, idparameters)
+ );
+
+ CREATE INDEX IF NOT EXISTS data_parameter_idx
+ ON glance_mirror.data(idparameters, idsamplerecord);
+
+ CREATE TABLE IF NOT EXISTS glance_mirror.eventtypes (
+ event TEXT PRIMARY KEY,
+ category INTEGER NOT NULL
+ );
+
+ CREATE TABLE IF NOT EXISTS glance_mirror.events (
+ idevents INTEGER PRIMARY KEY,
+ time TIMESTAMP WITHOUT TIME ZONE NOT NULL,
+ tool INTEGER NOT NULL
+ REFERENCES glance_mirror.tools(idtools),
+ type TEXT,
+ description TEXT NOT NULL
+ );
+
+ CREATE INDEX IF NOT EXISTS events_tool_time_idx
+ ON glance_mirror.events(tool, time, idevents);
+
+ CREATE TABLE IF NOT EXISTS glance_mirror.run_events (
+ idruns INTEGER NOT NULL
+ REFERENCES glance_mirror.runs(idruns) ON DELETE CASCADE,
+ idevents INTEGER NOT NULL
+ REFERENCES glance_mirror.events(idevents) ON DELETE CASCADE,
+ PRIMARY KEY (idruns, idevents)
+ );
+
+ CREATE INDEX IF NOT EXISTS run_events_event_idx
+ ON glance_mirror.run_events(idevents, idruns);
+
+ CREATE TABLE IF NOT EXISTS glance_mirror.sync_state (
+ idtools INTEGER NOT NULL
+ REFERENCES glance_mirror.tools(idtools),
+ state_key TEXT NOT NULL,
+ last_run_id INTEGER NOT NULL,
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL,
+ PRIMARY KEY (idtools, state_key)
+ );
+
+ CREATE TABLE IF NOT EXISTS glance_mirror.sync_audit (
+ id BIGSERIAL PRIMARY KEY,
+ idtools INTEGER NOT NULL,
+ idruns INTEGER,
+ state_key TEXT NOT NULL,
+ previous_cursor INTEGER NOT NULL,
+ committed_cursor INTEGER NOT NULL,
+ sample_count BIGINT NOT NULL DEFAULT 0,
+ value_count BIGINT NOT NULL DEFAULT 0,
+ event_count BIGINT NOT NULL DEFAULT 0,
+ status TEXT NOT NULL CHECK (status IN ('success', 'failed')),
+ duration_ms BIGINT NOT NULL,
+ error_text TEXT NOT NULL DEFAULT '',
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL
+ );
+
+ CREATE INDEX IF NOT EXISTS sync_audit_tool_time_idx
+ ON glance_mirror.sync_audit(idtools, created_at DESC);
+
+ GRANT USAGE ON SCHEMA glance_mirror
+ TO glance_mirror_writer, glance_mirror_reader;
+ GRANT SELECT, INSERT, UPDATE, DELETE
+ ON ALL TABLES IN SCHEMA glance_mirror
+ TO glance_mirror_writer;
+ GRANT USAGE, SELECT
+ ON ALL SEQUENCES IN SCHEMA glance_mirror
+ TO glance_mirror_writer;
+ GRANT SELECT ON ALL TABLES IN SCHEMA glance_mirror
+ TO glance_mirror_reader;
+
+ ALTER DEFAULT PRIVILEGES IN SCHEMA glance_mirror
+ GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES
+ TO glance_mirror_writer;
+ ALTER DEFAULT PRIVILEGES IN SCHEMA glance_mirror
+ GRANT USAGE, SELECT ON SEQUENCES
+ TO glance_mirror_writer;
+ ALTER DEFAULT PRIVILEGES IN SCHEMA glance_mirror
+ GRANT SELECT ON TABLES
+ TO glance_mirror_reader;
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: dt-glance-mirror-db
+ namespace: ncn-digitaltwins-zchen
+spec:
+ replicas: 1
+ strategy:
+ type: Recreate
+ selector:
+ matchLabels:
+ app: dt-glance-mirror-db
+ template:
+ metadata:
+ labels:
+ app: dt-glance-mirror-db
+ spec:
+ containers:
+ - name: postgres
+ image: postgres:15
+ ports:
+ - name: postgres
+ containerPort: 5432
+ env:
+ - name: PGDATA
+ value: /var/lib/postgresql/data/pgdata
+ - name: POSTGRES_USER
+ value: postgres
+ - name: POSTGRES_DB
+ value: glance_mirror
+ - name: POSTGRES_PASSWORD
+ valueFrom:
+ secretKeyRef:
+ name: glance-mirror-secret
+ key: POSTGRES_PASSWORD
+ - name: MIRROR_WRITER_PASSWORD
+ valueFrom:
+ secretKeyRef:
+ name: glance-mirror-secret
+ key: MIRROR_WRITER_PASSWORD
+ - name: MIRROR_AUTHENTICATOR_PASSWORD
+ valueFrom:
+ secretKeyRef:
+ name: glance-mirror-secret
+ key: MIRROR_AUTHENTICATOR_PASSWORD
+ readinessProbe:
+ exec:
+ command:
+ - pg_isready
+ - -U
+ - postgres
+ - -d
+ - glance_mirror
+ initialDelaySeconds: 5
+ periodSeconds: 5
+ resources:
+ requests:
+ cpu: 500m
+ memory: 1Gi
+ limits:
+ cpu: "4"
+ memory: 8Gi
+ volumeMounts:
+ - name: database
+ mountPath: /var/lib/postgresql/data
+ - name: init
+ mountPath: /docker-entrypoint-initdb.d/00-roles.sh
+ subPath: 00-roles.sh
+ - name: init
+ mountPath: /docker-entrypoint-initdb.d/10-schema.sql
+ subPath: 10-schema.sql
+ volumes:
+ - name: database
+ persistentVolumeClaim:
+ claimName: dt-glance-mirror-db
+ - name: init
+ configMap:
+ name: dt-glance-mirror-init
+ defaultMode: 0555
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: dt-glance-mirror-db
+ namespace: ncn-digitaltwins-zchen
+spec:
+ type: ClusterIP
+ selector:
+ app: dt-glance-mirror-db
+ ports:
+ - name: postgres
+ port: 5432
+ targetPort: postgres
diff --git a/geddes/k8s/glance-mirror/03-postgrest.yaml b/geddes/k8s/glance-mirror/03-postgrest.yaml
new file mode 100644
index 0000000..ee0cda1
--- /dev/null
+++ b/geddes/k8s/glance-mirror/03-postgrest.yaml
@@ -0,0 +1,61 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: dt-glance-mirror-postgrest
+ namespace: ncn-digitaltwins-zchen
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: dt-glance-mirror-postgrest
+ template:
+ metadata:
+ labels:
+ app: dt-glance-mirror-postgrest
+ spec:
+ containers:
+ - name: postgrest
+ image: geddes-registry.rcac.purdue.edu/docker-hub-cache/postgrest/postgrest:v13.0.6
+ ports:
+ - name: http
+ containerPort: 3000
+ env:
+ - name: PGRST_DB_URI
+ valueFrom:
+ secretKeyRef:
+ name: glance-mirror-secret
+ key: PGRST_DB_URI
+ - name: PGRST_DB_SCHEMAS
+ value: glance_mirror
+ - name: PGRST_DB_ANON_ROLE
+ value: glance_mirror_reader
+ - name: PGRST_SERVER_PORT
+ value: "3000"
+ - name: PGRST_LOG_LEVEL
+ value: warn
+ readinessProbe:
+ tcpSocket:
+ port: http
+ initialDelaySeconds: 3
+ periodSeconds: 5
+ resources:
+ requests:
+ cpu: 100m
+ memory: 256Mi
+ limits:
+ cpu: "1"
+ memory: 1Gi
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: dt-glance-mirror-postgrest
+ namespace: ncn-digitaltwins-zchen
+spec:
+ type: ClusterIP
+ selector:
+ app: dt-glance-mirror-postgrest
+ ports:
+ - name: http
+ port: 3000
+ targetPort: http
diff --git a/geddes/k8s/glance-mirror/04-sync-cronjob.yaml b/geddes/k8s/glance-mirror/04-sync-cronjob.yaml
new file mode 100644
index 0000000..7caa986
--- /dev/null
+++ b/geddes/k8s/glance-mirror/04-sync-cronjob.yaml
@@ -0,0 +1,75 @@
+apiVersion: batch/v1
+kind: CronJob
+metadata:
+ name: dt-glance-mirror-sync
+ namespace: ncn-digitaltwins-zchen
+spec:
+ suspend: true
+ schedule: "*/10 * * * *"
+ concurrencyPolicy: Forbid
+ startingDeadlineSeconds: 300
+ successfulJobsHistoryLimit: 3
+ failedJobsHistoryLimit: 5
+ jobTemplate:
+ spec:
+ backoffLimit: 2
+ activeDeadlineSeconds: 1800
+ template:
+ metadata:
+ labels:
+ app: dt-glance-mirror-sync
+ spec:
+ restartPolicy: Never
+ imagePullSecrets:
+ - name: sdx-registry-secret
+ securityContext:
+ runAsNonRoot: true
+ runAsUser: 10001
+ runAsGroup: 10001
+ containers:
+ - name: mirror
+ image: geddes-registry.rcac.purdue.edu/sdx/dt-glance-ingestion:REPLACE_WITH_DIGEST
+ imagePullPolicy: IfNotPresent
+ command:
+ - python
+ - scripts/run_glance_mirror.py
+ env:
+ - name: GLANCE_DB_URI
+ valueFrom:
+ secretKeyRef:
+ name: glance-postgrest-credentials
+ key: PGRST_DB_URI
+ - name: GLANCE_MIRROR_DB_URI
+ valueFrom:
+ secretKeyRef:
+ name: glance-mirror-secret
+ key: GLANCE_MIRROR_DB_URI
+ - name: GLANCE_MIRROR_TOOL_IDS
+ value: "3"
+ - name: GLANCE_MIRROR_INITIAL_RUN_ID
+ value: "1"
+ - name: GLANCE_MIRROR_OVERLAP_RUN_IDS
+ value: "5"
+ - name: GLANCE_MIRROR_MAX_RUNS_PER_TOOL
+ value: "20"
+ - name: GLANCE_MIRROR_MAX_SAMPLES_PER_RUN
+ value: "100000"
+ - name: GLANCE_MIRROR_MAX_VALUES_PER_RUN
+ value: "1000000"
+ - name: GLANCE_MIRROR_INSERT_BATCH_SIZE
+ value: "5000"
+ - name: GLANCE_MIRROR_STATEMENT_TIMEOUT_SECONDS
+ value: "300"
+ - name: GLANCE_MIRROR_SOURCE_RETRIES
+ value: "3"
+ # Bounded initial Tool 3 mirror pilot. Remove this setting only
+ # after the pilot count comparison and backfill plan are approved.
+ - name: GLANCE_MIRROR_BACKFILL_RUN_IDS
+ value: "1,5,7,8,29,32,73,161,162,164,165,166,169"
+ resources:
+ requests:
+ cpu: 500m
+ memory: 512Mi
+ limits:
+ cpu: "2"
+ memory: 2Gi
diff --git a/geddes/k8s/glance-mirror/05-network-policy.yaml b/geddes/k8s/glance-mirror/05-network-policy.yaml
new file mode 100644
index 0000000..8c62567
--- /dev/null
+++ b/geddes/k8s/glance-mirror/05-network-policy.yaml
@@ -0,0 +1,22 @@
+apiVersion: networking.k8s.io/v1
+kind: NetworkPolicy
+metadata:
+ name: dt-glance-mirror-db-ingress
+ namespace: ncn-digitaltwins-zchen
+spec:
+ podSelector:
+ matchLabels:
+ app: dt-glance-mirror-db
+ policyTypes:
+ - Ingress
+ ingress:
+ - from:
+ - podSelector:
+ matchLabels:
+ app: dt-glance-mirror-sync
+ - podSelector:
+ matchLabels:
+ app: dt-glance-mirror-postgrest
+ ports:
+ - protocol: TCP
+ port: 5432
diff --git a/geddes/k8s/glance-mirror/README.md b/geddes/k8s/glance-mirror/README.md
new file mode 100644
index 0000000..3543da8
--- /dev/null
+++ b/geddes/k8s/glance-mirror/README.md
@@ -0,0 +1,52 @@
+# GLANCE modern mirror
+
+These manifests define an inactive, private PostgreSQL 15 mirror of the
+authoritative GLANCE PostgreSQL 10.5 database.
+
+The source was approximately 53 GB on 2026-07-28. Its `data` table contained
+approximately 613 million rows. The mirror therefore uses a separate,
+expandable 150 GiB PVC and must be backfilled in bounded tool/run windows.
+
+## Safety boundary
+
+- Both CronJobs in this repository are committed with `suspend: true`.
+- The mirror PostgREST service is `ClusterIP`; no Ingress is supplied.
+- The source identity is read-only.
+- The mirror writer can modify only the destination mirror schema.
+- One run is replaced and its mode-specific tool cursor is handled in one
+ destination transaction.
+- Live polling, explicit-ID pilots, and bounded backfills have independent
+ cursor ledgers.
+- A failed run prevents later cursor advancement. Later valid runs can still
+ be copied idempotently and will be revisited after the failed run succeeds.
+- Per-run sample and value limits prevent an unexpectedly large source run
+ from exhausting worker memory. Raising either limit requires review.
+- Run/event associations reconcile events that are removed or moved on a
+ subsequent source snapshot without deleting events shared by another run.
+- Null recipe IDs are valid and are mirrored.
+
+Do not apply these manifests as a group before creating reviewed credentials:
+`02-postgres.yaml` allocates a 150 GiB PVC and starts a database.
+
+## Reviewed rollout
+
+1. Copy `01-secret.yaml.example` outside Git, generate three independent random
+ passwords, URL-encode them in the two URIs, and create
+ `glance-mirror-secret`.
+2. Review the 150 GiB allocation and backup/retention plan.
+3. Apply `02-postgres.yaml` and verify the schema and role grants.
+4. Build `azure/Dockerfile.glance`, push it, and replace every
+ `REPLACE_WITH_DIGEST` image reference with a digest-pinned image.
+5. Apply `03-postgrest.yaml`, `04-sync-cronjob.yaml`, and
+ `05-network-policy.yaml`. Keep the CronJob suspended.
+6. Create one manual Job from the CronJob for one approved Tool 3 run.
+7. Compare source and destination run, sample, value, parameter, recipe, and
+ event counts.
+8. Run the complete approved Tool 3 allowlist and repeat the comparison.
+9. Remove the allowlist only after a separately reviewed historical backfill
+ plan establishes bounded run/date windows.
+10. Unsuspend ten-minute synchronization only after backfill and lag monitoring
+ are accepted.
+
+PostgREST cutover is the last step. Until then, the direct read-only SQL
+connector remains the authoritative ingestion path.
diff --git a/geddes/k8s/ml-shadow-evaluation-cronjob.yaml b/geddes/k8s/ml-shadow-evaluation-cronjob.yaml
new file mode 100644
index 0000000..09d851e
--- /dev/null
+++ b/geddes/k8s/ml-shadow-evaluation-cronjob.yaml
@@ -0,0 +1,33 @@
+# Review and set ML_SHADOW_PROJECT_ID before unsuspending. This job is
+# intentionally disabled in source; applying it cannot change proposal behavior.
+apiVersion: batch/v1
+kind: CronJob
+metadata:
+ name: dt-ml-shadow-evaluation
+ namespace: ncn-digitaltwins-zchen
+spec:
+ schedule: "20 3 * * 1"
+ suspend: true
+ concurrencyPolicy: Forbid
+ successfulJobsHistoryLimit: 3
+ failedJobsHistoryLimit: 3
+ jobTemplate:
+ spec:
+ template:
+ spec:
+ restartPolicy: Never
+ containers:
+ - name: evaluator
+ image: ghcr.io/OWNER/IMAGE@sha256:REPLACE_WITH_IMMUTABLE_DIGEST
+ command:
+ - /bin/sh
+ - -c
+ - >-
+ python api/scripts/run_ml_shadow_evaluation.py
+ --project-id "${ML_SHADOW_PROJECT_ID}"
+ env:
+ - name: ML_SHADOW_PROJECT_ID
+ value: REPLACE_WITH_APPROVED_PROJECT_ID
+ envFrom:
+ - secretRef:
+ name: dt-api-secrets
diff --git a/geddes/k8s/postgres/schema_v2.sql b/geddes/k8s/postgres/schema_v2.sql
index 0653384..32fb8cb 100644
--- a/geddes/k8s/postgres/schema_v2.sql
+++ b/geddes/k8s/postgres/schema_v2.sql
@@ -111,6 +111,11 @@ CREATE TABLE IF NOT EXISTS equipment_runs (
row_index INT,
upload_filename VARCHAR(255) DEFAULT '',
source VARCHAR(100) DEFAULT 'data_upload',
+ source_system VARCHAR(100) NOT NULL DEFAULT 'data_upload',
+ source_tool_id BIGINT,
+ source_run_id BIGINT,
+ source_updated_at TIMESTAMP WITH TIME ZONE,
+ ingested_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
@@ -122,6 +127,11 @@ CREATE INDEX IF NOT EXISTS idx_equipment_runs_upload_id ON equipment_runs(upload
CREATE UNIQUE INDEX IF NOT EXISTS uq_equipment_runs_upload_row
ON equipment_runs(upload_id, row_index)
WHERE upload_id IS NOT NULL;
+CREATE UNIQUE INDEX IF NOT EXISTS uq_equipment_runs_source_identity
+ ON equipment_runs(
+ source_system, equipment_id, source_tool_id, source_run_id
+ )
+ WHERE source_tool_id IS NOT NULL AND source_run_id IS NOT NULL;
ALTER TABLE equipment_runs ENABLE ROW LEVEL SECURITY;
GRANT SELECT ON equipment_runs TO api_client;
@@ -154,8 +164,8 @@ CREATE TABLE IF NOT EXISTS equipment_run_trace_samples (
sample_index INT NOT NULL,
source_sample_id BIGINT NOT NULL,
source_tool_id BIGINT,
- sample_time TIMESTAMP WITHOUT TIME ZONE NOT NULL,
- sample_time_raw TEXT NOT NULL,
+ sample_time TIMESTAMP WITHOUT TIME ZONE,
+ sample_time_raw TEXT,
values_json JSONB NOT NULL DEFAULT '{}'::jsonb,
PRIMARY KEY (equipment_run_id, sample_index),
UNIQUE (equipment_run_id, source_sample_id),
@@ -165,6 +175,11 @@ CREATE TABLE IF NOT EXISTS equipment_run_trace_samples (
CREATE INDEX IF NOT EXISTS idx_equipment_run_trace_samples_time
ON equipment_run_trace_samples(equipment_run_id, sample_time, sample_index);
+ALTER TABLE equipment_run_trace_samples
+ ALTER COLUMN sample_time DROP NOT NULL;
+ALTER TABLE equipment_run_trace_samples
+ ALTER COLUMN sample_time_raw DROP NOT NULL;
+
ALTER TABLE equipment_run_trace_samples ENABLE ROW LEVEL SECURITY;
GRANT SELECT ON equipment_run_trace_samples TO api_client;
@@ -189,6 +204,73 @@ CREATE POLICY equipment_run_trace_visibility_policy
)
);
+CREATE TABLE IF NOT EXISTS equipment_run_trace_events (
+ equipment_run_id BIGINT NOT NULL
+ REFERENCES equipment_runs(id) ON DELETE CASCADE,
+ source_event_id BIGINT NOT NULL,
+ source_tool_id BIGINT,
+ event_time TIMESTAMP WITHOUT TIME ZONE NOT NULL,
+ event_time_raw TEXT NOT NULL,
+ event_type TEXT NOT NULL DEFAULT '',
+ category TEXT NOT NULL DEFAULT '',
+ description TEXT NOT NULL DEFAULT '',
+ raw_event_json JSONB NOT NULL DEFAULT '{}'::jsonb,
+ PRIMARY KEY (equipment_run_id, source_event_id),
+ CHECK (jsonb_typeof(raw_event_json) = 'object')
+);
+
+CREATE INDEX IF NOT EXISTS idx_equipment_run_trace_events_time
+ON equipment_run_trace_events(equipment_run_id, event_time, source_event_id);
+
+ALTER TABLE equipment_run_trace_events ENABLE ROW LEVEL SECURITY;
+GRANT SELECT ON equipment_run_trace_events TO api_client;
+
+CREATE POLICY equipment_run_trace_event_visibility_policy
+ ON equipment_run_trace_events
+ FOR SELECT
+ USING (
+ EXISTS (
+ SELECT 1
+ FROM equipment_runs r
+ JOIN projects p ON p.id = r.project_id
+ WHERE r.id = equipment_run_trace_events.equipment_run_id
+ AND (
+ p.access_mode = 'open'
+ OR EXISTS (
+ SELECT 1
+ FROM project_members pm
+ WHERE pm.project_id = p.id
+ AND pm.nanohub_user_id =
+ current_setting('app.current_user', true)
+ )
+ )
+ )
+ );
+
+CREATE TABLE IF NOT EXISTS glance_ingestion_audit (
+ id BIGSERIAL PRIMARY KEY,
+ batch_id VARCHAR(255) NOT NULL,
+ source_system VARCHAR(100) NOT NULL DEFAULT 'glance',
+ equipment_id VARCHAR(255) NOT NULL,
+ source_tool_id BIGINT NOT NULL,
+ project_id VARCHAR(50),
+ previous_cursor TEXT,
+ proposed_cursor TEXT,
+ poll_started_at TIMESTAMP WITH TIME ZONE,
+ poll_completed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+ status VARCHAR(32) NOT NULL,
+ run_count INT NOT NULL DEFAULT 0,
+ sample_count INT NOT NULL DEFAULT 0,
+ event_count INT NOT NULL DEFAULT 0,
+ warning_count INT NOT NULL DEFAULT 0,
+ payload_sha256 VARCHAR(64),
+ error_text TEXT,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX IF NOT EXISTS idx_glance_ingestion_audit_tool_time
+ON glance_ingestion_audit(equipment_id, source_tool_id, created_at DESC);
+
CREATE TABLE IF NOT EXISTS users (
id VARCHAR(255) PRIMARY KEY, -- NanoHUB Subject ID or Email
name VARCHAR(255) NOT NULL,
diff --git a/web/app/data/analysis/page.tsx b/web/app/data/analysis/page.tsx
new file mode 100644
index 0000000..82ca694
--- /dev/null
+++ b/web/app/data/analysis/page.tsx
@@ -0,0 +1,5 @@
+import { CrossRunTraceAnalysis } from "@/components/cross-run-trace-analysis";
+
+export default function TraceAnalysisPage() {
+ return ;
+}
diff --git a/web/app/optimize/page.tsx b/web/app/optimize/page.tsx
index b70acec..953a966 100644
--- a/web/app/optimize/page.tsx
+++ b/web/app/optimize/page.tsx
@@ -5,10 +5,12 @@ import { cn } from "@/lib/utils";
import {
createExperiment,
getProjectExperiments,
+ getMlRuntimeStatus,
getProposalsData,
getV2Projects,
runOptimization,
type ProjectExperimentRecord,
+ type MlRuntimeStatus,
type V2Project,
} from "@/lib/api-client";
import { Play, Download, Info, Loader2, AlertCircle, Save } from "lucide-react";
@@ -38,9 +40,13 @@ export default function OptimizePage() {
const [error, setError] = useState(null);
const [batches, setBatches] = useState([]);
const [source, setSource] = useState("loading...");
+ const [runtime, setRuntime] = useState(null);
const [selectedBatch, setSelectedBatch] = useState(0);
const [projects, setProjects] = useState([]);
- const [selectedProject, setSelectedProject] = useState("");
+ const [selectedProject, setSelectedProject] = useState(() => {
+ if (typeof window === "undefined") return "";
+ return new URLSearchParams(window.location.search).get("project_id") || "";
+ });
const [projectUnitExperiments, setProjectUnitExperiments] = useState([]);
const [selectedUnitExperiment, setSelectedUnitExperiment] = useState("");
const [savingRecipeIndex, setSavingRecipeIndex] = useState(null);
@@ -63,18 +69,12 @@ export default function OptimizePage() {
Etch_Avgcf4Flow: { label: "CF4 Flow", unit: "sccm" },
};
- // Fetch initial proposals
- useEffect(() => {
- const params = new URLSearchParams(window.location.search);
- const projectId = params.get("project_id") || "";
- if (projectId) setSelectedProject(projectId);
- }, []);
-
useEffect(() => {
Promise.all([
getProposalsData(selectedProject || undefined),
getV2Projects(),
- ]).then(([data, projectData]) => {
+ getMlRuntimeStatus(),
+ ]).then(([data, projectData, runtimeData]) => {
if (data) {
setBatches(((data as Record).batches as ProposalBatch[]) || []);
setSource((data as Record).source as string || "unknown");
@@ -82,14 +82,13 @@ export default function OptimizePage() {
setError("Could not load optimization data. Is the API running?");
}
setProjects(projectData ?? []);
+ setRuntime(runtimeData);
setLoading(false);
});
}, [selectedProject]);
useEffect(() => {
if (!selectedProject) {
- setProjectUnitExperiments([]);
- setSelectedUnitExperiment("");
return;
}
getProjectExperiments(selectedProject).then((items) => {
@@ -103,6 +102,14 @@ export default function OptimizePage() {
});
}, [selectedProject]);
+ const handleProjectChange = (projectId: string) => {
+ setSelectedProject(projectId);
+ if (!projectId) {
+ setProjectUnitExperiments([]);
+ setSelectedUnitExperiment("");
+ }
+ };
+
const handleOptimize = async () => {
setOptimizing(true);
setError(null);
@@ -110,6 +117,9 @@ export default function OptimizePage() {
if (result) {
setBatches((result.batches as ProposalBatch[]) || []);
setSource(result.source as string || "optimized");
+ if (result.runtime) {
+ setRuntime(result.runtime as unknown as MlRuntimeStatus);
+ }
setSelectedBatch(0);
} else {
setError("Optimization failed. Check the API server logs.");
@@ -259,6 +269,11 @@ export default function OptimizePage() {
source: {source}
+ {runtime && (
+
+ configured: {runtime.proposal_engine} / {runtime.effective_model_policy}
+
+ )}
@@ -267,7 +282,7 @@ export default function OptimizePage() {