Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
7acc317
add v1 for implementing backfill app
dudu-theman Jul 14, 2026
814ded6
mermaid diagram of ingestion
dudu-theman Jul 16, 2026
3a745a1
v2 with new system design and partition projection laid out
dudu-theman Jul 17, 2026
81cd07d
fix tldraw page link
dudu-theman Jul 17, 2026
d995242
Merge branch 'main' of github.com:METResearchGroup/lab_data_integrati…
dudu-theman Jul 17, 2026
f9090ce
make a md file
dudu-theman Jul 18, 2026
ab7b394
v1 for backfill app
dudu-theman Jul 18, 2026
4b950ae
new glue table/partitioning layout
dudu-theman Jul 20, 2026
bbecb16
add reasoning for splititng data into tables
dudu-theman Jul 20, 2026
51fa1e7
have temp reference similar doc as opposed to copy paste
dudu-theman Jul 20, 2026
7e809e9
add Deduplication segment
dudu-theman Jul 20, 2026
5e8c2be
add dedupe section
dudu-theman Jul 20, 2026
f68cd9b
add dedup logic via apache iceberg
dudu-theman Jul 20, 2026
b7f64b1
Merge branch 'main' of github.com:METResearchGroup/lab_data_integrati…
dudu-theman Jul 22, 2026
ae1d45a
rename bluesky_backfill_app -> bluesky_ingestion_jetstream and rename…
dudu-theman Jul 22, 2026
95fb371
07 23 updates
dudu-theman Jul 24, 2026
cd5eaf2
07 23 updates
dudu-theman Jul 24, 2026
885f856
add skeleton
dudu-theman Jul 24, 2026
fedc3f4
implement per-event parsing
dudu-theman Jul 24, 2026
0ad065c
implement buffer
dudu-theman Jul 24, 2026
193493a
implement stream_events
dudu-theman Jul 24, 2026
87d3353
add orchestrator main
dudu-theman Jul 24, 2026
9dad4d0
change max buffer size to 2GB
dudu-theman Jul 24, 2026
9a56492
implement disk flushes
dudu-theman Jul 24, 2026
2fde958
implement jetstream connection retries
dudu-theman Jul 24, 2026
3d464d8
add jetstream data
dudu-theman Jul 24, 2026
fdc815d
change buffer flush time to 30s
dudu-theman Jul 24, 2026
e637d8f
add unit tests for jetstream connection, retries, uploads to disk
dudu-theman Jul 24, 2026
d37703a
add event schemas
dudu-theman Jul 24, 2026
11eb1ef
Merge branch 'main' into ingestion_and_s3_flush
mark-torres10 Jul 28, 2026
37304f1
fix hardcoded comment and change buffer drain to not return rows and …
dudu-theman Jul 28, 2026
ca57d31
merge 2 bluesky files into 1
dudu-theman Jul 28, 2026
72e8e1a
add rev and ingested_at
dudu-theman Jul 28, 2026
cccfa65
add rev and ingested_at
dudu-theman Jul 28, 2026
a3aca9d
update docstrings
dudu-theman Jul 28, 2026
fdb6f19
add backfill notes
dudu-theman Jul 28, 2026
d1244c4
Merge branch 'ingestion_and_s3_flush' of github.com:METResearchGroup/…
dudu-theman Jul 28, 2026
19eda82
remove incorrect comment
dudu-theman Jul 28, 2026
1696b7b
add ingested_at as a common_required_key
dudu-theman Jul 28, 2026
ac832c8
add ingested_at as a common_required_key
dudu-theman Jul 28, 2026
e6eb122
fix rich dependency to work for iceberg
dudu-theman Jul 29, 2026
c6f9f3e
add run_id
dudu-theman Jul 29, 2026
3841976
add run_id
dudu-theman Jul 29, 2026
100be6a
add run_id
dudu-theman Jul 29, 2026
d58cc14
merge main
dudu-theman Jul 30, 2026
0d2247d
add run_id and timestamp validation
dudu-theman Jul 30, 2026
559f177
restart infrastructure for bluesky_ingestion_stream
dudu-theman Jul 30, 2026
46e930a
integrate iceberg into s3 writes
dudu-theman Jul 30, 2026
d2c10b8
update changelog with iceberg changes
dudu-theman Jul 30, 2026
e6443a8
trim down comments, docstrings, rename some variables for clarity
dudu-theman Aug 3, 2026
df9d226
condense comments and docstrings
dudu-theman Aug 3, 2026
9c89b8f
move test-only class to tests instead of keeping in bluesky_ingestion…
dudu-theman Aug 3, 2026
eaa2c5d
add runbook for cieberg tables
dudu-theman Aug 3, 2026
5d7a4f6
restore data_platform tf file
dudu-theman Aug 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@
## 2026-07-30

1. Gated the UI behind Supabase email/password auth (issue #124): a login page, a protected route group that redirects unauthenticated visitors to sign-in and returns them to their intended destination, and a sign-out control showing the signed-in user's email. Access is invite-only, with users added directly in Supabase. Also removed job-polling status flicker. [PR #140](https://github.com/METResearchGroup/lab_data_integrations_interface/pull/140)
2. Bluesky Jetstream ingestion now commits to Iceberg tables in the Glue catalog (`bluesky_raw`) instead of writing Parquet to disk, retrying transient commit failures and dead-lettering batches to `s3://lab-data-integrations-interface/dead_letter/` when they cannot land. Replaced the `data_platform` Terraform stack with `terraform/bluesky_ingestion_jetstream/`, which destroyed the previous Glue database, both Athena workgroups, and the pipeline-runs DynamoDB table that `backend/routes/posts.py` still references. [PR #141](https://github.com/METResearchGroup/lab_data_integrations_interface/pull/141)

## 2026-07-29

Expand Down
1 change: 1 addition & 0 deletions bluesky_ingestion_jetstream/aws/__init__.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
"""AWS-facing pieces of the Jetstream pipeline: Glue catalog and Iceberg tables."""
97 changes: 97 additions & 0 deletions bluesky_ingestion_jetstream/aws/bootstrap.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
"""One-shot creation of the four Iceberg tables. Run by hand, not by the ingester.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can you add a HOW_TO_SETUP_ICEBERG_TABLES runbook that mentions running this script so we know what to do for future reference?


python -m bluesky_ingestion_jetstream.aws.bootstrap

Idempotent: tables that already exist are reported and left alone, so a partial
failure can be resolved by re-running. Dropping a table is not offered here --
it discards data, and doing it by hand is the point.

The Glue database itself is Terraform's
(`terraform/bluesky_ingestion_jetstream/main.tf`); the
tables are not, because Iceberg rewrites a table's schema, partition spec, and
snapshot pointer on every commit, which an `aws_glue_catalog_table` resource
would read as drift and revert on the next apply.
"""

from pyiceberg.catalog.glue import GlueCatalog
from pyiceberg.exceptions import NoSuchNamespaceError, TableAlreadyExistsError
from pyiceberg.table import Table
from pyiceberg.transforms import DayTransform

from bluesky_ingestion_jetstream.aws.catalog import build_catalog
from bluesky_ingestion_jetstream.aws.constants import (
GLUE_DATABASE,
PARTITION_FIELD_NAME,
PARTITION_SOURCE_COLUMN,
TABLE_LOCATIONS,
TABLE_PROPERTIES,
)
from bluesky_ingestion_jetstream.schemas.arrow_schemas import RECORD_TYPE_TO_SCHEMA


def require_namespace(catalog: GlueCatalog) -> None:
"""Fail if Terraform has not created the Glue database yet."""

try:
catalog.list_tables(GLUE_DATABASE)
except NoSuchNamespaceError as error:
raise RuntimeError(
f"Glue database {GLUE_DATABASE!r} does not exist. It is managed by "
"Terraform -- run `terraform apply` in "
"terraform/bluesky_ingestion_jetstream first."
) from error


def create_table(catalog: GlueCatalog, record_type: str) -> Table:
"""Create one partitioned table from its Arrow schema.

The Arrow schema is passed through untouched so PyIceberg assigns the field
IDs itself. Hand-written IDs are renumbered on create, and because Iceberg
resolves columns by ID rather than name, any mismatch reads back as NULL for
every affected column rather than failing. Not writing IDs at all removes
that failure mode instead of guarding against it.
"""

table = catalog.create_table(
identifier=(GLUE_DATABASE, record_type),
schema=RECORD_TYPE_TO_SCHEMA[record_type],
location=TABLE_LOCATIONS[record_type],
properties=TABLE_PROPERTIES,
)

# Partition after creation rather than passing a `PartitionSpec`, because
# `add_field` takes the column name and resolves the source ID itself.
table.update_spec().add_field(
PARTITION_SOURCE_COLUMN, DayTransform(), PARTITION_FIELD_NAME
).commit()

return table


def bootstrap(catalog: GlueCatalog | None = None) -> dict[str, Table]:
"""Create every missing table. Returns record type -> table for all four."""

catalog = catalog or build_catalog()
require_namespace(catalog)

tables: dict[str, Table] = {}
for record_type in RECORD_TYPE_TO_SCHEMA:
try:
tables[record_type] = create_table(catalog, record_type)
print(f"created {GLUE_DATABASE}.{record_type} -> {TABLE_LOCATIONS[record_type]}")
except TableAlreadyExistsError:
tables[record_type] = catalog.load_table((GLUE_DATABASE, record_type))
print(f"exists {GLUE_DATABASE}.{record_type}")

return tables


def main() -> None:
"""CLI entry point."""

for record_type, table in bootstrap().items():
print(f"{record_type}: {table.spec()}")


if __name__ == "__main__":
main()
98 changes: 98 additions & 0 deletions bluesky_ingestion_jetstream/aws/catalog.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
"""Connect to the Glue catalog and load the four Iceberg tables.

Runs on every process start, unlike `bootstrap.py`. Nothing here creates or
alters a table: an ingester that can issue DDL is one typo in a database name
away from quietly writing a full day of data into a second, empty set of tables
that looks fine until someone queries the real ones. Missing tables raise.
"""

import boto3
from botocore.config import Config
from pyiceberg.catalog.glue import GlueCatalog
from pyiceberg.exceptions import NoSuchTableError
from pyiceberg.table import Table

from bluesky_ingestion_jetstream.aws.constants import (
AWS_REGION,
GLUE_CONNECT_TIMEOUT_SECONDS,
GLUE_DATABASE,
GLUE_MAX_ATTEMPTS,
GLUE_READ_TIMEOUT_SECONDS,
S3_BUCKET,
S3_CONNECT_TIMEOUT_SECONDS,
S3_PREFIX,
S3_REQUEST_TIMEOUT_SECONDS,
)
from bluesky_ingestion_jetstream.constants import RECORD_TYPES


class MissingTablesError(RuntimeError):
"""Raised when the catalog is missing tables `bootstrap.py` should have created."""


def build_glue_client():
"""A Glue client with a bounded worst case.

Built here rather than left to PyIceberg because its default is `standard`
retry mode with ten attempts over a 60s read timeout, which puts an
open-ended retry loop underneath every commit. The commit is retried at a
higher level where the failure can be dead-lettered, so this layer only needs
to cover a single dropped packet, not an outage.
"""

return boto3.client(
"glue",
region_name=AWS_REGION,
config=Config(
retries={"max_attempts": GLUE_MAX_ATTEMPTS, "mode": "standard"},
connect_timeout=GLUE_CONNECT_TIMEOUT_SECONDS,
read_timeout=GLUE_READ_TIMEOUT_SECONDS,
),
)


def build_catalog() -> GlueCatalog:
"""Construct the Glue-backed catalog.

Left on PyIceberg's default PyArrowFileIO. The Iceberg experiment pinned
FsspecFileIO so its S3 meter could see every request through aiobotocore;
production has nothing to meter and PyArrow's client is faster.
"""

return GlueCatalog(
name="bluesky",
client=build_glue_client(),
**{
"warehouse": f"s3://{S3_BUCKET}/{S3_PREFIX}",
"glue.region": AWS_REGION,
"s3.region": AWS_REGION,
"s3.connect-timeout": S3_CONNECT_TIMEOUT_SECONDS,
"s3.request-timeout": S3_REQUEST_TIMEOUT_SECONDS,
},
)


def load_tables(catalog: GlueCatalog) -> dict[str, Table]:
"""Load every record type's table, or raise naming all the ones missing.

Called once at startup rather than per flush, because each load is a Glue
`GetTable` call. Every missing table is collected before raising, so a fresh
environment reports all four in one go instead of one per re-run.
"""

tables: dict[str, Table] = {}
missing: list[str] = []

for record_type in RECORD_TYPES:
try:
tables[record_type] = catalog.load_table((GLUE_DATABASE, record_type))
except NoSuchTableError:
missing.append(record_type)

if missing:
raise MissingTablesError(
f"Glue database {GLUE_DATABASE!r} is missing table(s): {', '.join(missing)}. "
"Run `python -m bluesky_ingestion_jetstream.aws.bootstrap` to create them."
)

return tables
Comment on lines +75 to +98

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add unit tests for the new Glue/Iceberg integration modules. Both catalog.py and bootstrap.py are new modules with nontrivial branching logic (missing-table aggregation, idempotent create-or-load) but ship without dedicated test files in this cohort; the shared root cause is that neither module's GlueCatalog/Table interaction is exercised by a mock-backed test.

  • bluesky_ingestion_jetstream/aws/catalog.py#L75-L98: add tests for load_tables covering the all-tables-present path and the missing-table aggregation path (MissingTablesError naming every missing record type), using a mocked GlueCatalog.
  • bluesky_ingestion_jetstream/aws/bootstrap.py#L71-L86: add tests for bootstrap covering the create-new-table branch and the TableAlreadyExistsError load-existing-table branch, using a mocked GlueCatalog.
📍 Affects 2 files
  • bluesky_ingestion_jetstream/aws/catalog.py#L75-L98 (this comment)
  • bluesky_ingestion_jetstream/aws/bootstrap.py#L71-L86
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@bluesky_ingestion_jetstream/aws/catalog.py` around lines 75 - 98, Add
mock-backed unit tests for bluesky_ingestion_jetstream/aws/catalog.py lines
75-98, covering load_tables when every RECORD_TYPES table loads successfully and
when multiple NoSuchTableError cases are aggregated into MissingTablesError
naming all missing types. Add tests for
bluesky_ingestion_jetstream/aws/bootstrap.py lines 71-86, covering bootstrap
creating a new table and handling TableAlreadyExistsError by loading the
existing table; exercise each branch through a mocked GlueCatalog and Table
interaction.

75 changes: 75 additions & 0 deletions bluesky_ingestion_jetstream/aws/constants.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
"""AWS identifiers and Iceberg table configuration for this pipeline."""

from bluesky_ingestion_jetstream.constants import RECORD_TYPES

AWS_REGION = "us-east-2"
S3_BUCKET = "lab-data-integrations-interface"
S3_PREFIX = "bluesky/raw"

# Created by Terraform (`terraform/bluesky_ingestion_jetstream/main.tf`).
GLUE_DATABASE = "bluesky_raw"

# One table per record type. Glue names cannot contain `/`, so the location is
# passed explicitly at creation.
TABLE_LOCATIONS = {
record_type: f"s3://{S3_BUCKET}/{S3_PREFIX}/{record_type}" for record_type in RECORD_TYPES
}

# Applied at table creation only; edits here do not reach existing tables.
TABLE_PROPERTIES = {
"format-version": "2",
"write.parquet.compression-codec": "zstd",
"write.target-file-size-bytes": str(256 * 1024 * 1024),
# Iceberg's default salts a hash into the data path; unnecessary at this
# volume, and it costs a browsable `created_at_day=.../` layout.
"write.object-storage.enabled": "false",
"write.metadata.delete-after-commit.enabled": "true",
"write.metadata.previous-versions-max": "100",
# Inert while ingestion is append-only. Set now for the duplicates backfill
# will introduce; PyIceberg cannot write delete files, so that merge needs
# Athena or Spark.
"write.delete.mode": "merge-on-read",
"write.update.mode": "merge-on-read",
"write.merge.mode": "merge-on-read",
}

# Iceberg's default name for a `day()` transform, as it appears on disk.
PARTITION_SOURCE_COLUMN = "created_at"
PARTITION_FIELD_NAME = "created_at_day"

# ---------------------------------------------------------------------------
# Client bounds
#
# Overrides PyIceberg's Glue defaults
# ---------------------------------------------------------------------------

GLUE_MAX_ATTEMPTS = 2
GLUE_CONNECT_TIMEOUT_SECONDS = 3.0
GLUE_READ_TIMEOUT_SECONDS = 10.0

# Passed to the PyArrow S3 filesystem (pyiceberg/io/pyarrow.py:445-448).
S3_CONNECT_TIMEOUT_SECONDS = 3.0
S3_REQUEST_TIMEOUT_SECONDS = 15.0

# ---------------------------------------------------------------------------
# Commit retry
#
# Three attempts, two sleeps
# ---------------------------------------------------------------------------

COMMIT_MAX_ATTEMPTS = 3
COMMIT_INITIAL_DELAY_SECONDS = 1.0
COMMIT_MAX_DELAY_SECONDS = 8.0

# Stamped on the snapshot, so a retry can tell a failed commit from a lost reply.
SNAPSHOT_FLUSH_ID_TAG = "flush_id"

# ---------------------------------------------------------------------------
# Dead letter
#
# Outside `S3_PREFIX`: orphan cleanup deletes unreferenced files under the
# warehouse root, and these are unreferenced by definition.
# ---------------------------------------------------------------------------

DEAD_LETTER_PREFIX = "dead_letter/bluesky/raw"
DEAD_LETTER_ROOT = f"{S3_BUCKET}/{DEAD_LETTER_PREFIX}"
Loading
Loading