Skip to content

fix(iceberg): sinks write the expose their build names; dynamodb/jdbc/bigquery sinks start; Confluent reads as Glue; policy compile speaks up - #710

Merged
fas89 merged 23 commits into
mainfrom
fix/iceberg-defect-scan-wave
Oct 11, 2026
Merged

fas89 merged 23 commits into
mainfrom
fix/iceberg-defect-scan-wave

Conversation

@fas89

@fas89 fas89 commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Fixes six defects found by the defect scan, plus one found while writing the docs, of the Iceberg catalog-kind change set (#707 and #709). The owner chose the scope on 2026-10-07: the High plus five Mediums ship now, and the Lows stay on Trello. Defect fixes only: no new CLI flags and no schema changes (EIC freeze).

Defect Was Now
LOGIC-1 (High) Every derived streaming sink wrote exposes[0]. A second CDC build streamed its records into the first expose's table, and validate, the preflight and the run all passed. A derived Kafka Connect sink writes the one Iceberg expose its build's outputs pick. An ambiguous build is a hard error in validate and the run preflight. A derived embedded Debezium sink may write several outputs that share one database and one catalog. One resolver is used by validate, the preflight, both runners and the late-arrival target. Hand-written configs and overrides that set iceberg.tables / table-namespace behave as before.
CON-3 dynamodb and jdbc sinks got no warehouse. Iceberg's DynamoDbCatalog and JdbcCatalog refuse to initialise without one, so the connector never started. The warehouse comes from location.warehouse, else from an explicit location.bucket (s3:// on AWS, gs:// on GCP), else an override. With none, validate errors and the run refuses. A {{ env.* }} bucket whose variable is unset or empty: validate warns, and the run refuses instead of deriving a bucket the contract does not name.
CON-4 The bigquery sink had no gcp.bigquery.project-id (required by BigQueryMetastoreCatalog) and no warehouse, and it got the AWS-only client.region. location.project maps to gcp.bigquery.project-id (required), location.region maps to gcp.bigquery.location, and the warehouse is the gs:// storage dbt-bigquery and the GCP IaC use, or a warning when there is none. client.region is no longer emitted.
GCP namespace (found while writing the docs; owner added it to this PR) Every derived sink named its table <location.database>.<table>. A GCP binding names its BigQuery dataset in location.dataset, so a GCP Iceberg sink pushed iceberg.tables=None.<table>, and validate passed. The namespace is location.database, else location.dataset (the BigQuery metastore catalog maps a namespace to a dataset: BigQueryMetastoreCatalog.java:149, :200, :307). A derived sink with no namespace is refused, as is a derived Kafka Connect sink with no table.
CON-5 A platform: confluent expose with no catalog read as rest to policy compile and dbt, but the Tableflow IaC published it to Glue, so policy compile dropped every grant with a false warning. It reads as glue everywhere. Policy compile grants the Glue table Tableflow publishes (the topic's table, in location.database or the Kafka cluster id database).
RT-707-3 fluid policy compile never showed its warnings, and a compiler crash became an empty bindings.json with exit 0. Warnings print at WARNING level and the exit code stays 0. fluid policy apply echoes the stored warnings. A crash exits non-zero with an error.
JRN-707-6 The Snowflake volume guard blamed an earlier release and a specific expose for a volume a removed managed expose owned. Message only, as decided: the guard still blocks, and the message names both possible causes plus the existing remediation.

The Upgrade notes in CHANGELOG.md list the contracts that validated before and now fail (all of them already failed at connector start) and the config output changes.

Borrow before build

Every third-party contract the fix relies on was read from upstream source at the released tag, and is cited in the code:

  • apache/iceberg apache-iceberg-1.10.0:
    • DynamoDbCatalog.java and JdbcCatalog.java: the warehouse is required, and the table layout is under it.
    • BigQueryMetastoreCatalog.java: gcp.bigquery.project-id is required, gcp.bigquery.location, and when the warehouse is used.
    • IcebergSinkConfig.java:154-159: iceberg.tables.auto-create-enabled defaults to false.
  • memiiso/debezium-server-iceberg 1.2.0.Final IcebergConfig.java: debezium.sink.iceberg.warehouse has no default.
  • Confluent Tableflow docs: the default Glue database is the cluster ID, and each topic becomes a table.

How it was built and verified

  • Slices: three, built in separate worktrees (catalog table, sink/expose join, policy + guard). Each was checked by a repro oracle that re-ran the original defect-scan repro scripts and by an adversarial reviewer. Repair rounds continued until both passed, and the owner refereed the scope.
  • Integration: the merge added a cross-slice test file, tests/build_runners/test_fw2_integration_seams.py. A seam review and a security review then ran on the merged diff. Their High (a multi-expose Debezium sink used one expose's table prefix as the catalog warehouse) and Mediums (the Tableflow grant table) are fixed here.
  • Negative controls: the new tests were run against origin/main, and against each pre-fix commit, in throwaway worktrees, and they fail there.

Local CI

  • black 24.10.0 (line length 100) and ruff: clean on all changed files. scripts/mypy_strict.py: no issues.
  • The full suite (pytest tests -n auto) passes, apart from these local-environment failures, which also fail on main:
    • tests/iac/test_iac_moto_e2e.py: up to 3 tests (OpenTofu 1.12 with a fresh moto).
    • tests/test_coding_agent_live.py: 1 test (the local claude CLI).
    • tests/test_docs_reminder.py and tests/test_ci_release_workflow.py: 8 tests each. The local Homebrew node cannot load libsimdjson, and the same tests fail on a clean origin/main worktree.
  • After the merge, the original defect-scan repros all show the fixed behaviour, and the fix(iceberg): close the gaps the post-merge inspection of #707 found #709 catalog-move controls still block.

Fixed during review

  • Credential echo: policy_compiler_crashed echoed a wrong-typed value, so a connection URL with a password could reach stderr. It now names the path and the expected type only (2ed842b2). fluid validate has the same echo on main; that is on Trello.

CI fix included

MCP SDK Drift (mcp 1.20) was red on main as well as here. mcp 1.20 and 1.21 import eval_type_backport from pydantic's private _typing_extra module, which pydantic 2.14 removed. Tested in throwaway uv environments: mcp 1.20 imports with pydantic 2.13.5 and fails with 2.14.0, and 1.22 is the first 1.x release that imports with 2.14.0. The mcp floor is now >=1.22,<3.0, and the drift leg tests 1.22. Reproduced locally:

  • mcp 1.22 + pydantic 2.14: 272 passed.
  • Negative control, mcp 1.20: the same 11 collection errors as CI.
  • range-max, mcp 2.3.0: 271 passed.

Tested live

This change touches the streaming-sink paths. The ci:integration-emulated label runs the Lakekeeper + Kafka Connect live lane (tests/integration/test_lakekeeper_kafka_connect_live.py) as the gate, per the owner's "CI as the gate" decision. No local Docker was used.

Follow-ups

The low and pre-existing findings from the verification rounds go to Trello. Docs: forge_docs #147, to merge after this PR.

fas89 added 18 commits October 7, 2026 23:14
…sh, and word the Snowflake volume guard without blaming an expose

RT-707-3: `fluid policy compile` wrote its warnings only into bindings.json
and logged a count at INFO (routed to DEBUG), so a grant that compiled to no
table grant printed nothing and exited 0. Each warning now goes to stderr
through the CLI warning helper, exit stays 0, and `fluid policy apply` echoes
the warnings stored in bindings.json. "No grants found in accessPolicy" (a
legitimate no-op) stays in the file and is not shown as a WARNING on compile,
apply or stage 3.

A crash inside compile_policy was swallowed into {"bindings": {"bindings": []}}
and exit 0. It now fails with the new policy_compiler_crashed error, writes
nothing, and names the schema type errors at the values the compiler reads
(the grants, their permissions, each binding and its location), or the
compiler's own exception when there are none. Schema errors elsewhere, such as
an extra key on a grant, are not named. The error routes to the policy compile
page's Errors section.

JRN-707-6: the Snowflake EXTERNAL VOLUME guard detects what it did before. Its
message no longer says forge-cli stopped creating the volume or lists the moved
exposes as the tables it was held for: the volume is keyed by the contract, so
the message names both possible causes (an upgrade from a release that gave a
non-Snowflake-managed catalog a volume, or a Snowflake-managed expose removed
or re-cataloged in this change) and keeps the tofu state rm remediation. The
AWS message, which has per-expose evidence, is unchanged.
… outputs pick

Every Kafka Connect and embedded Debezium Iceberg sink resolved its table
from exposes[0], so a second CDC build whose outputs named another Iceberg
expose streamed into the first expose's table while validate, the
preflight and the run all passed.

A sink config forge-cli derives now resolves its exposes from
build.outputs through resolve_iceberg_sink_exposes:

- Kafka Connect derives one iceberg.tables entry: exactly one Iceberg sink
  expose in outputs, or no outputs and exactly one in the contract.
  Anything else is a hard validate error.
- Embedded Debezium Server derives one table-namespace and one catalog:
  the exposes outputs pick (all of them with no outputs) must share one
  database and resolve to one catalog, else a hard error.

Validate, the runner preflight, both runners and the Kafka Connect
late-arrival target read iceberg_sink_target, which applies this join to
derived configs. A hand-written config, or an override that sets
iceberg.tables or table-namespace, is checked against the first Iceberg
sink expose as before.
…d to start; confluent defaults to glue

CON-3: Iceberg's DynamoDbCatalog and JdbcCatalog refuse to initialise
without a warehouse, but the kind table asked nothing (dynamodb) or only
uri (jdbc) of them, and the resolver ignored location.bucket for them.
Both now require a warehouse. The resolver takes location.warehouse, else
derives one from an explicit location.bucket (and path, as the Glue
helper builds it) with the platform scheme (aws s3://, gcp gs://), never
from the account-derived fallback bucket. When none can be derived,
validate errors and the runner preflight refuses. Both the Kafka Connect
and the embedded Debezium configs emit the warehouse.

CON-4: BigQueryMetastoreCatalog refuses to initialise without
gcp.bigquery.project-id. location.project now maps to it (a missing
project is a validate error and a preflight refusal), location.region maps
to gcp.bigquery.location instead of the AWS-only client.region, and the
gs:// storage URI dbt-bigquery and the GCP IaC use becomes the warehouse,
with a validate warning when none can be derived. The Kafka Connect
deriver now forwards the resolver's catalog-specific properties under the
iceberg.catalog. prefix, as the Debezium deriver already did.

CON-5: default_catalog_kind returns glue for platform confluent, the
catalog the Tableflow IaC publishes an absent-catalog table to. Policy
compile now grants the Glue table instead of dropping every grant with a
false "cataloged in 'rest'" warning, and dbt-snowflake keeps the Glue
linked-database type.
…og needs to start

The DynamoDB / JDBC warehouse and the BigQuery gcp.bigquery.project-id
requirements read only binding.location, so validate and the preflight
refused a config whose pushed form carried the key: a Kafka Connect
iceberg_catalog_overrides or sink_connector_config entry, or a Debezium
server.sink.config entry. Main accepted these. Check 4 now counts the key
when an override map sets the catalog property under the runtime's prefix
(iceberg.catalog.<prop> on Kafka Connect, bare on Debezium Server), and
still errors when neither the binding nor an override supplies it. The
refusal names the override remedy.

The BigQuery no-warehouse warning understated the Kafka Connect case: its
auto-create calls createNamespace before every createTable, and
BigQueryMetastoreCatalog.createNamespace refuses without a warehouse, so
table auto-creation fails even in a dataset that exists
(apache-iceberg-1.10.0 IcebergWriterFactory.java:89, :122-137;
BigQueryMetastoreCatalog.java:197-208, :298-303). The Debezium Server sink
creates a namespace only when it is missing, so its wording keeps the
dataset storage URI exception (debezium-server-iceberg 1.2.0.Final
IcebergUtil.java:98-126).
…d only by the runner preflight

A dynamodb or jdbc expose whose location.bucket is an unresolved {{ env.* }}
template was treated as having no bucket: fluid validate failed in a shell
without the variable and asked for the bucket the contract already sets.
Validate now warns and names the unset variables; the runner preflight, in
the environment the sink config is derived in, refuses the build.
A {{ env.* }} bucket whose variable is set but empty resolves to an empty
bucket, which derives no warehouse. Validate warned only for unset
variables, so the empty case fell through to the error that asks for the
bucket the contract already sets. The check now reads the raw template
and names every variable that is unset or empty.
…tten

The acquisition dispatcher resolves {{ env.X }} before the runner sees the
contract, turning an unset or empty X into an empty string. The preflight
then passed acme-{{ env.LAKE_ENV }}-lake as acme--lake and the sink was
pushed a warehouse in a bucket the contract does not name. The dispatcher
now runs the streaming runners inside contract_as_written, so the preflight
reads the bucket template, names the variable and refuses the build.

The bigquery deriver renders the bucket's env templates and derives no
warehouse from one that does not resolve; an empty variable counts as
unset for every bucket-derived warehouse. The Debezium bigquery
no-warehouse warning says the server refuses to boot, since
debezium.sink.iceberg.warehouse has no default.
…en sink ignores it

run_builds_from_args resolves a plan-embedded contract with
resolve_contract_env_templates before dispatching, and that resolver
renders an empty variable to "". The dispatcher then handed that
partially resolved contract to contract_as_written, so with LAKE_ENV
empty acme-{{ env.LAKE_ENV }}-lake reached the preflight as acme--lake
and the sink was pushed s3://acme--lake/... . The dispatcher now takes
the contract as written from its caller, and the binding match strips
strings the way the plan resolver does.

The preflight also refused a hand-written (non-deriving) sink config
when a bucket variable it never reads was unset. Only a deriving build
now waits on the bucket's variables.
…out auto-create only warns

A whitespace-only location.warehouse reached the sink verbatim. It now
counts as unset, so a DynamoDB or JDBC sink derives the warehouse from
the bucket or is refused.

A Kafka Connect BigQuery sink reads the warehouse only to auto-create
tables (iceberg.tables.auto-create-enabled defaults to false,
apache-iceberg-1.10.0 IcebergSinkConfig.java:154-159). When its bucket
variable is unset or empty, the run now refuses only with auto-create on;
otherwise it warns and pushes no warehouse rather than one in a bucket
the contract does not name.
…unts as missing

5047720 dropped the derived warehouse when the bucket variable was unset,
but iceberg_catalog_overrides is already merged into the derived config,
so an operator's override warehouse was dropped too and a DynamoDB, JDBC
or BigQuery sink was pushed without one. The runner now drops only a
warehouse check 4 would have derived: none for a build that does not
derive, or whose overrides set the warehouse.

A whitespace-only location.project passed as gcp.bigquery.project-id. The
project and region are stripped, and check 4 tests the stripped value, so
a blank project is the missing-project error.
…n-wave

# Conflicts:
#	fluid_build/build_runners/kafka_connect/iceberg_sink_validation.py
…es of one database is one catalog

A DynamoDB or JDBC warehouse derived from location.bucket is a per-table
prefix, as Glue's is, so two such exposes of one database no longer read
as two catalogs to the embedded Debezium join: only their bucket is
compared. A BigQuery project or location that differs is named as the
binding's project or region, not as catalog properties. Adds tests for
the expose resolver and the catalog derivation together.
…zium sink over differing derived warehouses

policy compile read only location.table for a Confluent Tableflow expose,
so it granted a Glue table nothing publishes, or with no table a
database-wide glue.table with table null. It now grants
<database>.<topic>, the table the Tableflow IaC names (_topic_name: topic >
table > exposeId), in location.database or, with none, the Kafka cluster
id database Tableflow defaults to; with neither it warns and grants no
Glue table.

An embedded Debezium sink over several DynamoDB / JDBC exposes used the
first expose's bucket-derived table prefix as the catalog warehouse, so
the catalog created every other table inside that prefix. Derived
warehouses are compared whole again, and differing ones are refused by
validate, the preflight and the runner, naming each expose's warehouse.
@fas89 fas89 added the ci:integration-emulated Maintainer vouch — run the admin-gated heavy-emulated lane (bigquery-emulator + LocalStack) label Oct 8, 2026
@fas89
fas89 deployed to integration-emulated October 8, 2026 23:50 — with GitHub Actions Active
@fas89
fas89 had a problem deploying to integration-emulated October 8, 2026 23:50 — with GitHub Actions Failure
@github-actions github-actions Bot added provider Changes or requests related to providers cli Changes to the CLI surface or implementation tests Test coverage or test infrastructure changes needs-docs Pull request needs a linked docs update or justification labels Oct 8, 2026
@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown

📄 Documentation Reminder

This PR appears to be missing a documentation reference. Our docs live in a separate repo.

Please update the PR description with one of:

  • Link a docs PR — check the "Docs PR linked" box and paste the URL
  • Mark as no docs needed — check "No docs needed" with a justification
  • Acknowledge docs TODO — check "Docs TODO" and create the docs PR before merge

See the Contributing Guide for details.

…h, never the value

The schema type error policy_compiler_crashed printed included the value
as written, so a connection URL with a password in a binding's location
reached stderr and CI logs. It now names the path and the expected type
only.
@fas89
fas89 had a problem deploying to integration-emulated October 9, 2026 00:04 — with GitHub Actions Error
@fas89
fas89 had a problem deploying to integration-emulated October 9, 2026 00:04 — with GitHub Actions Error
@github-actions github-actions Bot removed the ci:integration-emulated Maintainer vouch — run the admin-gated heavy-emulated lane (bigquery-emulator + LocalStack) label Oct 9, 2026
@fas89 fas89 added the ci:integration-emulated Maintainer vouch — run the admin-gated heavy-emulated lane (bigquery-emulator + LocalStack) label Oct 9, 2026
@fas89
fas89 deployed to integration-emulated October 9, 2026 00:06 — with GitHub Actions Active
@fas89
fas89 had a problem deploying to integration-emulated October 9, 2026 00:06 — with GitHub Actions Failure
@github-actions github-actions Bot removed the needs-docs Pull request needs a linked docs update or justification label Oct 9, 2026
The sink named its table's namespace from location.database only. A GCP
binding names its dataset in location.dataset, so a BigQuery or other GCP
Iceberg sink pushed iceberg.tables=None.<table> and validate passed. The
namespace is now location.database, else location.dataset (the BigQuery
metastore catalog maps a namespace to a dataset, apache-iceberg-1.10.0
BigQueryMetastoreCatalog.java:149, :200, :307). A derived sink whose expose
names no namespace, or a derived Kafka Connect sink with no table, is
refused by validate and the run preflight.
@fas89
fas89 had a problem deploying to integration-emulated October 9, 2026 09:27 — with GitHub Actions Error
@fas89
fas89 had a problem deploying to integration-emulated October 9, 2026 09:27 — with GitHub Actions Error
@github-actions github-actions Bot removed the ci:integration-emulated Maintainer vouch — run the admin-gated heavy-emulated lane (bigquery-emulator + LocalStack) label Oct 9, 2026
@fas89 fas89 added the ci:integration-emulated Maintainer vouch — run the admin-gated heavy-emulated lane (bigquery-emulator + LocalStack) label Oct 9, 2026
@fas89
fas89 deployed to integration-emulated October 9, 2026 09:29 — with GitHub Actions Active
@fas89
fas89 deployed to integration-emulated October 9, 2026 09:29 — with GitHub Actions Active
@fas89
fas89 had a problem deploying to integration-emulated October 11, 2026 07:39 — with GitHub Actions Error
@fas89
fas89 had a problem deploying to integration-emulated October 11, 2026 07:39 — with GitHub Actions Error
@github-actions github-actions Bot removed the ci:integration-emulated Maintainer vouch — run the admin-gated heavy-emulated lane (bigquery-emulator + LocalStack) label Oct 11, 2026
@fas89 fas89 added the ci:integration-emulated Maintainer vouch — run the admin-gated heavy-emulated lane (bigquery-emulator + LocalStack) label Oct 11, 2026
@fas89
fas89 had a problem deploying to integration-emulated October 11, 2026 07:40 — with GitHub Actions Error
@fas89
fas89 deployed to integration-emulated October 11, 2026 07:40 — with GitHub Actions Active
…t with pydantic 2.14

mcp 1.20 and 1.21 import eval_type_backport from
pydantic._internal._typing_extra, which pydantic 2.14 removed, so
mcp.server.fastmcp fails to import with current pydantic (verified: mcp
1.20 imports with pydantic 2.13.5 and fails with 2.14.0; 1.22 is the first
1.x that imports with 2.14.0). The floor CI leg force-reinstalled mcp==1.20
with the newest pydantic and was red on main. The pin is now
mcp>=1.22,<3.0 and the leg tests 1.22.
@github-actions github-actions Bot added ci Continuous integration and automation changes ci:integration-emulated Maintainer vouch — run the admin-gated heavy-emulated lane (bigquery-emulator + LocalStack) and removed ci:integration-emulated Maintainer vouch — run the admin-gated heavy-emulated lane (bigquery-emulator + LocalStack) labels Oct 11, 2026
@fas89
fas89 deployed to integration-emulated October 11, 2026 07:48 — with GitHub Actions Active
@fas89
fas89 deployed to integration-emulated October 11, 2026 07:48 — with GitHub Actions Active
@fas89
fas89 merged commit 86616e0 into main Oct 11, 2026
42 checks passed

This branch was successfully deployed

1 active deployment
integration-emulated — 7e73e2fc Deployed Oct 11, 2026 by fas89 via DataHub Quickstart integration #404
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci:integration-emulated Maintainer vouch — run the admin-gated heavy-emulated lane (bigquery-emulator + LocalStack) ci Continuous integration and automation changes cli Changes to the CLI surface or implementation provider Changes or requests related to providers tests Test coverage or test infrastructure changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant