Skip to content

Fold Git-for-data isolate into OpenHouseSparkCatalog - #11

Draft
cbb330 wants to merge 76 commits into
mainfrom
chbush/gfd-m1-openhouse-spark-catalog
Draft

Fold Git-for-data isolate into OpenHouseSparkCatalog#11
cbb330 wants to merge 76 commits into
mainfrom
chbush/gfd-m1-openhouse-spark-catalog

Conversation

@cbb330

@cbb330 cbb330 commented Aug 24, 2026

Copy link
Copy Markdown
Owner

Summary

  • Jobs use OpenHouseSparkCatalog (Spark 3.5 isolate + WAP enable; Spark 3.1 is the named SparkCatalog). GitForDataSparkCatalog is a deprecated subclass.
  • Tables REST body is unchanged. Clients stamp X-OH-Wap-Branch and Iceberg REST 1.11-shaped X-Iceberg-Ref (same value). Iceberg REST 1.11 carries ref / ref-name inside commit payloads; extra headers are ignored until OpenHouse speaks that body.
  • DROP / RENAME / GRANT on a non-main WAP branch skip house-table identity mutations. Catalogs enable write.wap.enabled; Iceberg creates a missing WAP ref on commit.

Testing Done

  • WapBranchTest reads X-Iceberg-Ref when the OpenHouse header is absent
  • Existing Git-for-data Spark 3.5 itests now load OpenHouseSparkCatalog via TestSparkSessionUtil

kamanavishnu and others added 30 commits May 11, 2026 17:24
)

## Summary

`OpenHouseDataLayoutStrategyGenerator` invokes several Spark terminal
actions (`count`, `collectAsList`, `toLocalIterator`) on the lazy
`Dataset<FileStat>` returned by `tableFileStats.get()`. Without caching,
every action independently materializes the Dataset by re-reading the
underlying Iceberg `system.files` manifest. In
`generateTableLevelStrategies`, that's 5-7 manifest reads per app run;
in `generatePartitionLevelStrategies` it's that figure multiplied by
partition count, since the loop calls `tableFileStats.get()` fresh per
partition.

This PR adds `.cache()` so the Dataset is materialized once and
subsequent actions read from the cached partitions instead of re-reading
the manifest. The action count is unchanged - this only makes each
action faster by eliminating the redundant scan work.

A try/finally `unpersist()` bounds the cache lifetime to the generator
invocation so the cached blocks are released promptly.

## Changes

- [ ] Client-facing API Changes
- [ ] Internal API Changes
- [ ] Bug Fixes
- [ ] New Features
- [x] Performance Improvements
- [ ] Code Style
- [ ] Refactoring
- [ ] Documentation
- [ ] Tests

### Details

In `OpenHouseDataLayoutStrategyGenerator.java`:

- `generateTableLevelStrategies`: wrap `tableFileStats.get()` with
`.cache()`, add `try/finally unpersist()`.
- `generatePartitionLevelStrategies`: cache the unfiltered Dataset
**outside** the per-partition loop; each per-partition filter now
derives from the cached Dataset rather than calling
`tableFileStats.get()` again.

No other logic changed. No API change.

## Testing Done

- [ ] Manually Tested on local docker setup. Please include commands
ran, and their output.
- [ ] Added new tests for the changes made.
- [ ] Updated existing tests to reflect the changes made.
- [x] No tests added or updated. Please explain why. If unsure, please
feel free to ask for help.
- [ ] Some other form of testing like staging or soak time in
production. Please explain.

The change is a transparent performance hint - the public API and every
observable output (every `DataLayoutStrategy` field) is unchanged.
Verified by running the existing suite:

- `./gradlew :libs:datalayout:test` - all 17 tests pass, including:
  - `OpenHouseDataLayoutStrategyGeneratorTest.testTableLevelStrategy`
-
`OpenHouseDataLayoutStrategyGeneratorTest.testTableLevelStrategyPartitioned`
- `OpenHouseDataLayoutStrategyGeneratorTest.testPartitionLevelStrategy`
  - `IntegrationTest.testCompactionStrategyGenerationNonPartitioned`
-
`IntegrationTest.testCompactionStrategyGenerationWithPersistencePartitioned`

No new tests added because there is no new behavior to assert. The win
is reduced per-action wall time, which only manifests against a real
Spark cluster.

## Additional Information

- [ ] Breaking Changes
- [ ] Deprecations
- [ ] Large PR broken into smaller PRs, and PR plan linked in the
description.
## Summary

Introduces the OpenTelemetry metrics scaffolding for the Python
dataloader. No instruments are emitted yet — this PR only adds the
plumbing so future PRs can register metric groups without touching
dependency, wiring, or test-harness code.

## Changes

- [ ] Client-facing API Changes
- [ ] Internal API Changes
- [ ] Bug Fixes
- [x] New Features
- Adds `openhouse.dataloader.metrics` package with `DataLoaderMetrics`
(holds a shared OTEL `Meter`) and `get_metrics()` (returns the default
or a custom-provider instance).
- Adds `opentelemetry-api` as a runtime dependency (no-op until an SDK
is configured by the consumer) and `opentelemetry-sdk` as a dev
dependency.
- [ ] Performance Improvements
- [ ] Code Style
- [ ] Refactoring
- [ ] Documentation
- [x] Tests
## Testing Done

- [ ] Manually Tested on local docker setup. Please include commands
ran, and their output.
- [x] Added new tests for the changes made.
- [ ] Updated existing tests to reflect the changes made.
- [ ] No tests added or updated. Please explain why. If unsure, please
feel free to ask for help.
- [ ] Some other form of testing like staging or soak time in
production. Please explain.

# Additional Information

- [ ] Breaking Changes
- [ ] Deprecations
- [x] Large PR broken into smaller PRs, and PR plan linked in the
description.
- Follow-up PRs will introduce concrete instrument groups (e.g. split
iteration metrics) on top of this scaffolding.
)

## Summary

Adds an `id` property to `OpenHouseDataLoader`. Each instance gets a
unique `dataloader-<uuid>` id at construction, useful for logging and
correlation.

## Changes

- [x] Client-facing API Changes
- [ ] Internal API Changes
- [ ] Bug Fixes
- [x] New Features
- [ ] Performance Improvements
- [ ] Code Style
- [ ] Refactoring
- [ ] Documentation
- [x] Tests

## Testing Done

- [x] Added new tests for the changes made.
- [ ] Updated existing tests to reflect the changes made.
- [ ] Manually Tested on local docker setup. Please include commands
ran, and their output.
- [ ] No tests added or updated. Please explain why. If unsure, please
feel free to ask for help.
- [ ] Some other form of testing like staging or soak time in
production. Please explain.

`make verify` passes (lint, format, mypy, 264 pytest).

# Additional Information

- [ ] Breaking Changes
- [ ] Deprecations
- [ ] Large PR broken into smaller PRs, and PR plan linked in the
description.
…din#509)

## Design Doc Summary

Full design doc:
https://docs.google.com/document/d/1PjuXS4xER-oA0Q06e-qUvkrh7tSfhj5h873f7nq1rEg

### Problem

Kafka ETL reported that OpenHouse `PUT /snapshots` (commit) p99 latency
is missing SLOs, sometimes exceeding 1 minute. Root cause:
`refreshMetadata` is called **6 times** per commit request, but **3 of
those are redundant real HDFS reads** of the same pre-commit metadata
file. This happens because each `loadTable()` creates a fresh
`TableOperations` instance with no shared cache.

The 6 refreshes break down as:

| # | Source | Real HDFS read? |
|---|--------|----------------|
| 1 | `TablesService.putTable` initial `findById` | Yes |
| 2 | `save()` redundant `existsById` | Yes |
| 3 | `save()` real `catalog.loadTable` | Yes |
| 4-5 | Transaction internal `ops.refresh()` | No (cheap no-ops) |
| 6 | Post-commit response building | Yes (new metadata) |

### Solution: TableMetadata Cache

- **7-day fleet analysis:** 33-38M metadata refreshes/day; ~28% are
duplicates within a 10-minute window
- A `TableMetadata` cache keyed by metadata location eliminates
redundant HDFS reads across `loadTable()` calls within the same pod

### Cache Configuration

| Property | Env var | Type | Default |
|---|---|---|---|
| `cluster.iceberg.tables.metadata-cache.enabled` |
`CLUSTER_ICEBERG_TABLES_METADATACACHE_ENABLED` | bool | `false` |
| `cluster.iceberg.tables.metadata-cache.max-weight` |
`CLUSTER_ICEBERG_TABLES_METADATACACHE_MAXWEIGHT` | DataSize (`2GB`,
`512MB`) | `512MB` |
| `cluster.iceberg.tables.metadata-cache.ttl` |
`CLUSTER_ICEBERG_TABLES_METADATACACHE_TTL` | Duration (`10m`, `30s`) |
`10m` |

Default-off rollout: deploy fleet-wide, flip
`CLUSTER_ICEBERG_TABLES_METADATACACHE_ENABLED=true` on a canary pod via
its env, watch the cache metrics, then promote the value into
`cluster.yaml` for the cluster. When `enabled=false`, the bean returns
Spring's `NoOpCacheManager` so `@Cacheable` methods always pass through.

`max-weight` defaults to `512MB` to fit the 1GB JVM heap used in lower
envs. Prod (4GB heap) can opt up to `2GB` via its `cluster.yaml`.

### Cache Metrics

| Metric | Source | What it tells you |
|---|---|---|
| `cache_gets_total{result="hit"\|"miss"}` | auto | Hit ratio = hit /
(hit+miss) |
| `cache_evictions_total` | auto | Eviction rate (cause unknown) |
| `cache_eviction_weight_total` | auto | Bytes evicted — confirms
weigher is live |
| `cache_size` | auto | Current weight in bytes |
| `cache_load_duration_seconds` | auto | Refresh latency on miss |
| `cache_load_total{result="success"\|"failure"}` | auto | Refresh
failure rate |
| `metadata_cache_removal_total{cause}` | custom | Splits evictions by
`SIZE` / `EXPIRED` / `REPLACED` / `EXPLICIT` / `COLLECTED` — tells you
*why* to resize |

### Before/After Validation

- Traces are structurally identical (42 spans) — the cache only reduces
latency within spans by avoiding redundant HDFS I/O
- AFTER eliminates 8 HDFS connection DEBUG lines (24 → 16)
- Single-sample latency dropped from 1643ms → 560ms; production-scale
validation expected to show larger gains due to HDFS p90 variance

## Changes

- [ ] Client-facing API Changes
- [x] Internal API Changes
- [ ] Bug Fixes
- [ ] New Features
- [x] Performance Improvements
- [ ] Code Style
- [x] Refactoring
- [ ] Documentation
- [x] Tests

For all the boxes checked, please include additional details of the
changes made in this pull request.

Internal API Changes:
- Introduce `TableMetadataCache` interface and
`SpringTableMetadataCache` implementation; wire a Spring `CacheManager`
bean (`internalCatalogCacheManager`) backed by Caffeine.
- Bound the cache by byte weight (Caffeine `Weigher` over
`TableMetadataParser.toJson` length) instead of entry count, exposed via
`cluster.iceberg.tables.metadata-cache.max-weight`.

Performance Improvements:
- Eliminates redundant HDFS `TableMetadataParser.read` calls across
`loadTable()` instances within a pod.

Refactoring:
- Add `RemovalListener` that emits a Micrometer counter tagged by
`RemovalCause`, complementing Spring Boot's auto-instrumented `cache_*`
metrics.
- Gate the whole cache on `metadata-cache.enabled`; when off, the bean
is a `NoOpCacheManager` and no Caffeine resources are allocated.

Tests:
- New: `CacheConfigurationTest` covers default-disabled,
enabled-with-overrides, and the `@Cacheable` round-trip;
`InternalCatalogBeansTest` covers property propagation and the
cross-module bean wiring for both modes.

## Testing Done

- [x] Manually Tested on local docker setup. Please include commands
ran, and their output.
- [x] Added new tests for the changes made.
- [x] Updated existing tests to reflect the changes made.
- [ ] No tests added or updated. Please explain why. If unsure, please
feel free to ask for help.
- [ ] Some other form of testing like staging or soak time in
production. Please explain.

For all the boxes checked, include a detailed description of the testing
done for the changes made in this pull request.

Manually tested on local docker setup
(`infra/recipes/docker-compose/oh-only`):
- Brought up compose, hit `/actuator/prometheus` on the tables service:
all seven cache metrics in the table above appeared with
`cache="tableMetadata"` tags.
- Created/read tables under a tightened `max-weight=4KB`; observed
`cache_evictions_total = metadata_cache_removal_total{cause="SIZE"} =
12`, confirming the listener fires with the correct cause tag for every
eviction.

Validation run:
`JAVA_HOME=$(/usr/libexec/java_home -v 11) ./gradlew
:iceberg:openhouse:internalcatalog:test :services:tables:test`

# Additional Information

- [ ] Breaking Changes
- [ ] Deprecations
- [ ] Large PR broken into smaller PRs, and PR plan linked in the
description.

For all the boxes checked, include additional details of the changes
made in this pull request.
…#582)

## Summary
DataLoader reads have no built-in observability today, so callers cannot
track read latency, retries, or errors. This PR emits OpenTelemetry
metrics from the DataLoader so operators can monitor table-reading
workloads.

## Changes

- [ ] Client-facing API Changes
- [ ] Internal API Changes
- [ ] Bug Fixes
- [x] New Features
- [ ] Performance Improvements
- [ ] Code Style
- [ ] Refactoring
- [ ] Documentation
- [x] Tests

For all the boxes checked, please include additional details of the
changes made in this pull request.

## Testing Done
<!--- Check any relevant boxes with "x" -->

- [ ] Manually Tested on local docker setup. Please include commands
ran, and their output.
- [x] Added new tests for the changes made.
- [ ] Updated existing tests to reflect the changes made.
- [ ] No tests added or updated. Please explain why. If unsure, please
feel free to ask for help.
- [ ] Some other form of testing like staging or soak time in
production. Please explain.

For all the boxes checked, include a detailed description of the testing
done for the changes made in this pull request.

# Additional Information

- [ ] Breaking Changes
- [ ] Deprecations
- [ ] Large PR broken into smaller PRs, and PR plan linked in the
description.

For all the boxes checked, include additional details of the changes
made in this pull request.
## Summary

Loosens the floor on `opentelemetry-api` / `opentelemetry-sdk` from
`>=1.41.1` to `>=1.38.0` in the `openhouse-dataloader` package. The
current pin demands a release line that is too new for many consumer
environments.

## Changes

- [ ] Client-facing API Changes
- [ ] Internal API Changes
- [x] Bug Fixes
- [ ] New Features
- [ ] Performance Improvements
- [ ] Code Style
- [ ] Refactoring
- [ ] Documentation
- [ ] Tests

## Testing Done

- [ ] Manually Tested on local docker setup. Please include commands
ran, and their output.
- [ ] Added new tests for the changes made.
- [ ] Updated existing tests to reflect the changes made.
- [x] No tests added or updated. Please explain why. If unsure, please
feel free to ask for help.
- [ ] Some other form of testing like staging or soak time in
production. Please explain.

Dependency-floor change only — no behavioral change. Validated with
`make verify` (ruff lint + format check, mypy, 280 pytest tests) against
the regenerated lockfile resolving `opentelemetry-{api,sdk}` to 1.38.0;
all green.

# Additional Information

- [ ] Breaking Changes
- [ ] Deprecations
- [ ] Large PR broken into smaller PRs, and PR plan linked in the
description.
…inkedin#527)

## Optimizer Stack

| PR | Content |
|---|---|
| linkedin#527 **(this)** | API and
internal models |
| linkedin#530 | Database Repos |
| linkedin#531 | REST service |
| linkedin#533 | Analyzer app |
| linkedin#534 | Scheduler app |
| #tbd | Spark BatchedOFD app |
| #tbd | Infra, docker-compose, smoke test |

## Summary

PR 0 of N in the optimizer stack. 
[Overall
Project](https://docs.google.com/document/d/1oGQWkmlVw0HG-D4Nx37q0oUEQd53Ni4q5Q7h1voaZIQ/edit?tab=t.0#heading=h.vtl818e4m9f7)
[Service Design
doc](https://docs.google.com/document/d/1xYOD7iuPjZO05UWfT1Dkmf4lYNw4iOjqY10UT2X1FAg/edit?tab=t.0).

Introduces the optimizer service API and internal model

<img width="631" height="410" alt="image"
src="https://github.com/user-attachments/assets/8833471d-069a-49a2-9a10-5eb7f3b96a72"
/>

## Changes

- [ ] Client-facing API Changes
- [x] Internal API Changes
- [ ] Bug Fixes
- [x] New Features
- [ ] Performance Improvements
- [ ] Code Style
- [ ] Refactoring
- [ ] Documentation
- [ ] Tests

## Testing Done

- [ ] Manually Tested on local docker setup. Please include commands
ran, and their output.
- [ ] Added new tests for the changes made.
- [ ] Updated existing tests to reflect the changes made.
- [x] No tests added or updated. Please explain why. If unsure, please
feel free to ask for help.
- [ ] Some other form of testing like staging or soak time in
production. Please explain.

This PR contains only the data model (entities, DTOs, converters).
Repository tests follow in PR 1. Verified:
- `./gradlew :services:optimizer:compileJava` passes
- `./gradlew compileJava` (full project) passes with no regressions
- Spotless formatting passes

# Additional Information

- [ ] Breaking Changes
- [ ] Deprecations
- [x] Large PR broken into smaller PRs, and PR plan linked in the
description.

---------

Co-authored-by: mkuchenbecker <mkuchenbecker@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
## Summary
This empty commit is in order to trigger the ELR process.

## Changes

- [ ] Client-facing API Changes
- [ ] Internal API Changes
- [ ] Bug Fixes
- [ ] New Features
- [ ] Performance Improvements
- [ ] Code Style
- [ ] Refactoring
- [ ] Documentation
- [ ] Tests

For all the boxes checked, please include additional details of the
changes made in this pull request.

## Testing Done
<!--- Check any relevant boxes with "x" -->

- [ ] Manually Tested on local docker setup. Please include commands
ran, and their output.
- [ ] Added new tests for the changes made.
- [ ] Updated existing tests to reflect the changes made.
- [ ] No tests added or updated. Please explain why. If unsure, please
feel free to ask for help.
- [ ] Some other form of testing like staging or soak time in
production. Please explain.

For all the boxes checked, include a detailed description of the testing
done for the changes made in this pull request.

# Additional Information

- [ ] Breaking Changes
- [ ] Deprecations
- [ ] Large PR broken into smaller PRs, and PR plan linked in the
description.

For all the boxes checked, include additional details of the changes
made in this pull request.

Co-authored-by: Levi Jiang <lejiang@lejiang-mn2962.linkedin.biz>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
linkedin#594)

## Summary
This is the follow up PR of
linkedin#542. After the fix, we were
able to replace the table once but not multiple times. RC anslysis:
1. `OpenHouseInternalTableOperations` uses `metadata.location()` for the
new HTS tableLocation, and that value comes from
`.withLocation(tableLocation)` that we pass, so we need to make sure
it's schemeless.
2. We used `tableDto.getTableVersion()` to populate
`.withLocation(tableLocation)`, but tableDto always contains scheme.
(Why is tableLocation = tableVersion in tableDto? Because tableVersion
comes from client request, and client request uses the tableLocation
from the server response, so they will always be same and contain
scheme)
3. The last PR fixes the tableLocation in table properties, so the new
tableVersion for the first replace will be schemeless. But since the new
tableLocation has scheme, the next replace will fail.

Therefore, in this PR, we use the tableLocation from the table
properties that we had just stripped in the last PR for
`.withLocation(tableLocation)`.

## Changes

- [ ] Client-facing API Changes
- [ ] Internal API Changes
- [x] Bug Fixes
- [ ] New Features
- [ ] Performance Improvements
- [ ] Code Style
- [ ] Refactoring
- [ ] Documentation
- [ ] Tests

For all the boxes checked, please include additional details of the
changes made in this pull request.

## Testing Done
<!--- Check any relevant boxes with "x" -->

- [x] Manually Tested on local docker setup. Please include commands
ran, and their output.
- [ ] Added new tests for the changes made.
- [x] Updated existing tests to reflect the changes made.
- [ ] No tests added or updated. Please explain why. If unsure, please
feel free to ask for help.
- [ ] Some other form of testing like staging or soak time in
production. Please explain.

**Unit tests**
I removed `stripPathScheme` in the spark E2E tests to gurantee that
scheme issues can be captured by unit tests.

**Test 1: RTAS**
```
scala> spark.sql(s"CREATE TABLE $tableName TBLPROPERTIES ('prop1'='val1', 'prop2'='val2') AS SELECT * FROM $sourceName");
res3: org.apache.spark.sql.DataFrame = []                                       

scala> spark.sql(s"INSERT INTO $tableName values (4, 'd')");
res4: org.apache.spark.sql.DataFrame = []

scala> spark.sql(s"REPLACE TABLE $tableName PARTITIONED BY (part) TBLPROPERTIES ('prop1'='newval1', 'prop3'='val3') AS SELECT id, data, CASE WHEN (id % 2) = 0 THEN 'even' ELSE 'odd' END AS part FROM $sourceName ORDER BY 3, 1");
res5: org.apache.spark.sql.DataFrame = []

scala> spark.sql(s"REPLACE TABLE $tableName PARTITIONED BY (part) AS SELECT 2 * id as id, data, CASE WHEN ((2 * id) % 2) = 0 THEN 'even' ELSE 'odd' END AS part FROM $sourceName ORDER BY 3, 1");
res12: org.apache.spark.sql.DataFrame = []

scala> spark.sql(s"SELECT * FROM $tableName").show(false)
+---+----+----+
|id |data|part|
+---+----+----+
|2  |a   |even|
|4  |b   |even|
|6  |c   |even|
+---+----+----+
```
**Test 2: CRTAS**
```
scala> spark.sql(s"CREATE OR REPLACE TABLE $tableName TBLPROPERTIES ('prop1'='val1', 'prop2'='val2') AS SELECT * FROM $sourceName");
res19: org.apache.spark.sql.DataFrame = []

scala> spark.sql(s"INSERT INTO $tableName values (4, 'd')");
res20: org.apache.spark.sql.DataFrame = []

scala> spark.sql(s"CREATE OR REPLACE TABLE $tableName PARTITIONED BY (part) AS SELECT id, data, CASE WHEN id % 2 = 0 THEN 'even' ELSE 'odd' END AS part FROM $sourceName ORDER BY 3, 1");
res21: org.apache.spark.sql.DataFrame = []

scala> spark.sql(s"CREATE OR REPLACE TABLE $tableName PARTITIONED BY (part) AS SELECT 2 * id as id, data, CASE WHEN ((2 * id) % 2) = 0 THEN 'even' ELSE 'odd' END AS part FROM $sourceName ORDER BY 3, 1");
res22: org.apache.spark.sql.DataFrame = []

scala> spark.sql(s"SELECT * FROM $tableName").show(false)
+---+----+----+
|id |data|part|
+---+----+----+
|2  |a   |even|
|4  |b   |even|
|6  |c   |even|
+---+----+----+
```
# Additional Information

- [ ] Breaking Changes
- [ ] Deprecations
- [ ] Large PR broken into smaller PRs, and PR plan linked in the
description.

For all the boxes checked, include additional details of the changes
made in this pull request.

---------

Co-authored-by: Levi Jiang <lejiang@lejiang-mn2962.linkedin.biz>
## Optimizer Stack

| PR | Content |
|---|---|
| linkedin#527 | Data Model |
| linkedin#530 **(this)** | Database
Repos |
| linkedin#531 | REST service |
| linkedin#533 | Analyzer app |
| linkedin#534 | Scheduler app |
| #tbd | Spark BatchedOFD app |
| #tbd | Infra, docker-compose, smoke test |

## Summary

PR 1 of N in the optimizer stack. 
[Overall
Project](https://docs.google.com/document/d/1oGQWkmlVw0HG-D4Nx37q0oUEQd53Ni4q5Q7h1voaZIQ/edit?tab=t.0#heading=h.vtl818e4m9f7)
[Service Design
doc](https://docs.google.com/document/d/1xYOD7iuPjZO05UWfT1Dkmf4lYNw4iOjqY10UT2X1FAg/edit?tab=t.0).


Spring Data JPA repositories for all four optimizer tables with filtered
query support, plus tests exercising save/find, filtered queries, upsert
semantics, and append-only history.

## Changes

- [ ] Client-facing API Changes
- [ ] Internal API Changes
- [ ] Bug Fixes
- [x] New Features
- [ ] Performance Improvements
- [ ] Code Style
- [ ] Refactoring
- [ ] Documentation
- [x] Tests

**Repositories**: `TableOperationsRepository`,
`TableOperationsHistoryRepository`, `TableStatsRepository`,
`TableStatsHistoryRepository` — each with JPQL filtered query methods.

**Tests**: Repository tests for all four tables plus
`OptimizerServiceContextTest` verifying the Spring context loads.

## Testing Done

- [ ] Manually Tested on local docker setup. Please include commands
ran, and their output.
- [x] Added new tests for the changes made.
- [ ] Updated existing tests to reflect the changes made.
- [ ] No tests added or updated. Please explain why. If unsure, please
feel free to ask for help.
- [ ] Some other form of testing like staging or soak time in
production. Please explain.

`./gradlew :services:optimizer:test` — all tests pass (H2 in MySQL
mode).

# Additional Information

- [ ] Breaking Changes
- [ ] Deprecations
- [x] Large PR broken into smaller PRs, and PR plan linked in the
description.

---------

Co-authored-by: mkuchenbecker <mkuchenbecker@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
…n#531)

## Optimizer Stack

| PR | Content |
|---|---|
| linkedin#527 | Data Model |
| linkedin#530 | Database Repos |
| linkedin#531 **(this)** | REST
service |
| linkedin#533 | Analyzer app |
| linkedin#534 | Scheduler app |
| #tbd | Spark BatchedOFD app |
| #tbd | Infra, docker-compose, smoke test |

## Summary

PR 2 of N in the optimizer stack. 

Service layer and REST controllers for the optimizer service, plus the
`apps/optimizer` shared module providing lightweight entity/repo copies
for the analyzer and scheduler apps.

## Changes

- [ ] Client-facing API Changes
- [x] Internal API Changes
- [ ] Bug Fixes
- [x] New Features
- [ ] Performance Improvements
- [ ] Code Style
- [ ] Refactoring
- [ ] Documentation
- [x] Tests

**Service layer**: `OptimizerDataService` interface and
`OptimizerDataServiceImpl` — CRUD operations, complete-operation
lifecycle, stats upsert with history double-write, filtered queries.

**Controllers**: `TableOperationsController`,
`TableOperationsHistoryController`, `TableStatsController` — REST
endpoints per the design doc API spec.

**Shared module** (`apps/optimizer`): Lightweight entity and repository
copies used by the analyzer and scheduler apps to read optimizer state
directly from MySQL.

## Testing Done

- [ ] Manually Tested on local docker setup. Please include commands
ran, and their output.
- [x] Added new tests for the changes made.
- [ ] Updated existing tests to reflect the changes made.
- [ ] No tests added or updated. Please explain why. If unsure, please
feel free to ask for help.
- [ ] Some other form of testing like staging or soak time in
production. Please explain.

H2 integration tests in `OptimizerDataServiceImplTest` (5 tests):
- `completeOperation_writesHistoryFromOperationRow` — saves SCHEDULED
row, completes it, asserts history DTO fields
- `completeOperation_notFound_returnsEmpty` — completes nonexistent ID,
asserts empty
- `upsertTableStats_createsNewRow` — upserts new table, asserts DTO and
repo row
- `upsertTableStats_updatesExistingRow` — upserts twice, asserts
overwrite with single row
- `upsertTableStats_appendsHistoryOnEveryCall` — upserts twice, asserts
2 history rows

```
./gradlew :services:optimizer:test
# BUILD SUCCESSFUL — all 25 tests pass (repo tests from PR 1 + 5 new service tests)
```

# Additional Information

- [ ] Breaking Changes
- [ ] Deprecations
- [x] Large PR broken into smaller PRs, and PR plan linked in the
description.

---------

Co-authored-by: mkuchenbecker <mkuchenbecker@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
## Summary

Dropping a table currently goes through `catalog.loadTable` twice:
1. `TablesServiceImpl.deleteTable` → `findById` parses metadata.json to
build a `TableDto` for the ACL check.
2. `OpenHouseInternalCatalog.dropTable` → `loadTable(id).location()`
parses metadata.json again to get the table base directory for
`fileIO.deletePrefix` on purge.

Both parses are redundant — everything drop needs (`databaseId`,
`tableId`, `tableUUID`, the metadata.json path) is already on the HTS
row. The redundant parses also make drop fragile: if metadata is
semantically invalid (e.g. `Cannot find schema with current-schema-id=6
from schemas`), drop fails even though the HTS row and storage files are
still well-formed.

This PR removes both `loadTable` calls from the drop path:

- **`OpenHouseInternalCatalog#findHouseTable(TableIdentifier)`** —
HTS-only lookup that returns the `HouseTable` row, no metadata parse.
- **`OpenHouseInternalRepository#findStubById(TableDtoPrimaryKey)`** —
returns a partial `TableDto` (`databaseId`, `tableId`, `tableUUID`,
`tableLocation`) built from the HTS row. Enough for the OPA auth check,
which only uses db + UUID.
- **`TablesServiceImpl#deleteTable`** uses `findStubById` instead of
`findById`.
- **`OpenHouseInternalCatalog#dropTable`** uses `findHouseTable` and
derives the storage base location as the parent directory of the
metadata.json path — same derivation
`OpenHouseInternalRepositoryImpl#save` already uses in its replace flow,
since OpenHouse writes `metadata.json` directly under `<base>/` (no
`/metadata/` subdir).

A side benefit: corrupted-metadata tables can now be dropped end-to-end
through the normal API.

## Changes

- [ ] Client-facing API Changes
- [ ] Internal API Changes
- [ ] Bug Fixes
- [ ] New Features
- [ ] Performance Improvements
- [ ] Code Style
- [X] Refactoring
- [ ] Documentation
- [ ] Tests

For all the boxes checked, please include additional details of the
changes made in this pull request.

## Testing Done
<!--- Check any relevant boxes with "x" -->

- [ ] Manually Tested on local docker setup. Please include commands
ran, and their output.
- [X] Added new tests for the changes made.
- [ ] Updated existing tests to reflect the changes made.
- [ ] No tests added or updated. Please explain why. If unsure, please
feel free to ask for help.
- [ ] Some other form of testing like staging or soak time in
production. Please explain.

For all the boxes checked, include a detailed description of the testing
done for the changes made in this pull request.

# Additional Information

- [ ] Breaking Changes
- [ ] Deprecations
- [ ] Large PR broken into smaller PRs, and PR plan linked in the
description.

For all the boxes checked, include additional details of the changes
made in this pull request.

---------

Co-authored-by: Dushyant Kumar <dukumar@linkedin.biz>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…zer (linkedin#569 follow-up) (linkedin#583)

## Summary

PR linkedin#569 added datetime/date/time support to `filters._literal_to_sql()`
so the Python → DataFusion SQL forward direction emits e.g.
`CAST('2026-05-02 00:00:00.000000+0000' AS TIMESTAMP)`.

However the reverse direction in `scan_optimizer._convert_comparison()`
only handles bare `exp.Literal` nodes. When sqlglot parses the SQL back,
the value is an `exp.Cast(exp.Literal, DataType(TIMESTAMP))` — so
`isinstance(right, exp.Literal)` returns `False` and the comparison gets
**silently dropped** from the pushdown `row_filter`.

The result, for Iceberg tables partitioned by `day(timestamp_col)`, is
that the datetime filter never reaches PyIceberg's manifest pruning.
Every file in the snapshot gets scanned regardless of the predicate.
Observed in production via a `dalids://` table: requesting
`datepartition >= '2026-05-02' AND datepartition < '2026-05-04'` ended
up reading whichever partitions happened to be at the head of the
snapshot (`datepartition_day=2026-05-08` in our case).

## Changes

- [ ] Client-facing API Changes
- [ ] Internal API Changes
- [x] Bug Fixes
- [ ] New Features
- [ ] Performance Improvements
- [ ] Code Style
- [ ] Refactoring
- [ ] Documentation
- [x] Tests

This patch extends `_convert_comparison` + `_literal_to_python` in
`scan_optimizer.py` to also accept `Cast(Literal, TIMESTAMP/DATE/TIME)`
and convert it back to the matching Python `datetime`/`date`/`time`
value, restoring partition pushdown.

## Testing Done

- [x] Added new tests for the changes made.

Added `test_cast_timestamp_pushed_as_datetime` covering `TIMESTAMP`
(with tz), `TIMESTAMP` (without tz), and `DATE`. All three round-trip to
the expected datetime/date `Filter`.

Manually verified end-to-end: with the patch, an Iceberg scan with a
daily-partitioned table only opens manifests whose partition value
matches the datetime filter (instead of every manifest in the snapshot).

## Notes for Reviewers

Direct follow-up to linkedin#569 — they go together. Without this fix, that PR's
forward-direction support produces a SQL string that the scan optimizer
can't reverse, so partition pushdown silently regresses for datetime
predicates. The change is small and isolated to `scan_optimizer.py`.
## Optimizer Stack

| PR | Content |
|---|---|
| linkedin#527 | Data Model |
| linkedin#530 | Database Repos |
| linkedin#531 | REST service |
| linkedin#533 **(this)** | Analyzer
app |
| linkedin#534 | Scheduler app |
| linkedin#599 | Spark BatchedOFD app
|
| #tbd | Infra, docker-compose, smoke test |

## Summary

PR 3 of N in the optimizer stack.

Introduces `apps/optimizer-analyzer`, a Spring Boot CommandLineRunner
that evaluates every table in `table_stats` against pluggable
`OperationAnalyzer` strategies. The first strategy,
`OrphanFilesDeletionAnalyzer`, schedules OFD operations with 24h success
/ 1h failure retry cadence, a 6h SCHEDULED timeout, and a 5-strike
circuit breaker.

Key design choices:
- Bulk-loads operations and history into maps (one query per type), then
iterates the stats list — O(types) queries, not O(tables).
- Uses the existing generic `find()` repository methods with null
params.
- Pure unit tests with Mockito — no Spring context needed.

## Changes

- [ ] Client-facing API Changes
- [ ] Internal API Changes
- [ ] Bug Fixes
- [x] New Features
- [ ] Performance Improvements
- [ ] Code Style
- [ ] Refactoring
- [ ] Documentation
- [x] Tests

**Core**: `AnalyzerRunner` — loads table_stats, pre-loads operations and
history into maps, evaluates each table against all analyzers, circuit
breaker logic.

**Strategy interface**: `OperationAnalyzer` — `isEnabled(table)`,
`shouldSchedule(table, currentOp, latestHistory)`,
`getCircuitBreakerThreshold()`.

**Cadence policy**: `CadencePolicy` — encapsulates time-based retry
logic shared across operation types.

**OFD analyzer**: `OrphanFilesDeletionAnalyzer` — enabled via
`maintenance.optimizer.ofd.enabled` table property.

## Testing Done

- [ ] Manually Tested on local docker setup. Please include commands
ran, and their output.
- [x] Added new tests for the changes made.
- [ ] Updated existing tests to reflect the changes made.
- [ ] No tests added or updated. Please explain why. If unsure, please
feel free to ask for help.
- [ ] Some other form of testing like staging or soak time in
production. Please explain.

25 unit tests:
- `AnalyzerRunnerTest` (7 tests) — eligible table insertion, cadence
skip, disabled table, shouldSchedule=false, null UUID, circuit breaker
trip, below-threshold pass
- `OrphanFilesDeletionAnalyzerTest` (18 tests) — isEnabled variants,
shouldSchedule for no-op/PENDING/SCHEDULING/SCHEDULED with history
combinations

```
./gradlew :apps:optimizer-analyzer:test
# BUILD SUCCESSFUL — 25 tests pass
```

# Additional Information

- [ ] Breaking Changes
- [ ] Deprecations
- [x] Large PR broken into smaller PRs, and PR plan linked in the
description.

---------

Co-authored-by: mkuchenbecker <mkuchenbecker@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Abhishek Nath <anath1@linkedin.com>
## Summary

Reverts commit `637896219c53cad672a4a3d381c1dad302971854` — "Support
returning HTS fields in table list api (linkedin#579)".

The revert conflicted with linkedin#589 (merged after linkedin#579) in
`OpenHouseInternalCatalog.java`. To make the conflict visible to the
reviewer, this PR is split into two commits:

1. **Revert with conflict markers preserved** (`a15b0da5`) — raw `git
revert` output, `OpenHouseInternalCatalog.java` left with `<<<<<<<` /
`=======` / `>>>>>>>` markers. Committed with `--no-verify` because the
markers fail spotless.
2. **Resolve conflict** (`387dd773`) — keep `findHouseTable` (from linkedin#589,
still used by the drop path), drop `listHouseTables` (from linkedin#579, the new
addition being reverted).

The intent of the split is to let the reviewer inspect what conflicted,
then see exactly how it was resolved.

## Changes

- [X] Client-facing API Changes
- [ ] Internal API Changes
- [ ] Bug Fixes
- [ ] New Features
- [ ] Performance Improvements
- [ ] Code Style
- [X] Refactoring
- [ ] Documentation
- [X] Tests

Reverts the public list-table API additions and the supporting tests
from linkedin#579.

## Testing Done

- [ ] Manually Tested on local docker setup. Please include commands
ran, and their output.
- [ ] Added new tests for the changes made.
- [X] Updated existing tests to reflect the changes made.
- [ ] No tests added or updated. Please explain why. If unsure, please
feel free to ask for help.
- [ ] Some other form of testing like staging or soak time in
production. Please explain.

Tests added by linkedin#579 are removed as part of the revert. Relying on CI to
confirm the remaining suite (including the linkedin#589 drop-path tests that
depend on `findHouseTable`) still passes.

# Additional Information

- [ ] Breaking Changes
- [ ] Deprecations
- [ ] Large PR broken into smaller PRs, and PR plan linked in the
description.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: mkuchenbecker <mkuchenbecker@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## Summary

Trigger build

## Testing Done
build

- [ ] Manually Tested on local docker setup. Please include commands
ran, and their output.
- [ ] Added new tests for the changes made.
- [ ] Updated existing tests to reflect the changes made.
- [ ] No tests added or updated. Please explain why. If unsure, please
feel free to ask for help.
- [ ] Some other form of testing like staging or soak time in
production. Please explain.

For all the boxes checked, include a detailed description of the testing
done for the changes made in this pull request.

# Additional Information

- [ ] Breaking Changes
- [ ] Deprecations
- [ ] Large PR broken into smaller PRs, and PR plan linked in the
description.

For all the boxes checked, include additional details of the changes
made in this pull request.
…edin#619)

Reverts the 17 commits that landed on main after v0.5.417, bringing the
tree back to exactly the v0.5.417 state. Squashed into a single revert
commit for reviewability and to allow reinstating everything as one unit
(revert this commit to bring all 17 changes back).

Reverted commits (v0.5.417..main, newest first):
- Revert linkedin#579 (HTS fields in table list api) (linkedin#610)
- feat(optimizer): [3/N] Analyzer (linkedin#533)
- [DataLoader] Handle Cast(Literal, TIMESTAMP/DATE/TIME) in scan
optimizer (linkedin#569 follow-up) (linkedin#583)
- Skip metadata.json parse in drop path (linkedin#589)
- feat(optimizer): [2/N] Optimizer REST Service and Controller (linkedin#531)
- [BDP-102028] feat(optimizer): [1/N] Optimizer Database (linkedin#530)
- [RTAS]: Fix bug - remove fs scheme from tableLocation in commit (cont)
(linkedin#594)
- Trigger ELR process (linkedin#593)
- [BDP-102028] feat(optimizer): [0/N] Optimizer API and internal model
(linkedin#527)
- Fail retention app when the columnPattern mismatch partition spec
(linkedin#552)
- [DataLoader] Drop OpenTelemetry minimum version to 1.38.0 (linkedin#590)
- [DataLoader] Emit OpenTelemetry metrics for read operations (linkedin#582)
- Cache iceberg metadata to reduce redundant requests to storage (linkedin#509)
- bump iceberg 1.2 version to 1.2.0.17 (linkedin#587)
- Support returning HTS fields in table list api (linkedin#579)
- [DataLoader] Add unique id property to OpenHouseDataLoader (linkedin#580)
- [DataLoader] Add OpenTelemetry metrics support (linkedin#575)

## Summary

<!--- HINT: Replace #nnn with corresponding Issue number, if you are
fixing an existing issue -->

[Issue](https://github.com/linkedin/openhouse/issues/#nnn)] Briefly
discuss the summary of the changes made in this
pull request in 2-3 lines.

## Changes

- [ ] Client-facing API Changes
- [ ] Internal API Changes
- [ ] Bug Fixes
- [ ] New Features
- [ ] Performance Improvements
- [ ] Code Style
- [ ] Refactoring
- [ ] Documentation
- [ ] Tests

For all the boxes checked, please include additional details of the
changes made in this pull request.

## Testing Done
<!--- Check any relevant boxes with "x" -->

- [ ] Manually Tested on local docker setup. Please include commands
ran, and their output.
- [ ] Added new tests for the changes made.
- [ ] Updated existing tests to reflect the changes made.
- [ ] No tests added or updated. Please explain why. If unsure, please
feel free to ask for help.
- [ ] Some other form of testing like staging or soak time in
production. Please explain.

For all the boxes checked, include a detailed description of the testing
done for the changes made in this pull request.

# Additional Information

- [ ] Breaking Changes
- [ ] Deprecations
- [ ] Large PR broken into smaller PRs, and PR plan linked in the
description.

For all the boxes checked, include additional details of the changes
made in this pull request.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Summary

Adds a catalog-level CAS in `OpenHouseInternalTableOperations.doCommit`
that aborts when the writer's declared base (`COMMIT_KEY`) does not
match the catalog's current persisted base — closing the
`BaseTransaction.applyUpdates` silent-rebase variant of the stale-base
lost-update bug (incident-12185).

## Mechanism

1. A writer's transaction stages `COMMIT_KEY = T_X` and a
`SNAPSHOTS_JSON_KEY` payload computed against `T_X`.
2. A racing commit advances the catalog `T_X → T_Y`, adding a snapshot.
3. `BaseTransaction.applyUpdates` silently refreshes the in-flight base
to `T_Y` and re-applies the staged update — re-stamping `COMMIT_KEY =
T_X` on top of `T_Y` while leaving the stale `SNAPSHOTS_JSON_KEY`.
4. `doCommit` runs with `base = T_Y` but `COMMIT_KEY = T_X`. Without the
check, the subtractive snapshot merge computes `toRemove =
T_Y.snapshots() − stale payload = {racing snapshot}` and silently drops
it.

The fix reads `COMMIT_KEY` before `failIfRetryUpdate` strips it,
URI-normalizes both paths via Hadoop `Path`, and throws
`CommitFailedException` on mismatch so Iceberg retries against the fresh
base.

## Scope of the check

- **Aborts** when `COMMIT_KEY` is a concrete location differing from the
catalog base, or is `INITIAL_VERSION`.
- **Does not defend** commits that leave `COMMIT_KEY` unset — wholesale
replace/create (`replaceTable`, stage-create, stage-replace) are
authoritative over the snapshot set, so there is no stale base to
compare against.

## Changes

Bug fix + unit test, internal catalog only (2 files). `doCommit` may now
throw `CommitFailedException` on a stale-base commit that previously
silently dropped a racing snapshot.

## Testing

Unit test `testDoCommitMustAbortStaleBaseRebaseToPreventSnapshotLoss` in
`OpenHouseInternalTableOperationsTest` round-trips the post-refresh
`TableMetadata` through `TableMetadataParser` so
`base.metadataFileLocation()` is non-null (matching a loaded-from-disk
base) and the URI-normalized comparison runs. Asserts
`CommitFailedException` is thrown and `houseTableRepository.save` is
never invoked.

```
./gradlew :iceberg:openhouse:internalcatalog:test --tests "*OpenHouseInternalTableOperationsTest"
```

### Not covered

A Spark concurrent-insert behavior test
(`SparkConcurrentInsertFunctionalTest`, PR linkedin#614) was explored and
removed: it only reproduces against the H2 test fixture, not production
MySQL+HTS. A prod-realistic black-box repro would need the real HTS app
or a deployed instance.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: mkuchenbecker <mkuchenbecker@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Summary

Introduce an `all-modules` aggregate module that transitively depends on
every published OpenHouse module.

**Problem:** ELR (External Library Registry) at LinkedIn requires
onboarding and monitoring each artifact individually. With 24+ OpenHouse
modules per release, all 24 ELR checks must pass before a dependency
bump can proceed. This creates operational overhead and makes
non-happy-path failures harder to isolate and track.

**Solution:** `all-modules` provides a single artifact that covers the
full OpenHouse module set via transitive dependency resolution.

**How it is used in practice:** In our downstream LinkedIn dependency
(`li-openhouse`), `all-modules` is declared in the `external` block of
`product-spec.json` — the registry ELR scans to enumerate tracked
artifacts. ELR resolves `all-modules` transitively and sees all
OpenHouse modules through that single entry. The individual 24 ELR
registrations (e.g. `openhouse_tables-test-fixtures`,
`openhouse_storage`, etc.) can then be decommissioned.

During build time, `all-modules` is **not** placed on any classpath.
Submodules continue to resolve their specific OpenHouse dependencies
directly by GAV coordinate. The separation is intentional: `all-modules`
is a tracking artifact for ELR, not a runtime dependency.

## Changes

- [x] New Features

`all-modules/build.gradle` — new aggregate module that dynamically
depends on all leaf subprojects, following the same pattern used by the
[Venice](https://github.com/linkedin/venice) project.
`settings.gradle` — registers `:all-modules` as a Gradle subproject.

## Testing Done

- [x] No tests added or updated. Please explain why. If unsure, please
feel free to ask for help.

This change adds a Gradle configuration module only. There is no
production logic to test. The module is verified by the existing Gradle
build resolving all subproject dependencies correctly at configuration
time.

# Additional Information

- No breaking changes, deprecations, or large PR splits required.
…e) (linkedin#619)" (linkedin#625)

This reverts commit d4fc9fe (linkedin#619),
which had reverted all commits after v0.5.417.

Restores the tree state to include the commits that were rolled back,
including the optimizer service/analyzer modules, internal catalog
cache, and dataloader changes.

Co-authored-by: mkuchenbecker <mkuchenbecker@users.noreply.github.com>
## Optimizer Stack

| PR | Content |
|---|---|
| linkedin#527 | Data Model |
| linkedin#530 | Database Repos |
| linkedin#531 | REST service |
| linkedin#533 | Analyzer app |
| linkedin#534 **(this)** | Scheduler
app |
| #tbd | Spark BatchedOFD app |
| #tbd | Infra, docker-compose, smoke test |

## Summary

PR 4 of N in the optimizer stack.

Introduces `apps/optimizer-scheduler`, a Spring Boot CommandLineRunner
that claims PENDING operations and submits batched Spark jobs via the
Jobs Service.

<img width="381" height="293" alt="image"
src="https://github.com/user-attachments/assets/d8017b54-c6d5-4a7d-ad12-dc34c67e1cdb"
/>

State machine:
Analyzer creates all Operations as PENDING
1. Scheduler marks PENDING as SCHEDULING to reserve the operation for a
Bin ahead of scheduling to reduce duplicate job submission.
2. After claiming, the bin is submitted as a single job. If the batch is
successfully submitted, the existing operations are updated to a status
of scheduled with the associated jobID persisted.
3. If the submission fails, the job will be reset to pending so another
scheduler run can pick it up.
4. Duplicate pending jobs are cancelled.
5. SCHEDULING is not expected to be in that state for long, minutes at
most, and any operations that are stuck in the SCHEDULING status for >T
period of time should move to a state of CANCELLED so next analyzer
iteration will create a new PENDING op. This might happen if the
scheduler was to suddenly crash or pod rotation. It may or may not
happened after the job is scheduled. Therefore its safest to transition
to CANCELLED in case the job was successful but not transitioned to
scheduled. This is as-compared to failing to submit a job, in which case
we know the job will never complete.

## Changes

- [ ] Client-facing API Changes
- [ ] Internal API Changes
- [ ] Bug Fixes
- [x] New Features
- [ ] Performance Improvements
- [ ] Code Style
- [ ] Refactoring
- [ ] Documentation
- [x] Tests

**Scheduler runner**: Loads PENDING ops, bin-packs by file count, claims
via two-step CAS (PENDING → SCHEDULING → SCHEDULED), submits one Spark
job per bin.

**Bin packer**: Greedy first-fit descending algorithm. Oversized tables
get their own bin (never dropped). Tables with no stats default to cost
0.

**Jobs client**: WebClient-based REST client submitting `POST /jobs` to
the Jobs Service with table names, operation IDs, and results endpoint.

**Repository additions**: Three `@Modifying` CAS methods on
`TableOperationsRepository` — `cancelDuplicatePending`,
`markScheduling`, `markScheduled` — required for safe concurrent
scheduling.

## Testing Done

- [ ] Manually Tested on local docker setup. Please include commands
ran, and their output.
- [x] Added new tests for the changes made.
- [ ] Updated existing tests to reflect the changes made.
- [ ] No tests added or updated. Please explain why. If unsure, please
feel free to ask for help.
- [ ] Some other form of testing like staging or soak time in
production. Please explain.

13 unit tests:
- `BinPackerTest` (7 tests) — empty input, single table, under/over
limit, oversized table, no stats, descending sort
- `SchedulerRunnerTest` (6 tests) — no pending ops, two-step claim +
schedule, launch failure, already-claimed skip, duplicate cancellation,
multi-row bin claim

```
./gradlew :apps:optimizer-scheduler:test
# BUILD SUCCESSFUL — 13 tests pass
```

# Additional Information

- [ ] Breaking Changes
- [ ] Deprecations
- [x] Large PR broken into smaller PRs, and PR plan linked in the
description.

---------

Co-authored-by: mkuchenbecker <mkuchenbecker@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Abhishek Nath <anath1@linkedin.com>
… POM resolves (linkedin#628)

## Summary

The `:all-modules` aggregate added in linkedin#615 globs leaf subprojects:

```groovy
rootProject.subprojects.each { subproject ->
  if (subproject.path != project.path && subproject.subprojects.isEmpty()) {
    implementation project(subproject.path)
  }
}
```

The optimizer split (linkedin#533 introduced `:services:optimizer:analyzer` and
`:apps:optimizer:analyzerapp`) turned `:services:optimizer` from a leaf
into a
parent, so the glob now picks up `analyzer` / `analyzerapp` instead —
but
neither module applied `openhouse.maven-publish`. Consumers resolving
`all-modules:0.5.439` fail on `analyzerapp:0.5.439` / `analyzer:0.5.439`
(and `analyzer`'s POM transitively pulls `optimizer:0.5.439`, also
unpublished). JFrog falls through to the upstream public mirrors which
return 401/403.

Apply `id 'openhouse.maven-publish'` to `:services:optimizer`,
`:services:optimizer:analyzer`, and `:apps:optimizer:analyzerapp` so
their
JARs/POMs land in JFrog alongside the aggregate.

## Changes

- [x] Bug Fixes

Apply `id 'openhouse.maven-publish'` to three previously-unpublished
optimizer
leaf modules so the `all-modules:0.5.x` aggregate POM resolves cleanly
for
downstream consumers.

## Testing Done

- [x] Manually Tested on local docker setup. Please include commands
ran, and their output.

```
./gradlew :services:optimizer:publishToMavenLocal \
          :services:optimizer:analyzer:publishToMavenLocal \
          :apps:optimizer:analyzerapp:publishToMavenLocal \
          :all-modules:publishToMavenLocal
```

All four tasks succeed.
`~/.m2/repository/com/linkedin/openhouse/{optimizer,analyzer,analyzerapp,all-modules}/unspecified/`
now contains main jar, lib jar (where applicable), sources, javadoc, and
POM. Cross-module POM refs verified:

- `analyzer.pom` → `com.linkedin.openhouse:optimizer` (compile)
- `analyzerapp.pom` → `com.linkedin.openhouse:analyzer` (runtime)
- `all-modules.pom` → `com.linkedin.openhouse:analyzer` and
`com.linkedin.openhouse:analyzerapp` (runtime)

`bootJar SKIPPED` on `:services:optimizer:analyzer` (library; `enabled =
false`) does not break the publish — Gradle tolerates the skipped
artifact task and `jar` (`archiveClassifier = ''`) is what ships.

- [x] No tests added or updated. Build-system metadata change only; no
source/runtime behavior changes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: mkuchenbecker <mkuchenbecker@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
## Summary
This is a follow up of Optimizer series PRs (Previous PR linkedin#534).
Introduces `BatchedOrphanFilesDeletionSparkApp`, the multi-table
counterpart of the existing single-table `OrphanFilesDeletionSparkApp`.
One Spark job now processes a list of `(table, operationId)` pairs that
the optimizer scheduler bin-packed into a single batch, reporting
SUCCESS/FAILED per operation back to the Optimizer Service.

Stands up the **`optimizerclient` codegen module** so consumers (this
app, and future ones) talk to the Optimizer Service via an
auto-generated client rather than hand-rolled HTTP — mirrors the
existing
  `:client:jobsclient`/`:client:tableclient` pattern.

Lands a **first-fit-decreasing bin packer** that is used in the followup
PR (linkedin#604 ) to assemble tables in batches. Also consolidated existing bin
packer under this.

  ### Key design choices

- **Per-table failure isolation** — exceptions in one table are caught,
FAILED is posted for that operationId, and remaining tables continue.
The job exits 0 if at least one table succeeds.
- **Recoverable result reporting** — if `POST
/v1/optimizer/operations/{id}/update` fails after retries, the row stays
`SCHEDULED` and the Analyzer's stale-timeout will re-queue it. No retry
storms in the Spark driver.
- **Optional optimizer-service callbacks** —
`--resultsEndpoint`/`--operationIds`/`--tableUuids` are all optional, so
the legacy `JobsScheduler` can launch the app without optimizer
integration. When absent, the per-operation callback is
  skipped; HTS `StateManager` still tracks per-job lifecycle.
- **Scheduler decides parallelism, not the app** — `--driverParallelism`
is honoured verbatim; the app never picks its own thread count.
- **`MAX_BATCH_SIZE = 200`** — hard ceiling enforced at `buildEntries`
(Spark side) so a misconfigured scheduler can't blow past `ARG_MAX`.
Operating point stays `--batchMaxItems 25`.

  ### Optimizer client (codegen)

- **`services:optimizer`** wired with the same
`service-specgen-convention` + springdoc plugins as
`services:jobs`/`services:tables`/`services:housetables`. Port 8003
(slots into the existing 8000/8001/8002 allocation).
- **`client:optimizerclient`** — three-line `build.gradle` mirroring
`client:jobsclient`; generates
`com.linkedin.openhouse.optimizer.client.{api,model,invoker}` from the
spec.
- **`client:secureclient`** picks up `OptimizerApiClientFactory`
(parallels `JobsApiClientFactory`) for SSL/auth-aware `ApiClient`
construction.
- **`OptimizerServiceClient`** in `apps:spark` is a thin wrapper around
the generated `TableOperationsControllerApi`, mirroring `JobsClient`'s
shape — `RetryTemplate` (new
`RetryUtil.getOptimizerApiRetryTemplate()`), surface is
`Optional<TableOperationsHistory> updateOperation(operationId,
request)`. No more OkHttp, no more hand-rolled DTOs.

 ### Bin packer
- `FirstFitDecreasingBinPacker` — FFD by weight with secondary caps on
bytes and item count; oversized items get a dedicated bin. Reuses the
existing `Bin`/`BinItem` types from linkedin#534.
- FirstFitBinPacker.java and FirstFitBinPackerTest.java — deleted
(renamed / consolidated).

  ### Batched Spark app
- `BatchedOrphanFilesDeletionSparkApp` — extends `BaseSparkApp`;
iterates entries via a fixed thread pool, reuses
`Operations.deleteOrphanFiles(...)` per table, posts per-operation
status, runs the existing `TableStateValidator` per table

### Additional changes
  **Service-side spec generation (`services/optimizer`)**
- `build.gradle` — added `service-specgen-convention` + springdoc +
processes plugins. `openApi.customBootRun` overrides the production
MySQL DataSource with in-memory H2 at build time (overrides live in
`build.gradle`, not a profile file, so
   they don't ship inside the fat jar). Port 8003.
- `developmentOnly 'com.h2database:h2:2.1.210'` — H2 reaches
`bootRun`/specgen but is excluded from `bootJar` and from
`api`/`runtimeElements` propagation. Pinned to 2.1.210 to match the
version `:iceberg:openhouse:htscatalog` already pulls
in repo-wide and avoid the slf4j-2.x bump that 2.2.x would have
introduced.

  **Client codegen (`client/optimizerclient`)**
- New module (3-line `build.gradle`) generating
`TableOperationsControllerApi`, `TableStatsControllerApi`,
`TableOperationsHistoryControllerApi` + their DTOs.
- `client/secureclient/OptimizerApiClientFactory.java` — SSL/auth-aware
factory mirroring `JobsApiClientFactory`.
  - `settings.gradle` — registers `:client:optimizerclient`.

  **Bin packer (`libs/optimizer/binpack`)**
- `FirstFitDecreasingBinPacker.java` (moved from apps/spark).
Pre-projected `BinItem` inputs, weight + item-count caps, oversized
items get their own bin.
- `FirstFitDecreasingBinPackerTest.java` (moved + adapted to the libs
`BinItem` interface).

  **Spark app (`apps/spark`)**
- `spark/BatchedOrphanFilesDeletionSparkApp.java` — multi-table OFD with
worker-thread pool, per-table failure isolation,
`Iterables.size()`-based orphan count (bounded driver heap),
`MAX_BATCH_SIZE=200` guard.
- `spark/optimizer/OptimizerServiceClient.java` — wraps generated
`TableOperationsControllerApi`.
  - `util/RetryUtil.java` — added `getOptimizerApiRetryTemplate()`.
- `build.gradle` — depends on `:libs:optimizer:binpack` with a targeted
`exclude` on `log4j-slf4j2-impl` (transitively brought by
services:optimizer; conflicts with apps:spark's bundled
`log4j-slf4j-impl` 1.x bridge).

**Test using CLI:**
  --tableNames db.t1,db.t2,db.t3
  --operationIds op-uuid-1,op-uuid-2,op-uuid-3
  --tableUuids tab-uuid-1,tab-uuid-2,tab-uuid-3
  --resultsEndpoint http://optimizer.svc:8080
  --driverParallelism 4
plus existing OFD knobs (`--ttl`, `--backupDir`, `--concurrentDeletes`,
`--streamResults`, `--maxOrphanFileSampleSize`).

<!--- HINT: Replace #nnn with corresponding Issue number, if you are
fixing an existing issue -->

[Issue](https://github.com/linkedin/openhouse/issues/#nnn)] Briefly
discuss the summary of the changes made in this
pull request in 2-3 lines.

## Changes

- [ ] Client-facing API Changes
- [ ] Internal API Changes
- [ ] Bug Fixes
- [x] New Features
- [ ] Performance Improvements
- [ ] Code Style
- [ ] Refactoring
- [ ] Documentation
- [x] Tests

For all the boxes checked, please include additional details of the
changes made in this pull request.

## Testing Done
<!--- Check any relevant boxes with "x" -->

- [ ] Manually Tested on local docker setup. Please include commands
ran, and their output.
- [x] Added new tests for the changes made.
- [ ] Updated existing tests to reflect the changes made.
- [ ] No tests added or updated. Please explain why. If unsure, please
feel free to ask for help.
- [ ] Some other form of testing like staging or soak time in
production. Please explain.

For all the boxes checked, include a detailed description of the testing
done for the changes made in this pull request.

# Additional Information

- [ ] Breaking Changes
- [ ] Deprecations
- [ ] Large PR broken into smaller PRs, and PR plan linked in the
description.

  **Open items for reviewers:** 
- `OptimizerServiceClient` is add on top of the generated
`TableOperationsControllerApi` — matches `JobsClient`'s pattern. Fine,
or should it live inside `:client:optimizerclient` as a higher-level
facade?
- Apps:spark currently has to `exclude log4j-slf4j2-impl` when depending
on `:libs:optimizer:binpack` because services:optimizer transitively
brings the slf4j-2.x bridge while apps:spark ships the 1.x bridge. A
repo-wide bridge-alignment
cleanup would let us drop this exclude; happy to file a separate issue.
- The bin packer ends up as two siblings in libs (`FirstFitBinPacker` —
optimizer-coupled; `FirstFitDecreasingBinPacker` — agnostic).
Consolidation is done in FirstFitDecreasingBinPacker.
- Builder defaults (maxWeightPerBin = 1_000_000L, maxItemsPerBin = 50)
are sized for OFD. If we expect other operation types with materially
different cost shapes, we may want to move the defaults out of the
packer class and into the per-operation config (Spring @value already
provides this in SchedulerConfig).
 
For all the boxes checked, include additional details of the changes
made in this pull request.
…fault (linkedin#624)

## Summary

Stops a small read `batch_size` (e.g. 128) from lowering DataFusion's
internal `datafusion.execution.batch_size` below its 8192 default. The
read `batch_size` is now only ever *raised* into the execution batch
size to honor a large requested batch (the case linkedin#568 fixed) — never
lowered.

Rationale: lowering the engine's internal execution batch below its
default can only ever split work into more, smaller batches; it never
coalesces rows up, so it cannot help and can only add per-batch
overhead. We therefore only raise it for the large-batch case linkedin#568
added, and otherwise leave DataFusion at its default.

> **Scope note:** this began as an investigation into a reported
training perf regression (`read_batch` stalls, starved prefetch queue).
Follow-up with Jonathan showed the regression **persisted even with the
execution batch size pinned at 8192**, so that slowdown comes from
elsewhere (the read/IO path), not this config. This PR lands as a small
**correctness/hardening change**, not as the fix for that regression.

## Changes

- [ ] Client-facing API Changes
- [ ] Internal API Changes
- [x] Bug Fixes
- [ ] New Features
- [ ] Performance Improvements
- [ ] Code Style
- [ ] Refactoring
- [ ] Documentation
- [ ] Tests

One change in `_create_transform_session`: only set
`datafusion.execution.batch_size` when the read `batch_size` **exceeds**
DataFusion's 8192 default; a smaller value leaves DataFusion at its
default. No new public parameter (per review feedback to avoid extra
config surface) — this is a clamp on the existing `batch_size`.

## Testing Done

- [ ] Manually Tested on local docker setup. Please include commands
ran, and their output.
- [ ] Added new tests for the changes made.
- [ ] Updated existing tests to reflect the changes made.
- [x] No tests added or updated. Please explain why. If unsure, please
feel free to ask for help.
- [ ] Some other form of testing like staging or soak time in
production. Please explain.

The existing `test_split_batch_size_honored_with_transform` already
covers the only behavior the dataloader exercises — a `batch_size` above
the default is propagated so transforms are not fragmented — and still
passes under the clamp. The added behavior is purely a guard against
lowering the value below the default, which the dataloader's read path
never benefits from. Full suite green: `make verify` — ruff + mypy +
**281 passed**.

# Additional Information

- [ ] Breaking Changes
- [ ] Deprecations
- [ ] Large PR broken into smaller PRs, and PR plan linked in the
description.

Backward compatible. The only behavior change is that a read
`batch_size` ≤ 8192 no longer shrinks the transform execution batch
below DataFusion's default.
…#621)

## Summary

<!--- HINT: Replace #nnn with corresponding Issue number, if you are
fixing an existing issue -->

Depends on linkedin#601

Allowlist entries are now Java regular expressions matched against the
property key.

## Changes

- [x] Client-facing API Changes
- [ ] Internal API Changes
- [ ] Bug Fixes
- [ ] New Features
- [ ] Performance Improvements
- [ ] Code Style
- [ ] Refactoring
- [ ] Documentation
- [ ] Tests

For all the boxes checked, please include additional details of the
changes made in this pull request.

## Testing Done
<!--- Check any relevant boxes with "x" -->

- [ ] Manually Tested on local docker setup. Please include commands
ran, and their output.
- [x] Added new tests for the changes made.
- [ ] Updated existing tests to reflect the changes made.
- [ ] No tests added or updated. Please explain why. If unsure, please
feel free to ask for help.
- [ ] Some other form of testing like staging or soak time in
production. Please explain.

./gradlew :services:tables:test --tests
IcebergSnapshotsApiHandlerAuditTest

# Additional Information

- [ ] Breaking Changes
- [ ] Deprecations
- [ ] Large PR broken into smaller PRs, and PR plan linked in the
description.

For all the boxes checked, include additional details of the changes
made in this pull request.

Co-authored-by: James Wang <jamewang@linkedin.com>
kamanavishnu and others added 30 commits July 28, 2026 08:32
…mit (linkedin#648)" (linkedin#665)

# Problem & Solution Overview
This reverts commit e80d45d (linkedin#648).

linkedin#648 changed `OpenHouseInternalCatalog#listTables(Namespace)` to
internally paginate HTS "list all" queries (page size 1000) instead of
issuing a single unpaginated request, in order to avoid
`DataBufferLimitException` on very large databases.

In production, this caused `GET /v1/databases` (empty-namespace
list-tables path) to fan out into many sequential HTS calls per request
instead of one. For a deployment with a large number of
databases/tables, this significantly increased request volume against
HTS, saturating its JDBC connection pool (Hikari) and causing collateral
failures on unrelated HTS calls (e.g. `HikariPool-1 - Connection is not
available, request timed out after 30000ms`, `java.io.EOFException:
connection was unexpectedly lost`, `PrematureCloseException: Connection
prematurely closed BEFORE response`).

This PR reverts the pagination change to restore the prior unpaginated
`listTables` behavior while a safer fix (e.g. bounded concurrency,
smarter page sizing, or pushing pagination to the client-facing
paginated APIs only) is designed.

# Testing Done
- `git revert e80d45d` applied cleanly
with no conflicts against current `main` (two unrelated commits, linkedin#650
and linkedin#651, merged on top since linkedin#648; neither touches this file).
- `./gradlew :iceberg:openhouse:internalcatalog:compileJava` compiles
cleanly after the revert.
- Risk: low — this is a straight revert of a recent, isolated change
back to previously-running production behavior (pre-linkedin#648). Follow-up
work is needed to re-address the original `DataBufferLimitException`
motivating linkedin#648, without triggering HTS connection-pool exhaustion.
## Summary

Table creation filtered feature-gated preserved properties because
`allowKeyInCreation` called the advised `isKeyPreservedForTable` method
through Spring self-invocation, bypassing the feature-toggle aspect.
Invoke the table-aware check from the repository's Spring proxy before
applying the create-only allowlist fallback.

This makes CREATE consistent with ALTER while retaining support for
extension-defined properties that are writable only during creation.

## Changes

- [x] Bug Fixes
- [x] Tests

- Evaluate `isKeyPreservedForTable` through the proxied
`PreservedKeyChecker` during creation.
- Retain `allowKeyInCreation` as the fallback for create-only preserved
properties.
- Add a Spring integration regression test using a real active table
feature toggle.
- Update the default-file-format test to model preserved/toggled state
on the table-aware method.

## Testing Done

- [x] Added new tests for the changes made.
- [x] Updated existing tests to reflect the changes made.
- [x] Some other form of testing like staging or soak time in
production. Please explain.

- Verified the new regression test fails without the production change
and passes with it.
- `:services:tables:test` — 463 tests passed.
- `:services:tables:spotlessCheck`
- `:services:tables:checkstyleMain`
- `:services:tables:checkstyleTest`
- Manually reproduced the original behavior against a live OpenHouse
namespace: feature-gated properties survived ALTER but were silently
filtered during CREATE.

# Additional Information

- [ ] Breaking Changes
- [ ] Deprecations
- [ ] Large PR broken into smaller PRs, and PR plan linked in the
description.
…#635)

## Summary

This adds **Renovate** — an open-source tool that watches dependency
versions
and opens a pull request when a newer one is available — and points it
at the
LinkedIn build of Iceberg (`com.linkedin.iceberg`, the table library
OpenHouse
depends on). A scheduled job runs it every hour on GitHub's own
machines.

OpenHouse uses two versions of this library at the same time: one in the
`1.2.x` series (any version starting with `1.2.`) and one in the `1.5.x`
series. The tool keeps each series on its own newest version and never
lets one
cross into the other. First-run result, confirmed locally without
opening any
pull requests:

- `iceberg-core`: `1.2.0.17` → `1.2.0.18`
- `iceberg-core`: `1.5.2.11` → `1.5.2.15`

## Changes

- [x] New Features
- [x] Documentation

- `.github/renovate.json` — the rules above.
- `.github/workflows/renovate.yml` — the hourly scheduled job.
- `docs/development/renovate-iceberg-sync.md` — setup and reasoning.

## Testing Done

- [x] No tests added or updated.

Ran Renovate locally in a mode that reads the repo and looks up versions
but
opens no pull requests; it proposed exactly the two updates above, each
staying
inside its own series. Also checked `renovate.json` with Renovate's
official
config validator.

## Additional Information

- [ ] Breaking Changes
- [ ] Deprecations

**Optional:** adding a repository secret named `RENOVATE_TOKEN` lets the
update
pull requests run their tests automatically. GitHub will not start test
runs on
a pull request opened with a workflow's built-in credentials (a guard
against
workflows triggering each other), so without this secret the pull
requests
still open but you start their tests by hand. A personal access token or
a
GitHub App token works. It is not required to merge this change.

This covers the two main version numbers only. The separately pinned
`iceberg-aws` (`1.2.0.6`) is left out on purpose and can be added the
same way
later.
## Summary
Problem: I have a feature which I want to ramp on the server. but I also
don't want to prevent table owners from self-serve opting in. A generic
function to handle that overlap doesn't exist today.

Extend the existing `TableFeatureToggle` with self-service table
overrides while preserving its server-managed targeting API.

An explicit `<featureId>.enabled=true|false` table property wins. When
the property is absent, activation delegates to the server toggle.
Server rules now support trailing-`*` prefix matching independently for
database and table names.

## Changes

- [ ] Client-facing API Changes
- [x] Internal API Changes
- [ ] Bug Fixes
- [x] New Features
- [ ] Performance Improvements
- [ ] Code Style
- [ ] Refactoring
- [ ] Documentation
- [x] Tests

Adds a binary-compatible default method to `TableFeatureToggle`:

```java
isFeatureActivatedWithOverride(TableDto tableDto, String featureId)
```

It is deliberately **not** an overload of `isFeatureActivated`. The two
carry different safety contracts, and a distinct name makes the
difference visible at the call site: authorization gates such as
`enable_mor` decide whether a user may write a preserved table property,
so they must keep using the server-only `isFeatureActivated(String,
String, String)`. The override-honoring form reads a property the gated
user can write.

An override that is neither `true` nor `false` fails closed: it is
logged and the feature is treated as inactive. The gate is evaluated on
the table-load path, so throwing would turn a typo like
`read-bridge.enabled=flase` into a `400` and make the table unloadable.

Extends the existing toggle rule matcher while preserving exact and `*`
matching:

- `tracking.events` matches exactly.
- `tracking_*.events_*` matches database and table prefixes.
- `*.*` matches every table.

## Testing Done

- [ ] Manually Tested on local docker setup. Please include commands
ran, and their output.
- [x] Added new tests for the changes made.
- [ ] Updated existing tests to reflect the changes made.
- [ ] No tests added or updated. Please explain why. If unsure, please
feel free to ask for help.
- [ ] Some other form of testing like staging or soak time in
production. Please explain.

Ran:

```shell
JAVA_HOME=$(/usr/libexec/java_home -v 17) ./gradlew \
  :services:tables:test \
  --tests 'com.linkedin.openhouse.tables.toggle.TableFeatureToggleTest' \
  :services:housetables:test \
  --tests 'com.linkedin.openhouse.housetables.mock.WildcardTableToggleRuleMatcherTest' \
  -x CopyGitHooksTask
```

All 12 focused tests passed, covering server fallback, explicit opt-in
and opt-out, fail-closed handling of unparseable overrides, exact
matching, wildcard matching, and paired database/table prefix matching.

# Additional Information

- [ ] Breaking Changes
- [ ] Deprecations
- [x] Large PR broken into smaller PRs, and PR plan linked in the
description.

This is an independent OSS foundation for the read-bridge stack in linkedin#645
and the corresponding `li-openhouse` implementation PRs.
Feature-specific default derivation remains outside this PR.

Note for reviewers: the matcher change widens any existing
`table_toggle_rule` row whose pattern ends in `*` but is not exactly
`*`. Those previously matched nothing. Worth auditing HTS before merge.
## Summary

**Problem:** OpenHouse DataLoader errors could not be reliably
correlated with the corresponding Tables Service request.
Timestamp-based correlation is noisy, and authentication failures were
represented as generic `OSError`s and retried as if they were transient
I/O failures.

**Solution:** assign a unique `X-Request-ID` to every outbound catalog
HTTP request and expose it through shared, typed catalog exceptions. The
exception hierarchy centralizes request-ID formatting and distinguishes
authentication, authorization, not-found, transport, HTTP, and
malformed-response failures.

## Changes

- [x] Client-facing API Changes
- [x] Internal API Changes
- [x] Bug Fixes
- [ ] New Features
- [ ] Performance Improvements
- [ ] Code Style
- [x] Refactoring
- [ ] Documentation
- [x] Tests

Details:

- Added a central request-aware HTTP session that generates a fresh UUID
for every prepared request.
- Added shared exception types:
  - `OpenHouseCatalogError`
  - `OpenHouseRequestError`
  - `OpenHouseTransportError`
  - `OpenHouseHTTPError`
  - `OpenHouseAuthenticationError`
  - `OpenHouseAuthorizationError`
  - `OpenHouseNoSuchTableError`
  - `OpenHouseInvalidResponseError`
- Centralized `X-Request-ID` rendering and exposed it as
`exception.request_id`.
- Preserved compatibility with callers catching PyIceberg
`NoSuchTableError`.
- Classified 401/403 and other non-transient 4xx failures as
non-retryable.
- Kept transport failures, HTTP 408/429, and 5xx responses retryable.

## Testing Done

- [ ] Manually Tested on local docker setup. Please include commands
ran, and their output.
- [x] Added new tests for the changes made.
- [x] Updated existing tests to reflect the changes made.
- [ ] No tests added or updated. Please explain why. If unsure, please
feel free to ask for help.
- [x] Some other form of testing like staging or soak time in
production. Please explain.

Validation performed:

- `make verify`
  - Ruff lint passed
  - Ruff formatting passed
  - Mypy passed
  - **272 unit tests passed**
- Added coverage for unique request IDs, typed 401/403/404/500 errors,
transport errors, malformed JSON, missing/empty `tableLocation`,
retryable failures, and non-retryable authentication failures.
- Used a known request ID against the deployed OpenHouse route and
confirmed it appeared in the Nginx access log; follow-up proxy
configuration work is tracked separately to preserve the same value
through Ambassador.

# Additional Information

- [ ] Breaking Changes
- [ ] Deprecations
- [ ] Large PR broken into smaller PRs, and PR plan linked in the
description.
)

## Summary
Phase 1.5 only from `jobs-observability-plan.md` §10 — localized log
lines, no new classes or entry-point changes:

- **`OH_SCHED_START`** in `JobsScheduler.main`: echoes
`numParallelJobs`, poller/submitter counts, poll/timeout settings
(replaces brittle `values.yaml case()` for cap/deadline KQL).
- **`OH_SCHED_ELIGIBLE`** in `OperationTasksBuilder`: stable
eligible-count token alongside existing `metadata fetched count` log.
- **Queued-timeout poll exit** in `OperationTask`: appends
`lastObservedState` + `executionId` to distinguish genuine GGW queue
timeout vs poll-lag give-up on terminal jobs.

~35 lines across 3 files. Phase 2 (OTEL gauges, heartbeat sampler, DLQ
counters) intentionally deferred.

## Test plan
- [x] Compiles; no test signature changes
- [ ] Post OSS→LI bump: grep cron pod logs for `OH_SCHED_START`,
`OH_SCHED_ELIGIBLE`, `lastObservedState=` on queued-timeout lines

Co-authored-by: Cursor <cursoragent@cursor.com>
…kedin#667)

This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
|
[com.linkedin.iceberg:iceberg-core](https://redirect.github.com/linkedin/iceberg)
| `1.2.0.19` → `1.2.0.20` |
![age](https://developer.mend.io/api/mc/badges/age/maven/com.linkedin.iceberg:iceberg-core/1.2.0.20?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/com.linkedin.iceberg:iceberg-core/1.2.0.19/1.2.0.20?slim=true)
|

---

### Release Notes

<details>
<summary>linkedin/iceberg (com.linkedin.iceberg:iceberg-core)</summary>

###
[`v1.2.0.20`](https://redirect.github.com/linkedin/iceberg/releases/tag/v1.2.0.20)

[Compare
Source](https://redirect.github.com/linkedin/iceberg/compare/v1.2.0.19...v1.2.0.20)

<sup><sup>*Changelog generated by [Shipkit Changelog Gradle
Plugin](https://redirect.github.com/shipkit/shipkit-changelog)*</sup></sup>

##### 1.2.0.20

- 2026-07-31 - [0
commit(s)](https://redirect.github.com/linkedin/iceberg/compare/v1.2.0.20...v1.2.0.20)
by
- No notable improvements. No pull requests (issues) were referenced
from commits.

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR has been generated by [Mend
Renovate](https://redirect.github.com/renovatebot/renovate).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4yODguMCIsInVwZGF0ZWRJblZlciI6IjQzLjI4OC4wIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
## Summary

`CREATE OR REPLACE ... AS SELECT` (RTAS) silently dropped the table's
policies. The replace path rebuilt the `policies` table property purely
from the incoming request, so a replace that omitted policies wiped the
existing retention, sharing, PII column tags, replication, and history,
even though ordinary user table properties survived.

## Fix

Policies are table metadata that a replace must not silently drop.
Before the replace properties are built, the existing table's policies
are merged with the request's policies. The merge is based on the
existing policies, so any plane that the request does not explicitly
provide is carried forward from the existing table, and each plane that
the request does provide overrides the existing value.

## Merge behavior

The table below describes how each policy plane behaves during a
replace.

| Plane | When the request provides it | When the request omits it |
|---|---|---|
| `retention` | The request value is applied. | The existing value is
carried forward. |
| `replication` | The request value is applied. | The existing value is
carried forward. |
| `history` | The request value is applied. | The existing value is
carried forward. |
| `lockState` | The request value is applied. | The existing value is
carried forward. |
| `columnTags` | The request map replaces the existing map in full. This
is an overwrite, not a per-key merge. | The existing map is carried
forward. An omitted field and an empty map are treated the same way. |
| `sharingEnabled` | Not applicable, because this is a primitive boolean
and a provided value cannot be distinguished from an omitted one. | The
existing value is carried forward. |

Two consequences follow from this behavior. First, because column tags
use overwrite semantics and an empty map is treated the same as an
omitted field, a replace cannot clear all column tags. Clearing tags is
done with `ALTER TABLE ... MODIFY COLUMN ... UNSET TAG`. Second, because
`sharingEnabled` is a primitive boolean with no unset state, its value
is always preserved across a replace. Sharing is changed with `ALTER
TABLE ... SET POLICY (SHARING=...)`.

Spark RTAS has no policy clause, so it always sends a request with no
policies, and the entire existing policies object is carried forward
unchanged. A partial policy payload can only arrive from a client that
calls the REST API directly.

This behavior is consistent with the intent of RTAS, which should
preserve table properties so that a replace does not require re-granting
access to the same entity.

## Testing Done

The REST level partial payload path is exercised through
`RepositoryTest`, which is the layer that can send a partial `Policies`
object. Spark cannot reach this path because it always sends a request
with no policies.

- `testReplaceMergesExistingPolicies` replaces a table without policies
and asserts that the retention policy survives.
- `testReplaceAppliesRequestedPolicies` asserts that a retention policy
provided on the request is applied.
- `testReplaceWithPartialPoliciesPreservesSharing` sends a partial
payload containing only retention and asserts that `sharingEnabled`
stays true while the new retention is applied.
- `testReplaceWithPartialPoliciesPreservesOmittedPlanes` overrides only
retention and asserts that the omitted history plane is carried forward.
- `testReplaceWithPartialPoliciesPreservesColumnTags` sends a payload
that provides retention but omits column tags, and asserts that the
existing column tag is carried forward.
- `testReplaceOverwritesColumnTags` sends a new column tag map and
asserts that it replaces the existing map in full, dropping the previous
tag.

Black box coverage is exercised through `RtasPolicyPreservationTest`
against an embedded OpenHouse server driven by Spark SQL. It asserts
that retention, sharing, the PII column tag, and history all survive a
`REPLACE TABLE ... AS SELECT`.

The existing `SnapshotsControllerTest.testPutSnapshotsReplaceCommit`
still passes, which confirms that a replace on a table that never had
policies still yields none. `./gradlew :services:tables:test` and
`:integrations:spark:spark-3.1:openhouse-spark-itest:catalogTest` pass
on JDK 17, and Spotless is clean on the module.

---------

Co-authored-by: mkuchenbecker <mkuchenbecker@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds the open-source read-bridge feature on top of the per-table `config`
channel (linkedin#644):

- ColumnDefaultsSource: the single open-source/closed-source seam (field-id ->
  Iceberg single-value JSON). Open-source default is a no-op lambda in ApiConfig;
  a deployment overrides this bean (e.g. li-openhouse, from avro.schema.literal).
- ReadBridgeConfigResolver: server-side encoder that stamps each default as a
  flat namespaced config entry (openhouse.read-bridge.column-default.<fieldId> =
  single-value JSON) — no envelope/POJO; the config map carries the structure.
- ReadBridge (client): decodes those entries and applies the overlay at
  metadata-load time (the column-default transform is a marked TODO, and the
  place further V3 features get backported). Keeps loadMetadata to one call.

Behaviorless until a ColumnDefaultsSource is supplied; fail-closed throughout.

Co-authored-by: Cursor <cursoragent@cursor.com>
A known openhouse.read-bridge.column-default.* entry is produced by the
server encoder from a typed JsonNode keyed by an integer field-id, so its
suffix always parses as an int and its value always round-trips through
readTree. A decode failure is therefore an encoder bug or transport
corruption, not an expected input -- skipping it would silently read NULL
instead of the column's default and hide a real defect. Throw instead.

Unknown keys (a newer server feature this client doesn't recognize) are
still ignored, preserving forward compatibility.

Update javadoc/comments to the encoder round-trip rationale and note the
guarantee covers well-formedness, not default-to-schema correctness (a
write-time concern). Tests updated to assert fail-loud on bad field-id and
unparseable value, plus forward-compat skip of unknown keys.
The fail-loud change updated ReadBridge but left two docs stating the old
fail-closed behavior:

- ColumnDefaultsSource told implementers they "must never throw", the opposite
  of the policy, and unimplementable alongside validating that a declared
  default binds to its column. Restate it as the capability-gap vs
  invariant-violation split: an empty map means nothing to bridge, while a
  declared-but-unhonorable default throws.
- OpenHouseTableOperations.loadMetadata still claimed "unparseable config
  leaves the raw metadata untouched", which no longer holds: ReadBridge throws
  on a malformed known entry and loadMetadata is its only caller.

Comment-only; no behavior change.

Co-authored-by: Cursor <cursoragent@cursor.com>
loadMetadata is wrapped in Tasks.retry(20) for transient metadata *file*
reads. Decode and apply are deterministic: a malformed config fails the
same way every attempt, so retrying only burns ~90s and re-reads storage
to reproduce an error already known on the first try.

Keep from/apply inside loadMetadata (decode before IO so storage is never
touched on a bad config), and wrap IllegalStateException as
Tasks.UnrecoverableException — Iceberg already stops retry on that type.
No doRefresh decode field or AtomicReference needed.

Testing Done:
- :integrations:java:iceberg-1.2:openhouse-java-itest:test --tests '*ReadBridge*' --tests '*OpenHouseTableOperationsTest.testMalformed*' --tests '*OpenHouseTableOperationsTest.testDoRefresh*'
Co-authored-by: Cursor <cursoragent@cursor.com>
Iceberg skips the loader when tableLocation is unchanged, so writing
config on every GET desyncs stamps from in-memory overlays. Bind after
apply; skip-reload leaves the pair intact.

Co-authored-by: Cursor <cursoragent@cursor.com>
loadMetadata runs before Iceberg's UUID check. Setting config there
desyncs stamps from current() if that check fails. Bind only after
refreshFromMetadataLocation returns and the loader actually ran.

Co-authored-by: Cursor <cursoragent@cursor.com>
Keep the deployment-specific ColumnDefaultsSource optional and data-only;
OpenHouse owns the read-bridge.column-default feature id, self-serve
enabled property, and fail-open toggle lookup before asking for defaults.
Correct the self-service property name and drop the contradictory
"cluster kill switch" claim. Add coverage for a real source that returns
no defaults, and assert table-property opt-in never calls the server
toggle.

Testing Done:
- :services:tables:test --tests '*ReadBridgeConfigResolverTest'
BaseTableFeatureToggle looks up (databaseId, tableId, featureId) exactly;
claiming a * / * fleet kill switch was inaccurate. Also note HTS is only
hit when the self-service property is absent.
Replace essay javadoc with brief ownership, contract, ObjectProvider,
and fail-open notes so the PR description carries the design narrative.
Overlays must not persist. Remove initial-default on field-ids the config stamped; unstamped ids keep writer defaults. Bridge XOR native: the encoder does not stamp ids that already have on-disk defaults.
An empty GET that Iceberg skips must not let overlays persist; strip
uses currentConfig from the last load, not the latest GET.

Co-authored-by: Cursor <cursoragent@cursor.com>
Overlay stamped column defaults onto schema field objects by field-id using the sanitize JSON helpers.
Unpublished OSS tables/fixtures need this so a SNAPSHOT uber can no-op drop/grant/lock on X-OH-Wap-Branch.
…branch.

Stop rewriting schema, properties, and policies in the client so isolation is the X-OH-Wap-Branch skip, including partition-spec eligibility.
Jobs keep the catalog they already load. Tables REST body is unchanged; X-Iceberg-Ref aliases X-OH-Wap-Branch for a later Iceberg REST 1.11 ref field. DROP/RENAME/GRANT on a non-main WAP branch skip house-table identity mutations.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.