Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@ on:
branches: [main]
# A change to docs or Markdown alone cannot break a build, so it skips CI.
# docs/coverage still runs it: the Coverage job regenerates the page there.
# docs/published runs it too: the coverage generator holds each published
# page to the invariants it cites, so a page-only edit must reach that check.
pull_request:
branches: [main]
paths:
- "**"
- "!docs/**"
- "!**.md"
- "docs/coverage/**"
- "docs/published/**"
jobs:
# The Go engine (sqlflow), unit pass only. Fast feedback with no Docker: the
# integration tests start containers, and the job below runs those. -short
Expand Down
5 changes: 5 additions & 0 deletions dev/config/examples/published/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,8 @@ generated JSON schema.
When a page changes, change the file here first and copy it out. When the
engine changes, CI tells you which published page needs an edit before a
reader finds out.

The ClickHouse page itself lives in this repo too, at
[`docs/published/clickhouse.mdx`](../../../../docs/published/clickhouse.mdx).
Its YAML blocks must equal the two `clickhouse.*` files here byte for byte,
and the coverage generator fails when they drift.
4 changes: 2 additions & 2 deletions docs/coverage/clickhouse-types.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ SQLFlow hands each batch to ClickHouse as rows built from the Arrow table DuckDB
| `UBIGINT` | `UInt64` | |
| `REAL` | `Float32` | |
| `DOUBLE` | `Float64` | |
| `VARCHAR`, `UUID`, `JSON` | `String`, `LowCardinality(String)`, `DateTime64(3)`, `DateTime`, `Enum8('' = 0, 'a' = 1, 'b' = 2)`, `FixedString(36)`, `UUID`, `IPv4`, `Decimal(10, 2)` | |
| `VARCHAR` | `String`, `LowCardinality(String)`, `DateTime64(3)`, `DateTime`, `Enum8('' = 0, 'a' = 1, 'b' = 2)`, `FixedString(36)`, `UUID`, `IPv4`, `Decimal(10, 2)` | the text must already be the column's text form. `UUID` and `JSON` reach the sink as `VARCHAR`. |
| `BLOB`, `BIT`, `VARINT` | `String` | |
| `DATE` | `Date`, `Date32` | |
| `TIMESTAMP_S` | `DateTime` | |
Expand Down Expand Up @@ -46,7 +46,7 @@ SQLFlow hands each batch to ClickHouse as rows built from the Arrow table DuckDB
| `TIMESTAMP_NS[]` | `Array(DateTime64(9))` | |
| `TIMESTAMPTZ[]` | `Array(DateTime64(6))` | stored as the same UTC instant; the zone is dropped and the column's own zone governs rendering |
| `BIGINT[][]` | `Array(Array(Int64))` | |
| `NULL` in any column | the column's own type | the column type's zero value; the DEFAULT expression is not evaluated because every produced column is named in the INSERT |
| `NULL` in any column | the column's own type | the column type's zero value; the DEFAULT expression is not evaluated because every produced column is named in the INSERT. For an Enum with no 0 member that value is not a member, and the row cannot be read |
| `NULL` inside a `LIST` | the element's own type | the element type's zero value, because ClickHouse's Array(T) holds no nulls unless T is Nullable |

Values are matched to columns **by name**, not by position.
Expand Down
3 changes: 2 additions & 1 deletion docs/coverage/integrations/sink.clickhouse.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ types:
# override the value the lattice sends.
utf8:
outcome: exact
rule: the text must already be the column's text form.
columns:
- type: String
expect: "L'Œil 👁 \"quoted\" back\\slash\ttab"
Expand Down Expand Up @@ -374,7 +375,7 @@ types:
nulls:
default:
outcome: coerced
rule: the column type's zero value; the DEFAULT expression is not evaluated because every produced column is named in the INSERT
rule: the column type's zero value; the DEFAULT expression is not evaluated because every produced column is named in the INSERT. For an Enum with no 0 member that value is not a member, and the row cannot be read
# The third null position, and the one the published page does not
# mention. arrowListValue appends the element type's zero value at
# clickhouse.go:429-431, so [1, null, 3] lands as [1, 0, 3] with no
Expand Down
146 changes: 141 additions & 5 deletions docs/coverage/invariants.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
#
# `applies_to` is the interface the invariant is a property of. Every
# integration of that kind must prove it, or be exempt in the registry
# with a reason.
# with a reason. It may instead name one integration, for a claim only that
# integration makes; the id then starts with the integration's namespace.
#
# `verified_by` says how evidence attaches:
#
Expand All @@ -19,10 +20,11 @@
# never implemented, and dropped as a third way to claim the same evidence.
# scripts/coverage_matrix accepts the two above and nothing else.
#
# `requires` lists the levels evidence must exist at. Every entry is empty in
# this revision: the matrix reports, and nothing fails. An invariant is
# enforced by filling one in, which is legal only once every non-exempt
# integration has a subject.
# `requires` lists the levels evidence must exist at. Every kind's entry is
# empty in this revision: the matrix reports, and nothing fails. An invariant
# is enforced by filling one in, which is legal only once every non-exempt
# integration has a subject. An invariant scoped to one integration, such as
# clickhouse.*, is written with its subject and requires its level.
#
# `tracked_by` marks an invariant the code does not yet satisfy. It renders as
# declared-unenforced until that issue lands.
Expand Down Expand Up @@ -396,6 +398,140 @@ invariants:
verified_by: typetable
requires: []

# --- ClickHouse ----------------------------------------------------------
# Claims only sink.clickhouse makes. Each one backs a sentence on
# docs/published/clickhouse.mdx, which cites it by id, and the page check
# fails when the two disagree. A failing test here is a page that is now
# wrong: fix the page or the sink, never the assertion.
- id: clickhouse.insert.by_name
family: clickhouse
class: safety
applies_to: sink.clickhouse
claim: >
Columns are matched by name. A produced column the table lacks
fails the batch as `system.sink.write_failed`, naming the column.
A column the handler omits takes the table's DEFAULT.
verified_by: harness
requires: [integration]

- id: clickhouse.table.not_created
family: clickhouse
class: safety
applies_to: sink.clickhouse
claim: >
The sink never creates or alters a table. A table that does not
exist fails the batch as `system.sink.write_failed`.
verified_by: harness
requires: [integration]

- id: clickhouse.enum.null_is_zero
family: clickhouse
class: safety
applies_to: sink.clickhouse
claim: >
A NULL into a non-Nullable Enum is stored as 0. With no 0 member
the INSERT succeeds and reads of the column fail with
`UNKNOWN_ELEMENT_OF_ENUM`. With a 0 member it reads as that
member. Nullable reads NULL.
verified_by: harness
requires: [integration]

- id: clickhouse.encode.fails_once
family: clickhouse
class: safety
applies_to: sink.clickhouse
claim: >
A value the driver cannot encode fails the batch on the first
attempt as `user.sink.encode_failed`. It is not retried, and the
process exits with code 10.
verified_by: harness
requires: [integration]

- id: clickhouse.temporal_text.two_shapes
family: clickhouse
class: safety
applies_to: sink.clickhouse
claim: >
Text into a DateTime column takes `YYYY-MM-DD hh:mm:ss`, read as UTC
whatever the column's zone, or the same with an offset, which is
applied. Any other shape fails as `user.sink.encode_failed`.
verified_by: harness
requires: [integration]

- id: clickhouse.decimal.text_only
family: clickhouse
class: safety
applies_to: sink.clickhouse
claim: >
A Decimal column accepts its value as text. A float fails as
`user.sink.encode_failed`.
verified_by: harness
requires: [integration]

- id: clickhouse.replay.duplicates_until_merge
family: clickhouse
class: safety
applies_to: sink.clickhouse
claim: >
A batch delivered twice into a ReplacingMergeTree reads twice
until a merge, and once with FINAL. An identical block inside the
table's deduplication window is dropped at insert.
verified_by: harness
requires: [integration]

- id: clickhouse.flush.one_insert
family: clickhouse
class: safety
applies_to: sink.clickhouse
claim: >
One flush is one INSERT, however many batches it buffered.
verified_by: harness
requires: [integration]

- id: clickhouse.dsn.scheme
family: clickhouse
class: safety
applies_to: sink.clickhouse
claim: >
The DSN scheme decides the protocol, TLS and the default port. `@`
and `:` in a password parse raw. `/`, `#` and `?` must be percent-
encoded.
verified_by: harness
requires: [unit]

- id: clickhouse.tls.verifies
family: clickhouse
class: safety
applies_to: sink.clickhouse
claim: >
With TLS, the server certificate is verified against the system
roots. No option skips verification.
verified_by: harness
requires: [unit]

- id: clickhouse.auth.fails_start
family: clickhouse
class: safety
applies_to: sink.clickhouse
claim: >
Wrong credentials fail the startup probe, before any message is
consumed, with ClickHouse's "Authentication failed: password is
incorrect, or there is no user with such name". The HTTP status and
code vary by server and user.
verified_by: harness
requires: [integration]

- id: clickhouse.type_text.per_destination
family: clickhouse
class: safety
applies_to: sink.clickhouse
claim: >
Text into a non-String column must already be in that column's
text form. The type table proves each destination with its own
value.
verified_by: harness
requires: [integration]

# --- Lifecycle -----------------------------------------------------------
- id: lifecycle.drain.on_cancel
family: lifecycle
Expand Down
21 changes: 19 additions & 2 deletions docs/coverage/matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ integration behind it keeps a batch it could not deliver, or commits
offsets only after a flush. Those are invariants, they are counted
separately below, and the two numbers are not interchangeable.

**55 invariants declared: 47 safety and 8 liveness. Of 195 (invariant, integration) cells: 97 proven, 63 missing, 0 skipped, 0 failing, 35 exempt. 0 gap(s).**
**67 invariants declared: 59 safety and 8 liveness. Of 207 (invariant, integration) cells: 110 proven, 62 missing, 0 skipped, 0 failing, 35 exempt. 0 gap(s).**

Safety says nothing bad happens. Liveness says something good
eventually does, and the two are not interchangeable: a sink that
Expand Down Expand Up @@ -122,7 +122,7 @@ until an invariant's `requires` is filled in, and none is yet.
| `sink.flush.honours_context` | Flush returns ctx.Err() when the context ends. Rows stay buffered. *(violated once: #219)* | ✅ i | — exempt | — exempt | ✅ i | — exempt | ✅ i | — exempt |
| `sink.flush.empty_is_noop` | Flush with nothing buffered returns nil and touches nothing. | ✅ i | ✅ u | ✅ u | ✅ i | — exempt | ✅ i | ✅ u |
| `sink.buffer.reports_depth` | A sink reports how many rows it is holding, and the count rises when a flush fails and falls to zero when one succeeds. | ✅ i | ✅ u | ✅ u | ✅ i | — exempt | ✅ i | ✅ u |
| `sink.error.classifies` | The sink's errors classify as unreachable, rejected, or a user fault, so the retry ladder retries only the first. A value the sink's client cannot encode is a user fault: it never reaches the network and fails the same way every attempt. | ❌ missing | ❌ missing | ❌ missing | ❌ missing | — exempt | ❌ missing | ❌ missing |
| `sink.error.classifies` | The sink's errors classify as unreachable, rejected, or a user fault, so the retry ladder retries only the first. A value the sink's client cannot encode is a user fault: it never reaches the network and fails the same way every attempt. | ✅ i | ❌ missing | ❌ missing | ❌ missing | — exempt | ❌ missing | ❌ missing |
| `sink.probe.fails_start` | A Prober whose destination is unreachable fails the start once, without retrying. | ✅ i | — exempt | — exempt | ✅ i | — exempt | ✅ i | — exempt |
| `sink.flush.idempotent_on_key` | Delivering the same batch twice leaves the destination holding it once. The engine promises at-least-once; a sink that identifies rows by key is what turns that into exactly-once for the reader. A sink that declares no key is exempt, with a proof that it declares none. | — exempt | — exempt | — exempt | — exempt | — exempt | ✅ i | — exempt |

Expand Down Expand Up @@ -224,6 +224,23 @@ drains. An invariant holds only if it holds on all four.
| `serve.cache.errors_not_stored` | A request that fails stores nothing: the next request for its key runs a query. | ✅ u |
| `serve.cache.one_fill` | Concurrent requests for one key run one query, and a caller leaving fails no other caller. | ✅ u |

## Safety invariants: clickhouse

| Invariant | Claim | `sink.clickhouse` |
| --- | --- | --- |
| `clickhouse.insert.by_name` | Columns are matched by name. A produced column the table lacks fails the batch as `system.sink.write_failed`, naming the column. A column the handler omits takes the table's DEFAULT. | ✅ i |
| `clickhouse.table.not_created` | The sink never creates or alters a table. A table that does not exist fails the batch as `system.sink.write_failed`. | ✅ i |
| `clickhouse.enum.null_is_zero` | A NULL into a non-Nullable Enum is stored as 0. With no 0 member the INSERT succeeds and reads of the column fail with `UNKNOWN_ELEMENT_OF_ENUM`. With a 0 member it reads as that member. Nullable reads NULL. | ✅ i |
| `clickhouse.encode.fails_once` | A value the driver cannot encode fails the batch on the first attempt as `user.sink.encode_failed`. It is not retried, and the process exits with code 10. | ✅ ui |
| `clickhouse.temporal_text.two_shapes` | Text into a DateTime column takes `YYYY-MM-DD hh:mm:ss`, read as UTC whatever the column's zone, or the same with an offset, which is applied. Any other shape fails as `user.sink.encode_failed`. | ✅ i |
| `clickhouse.decimal.text_only` | A Decimal column accepts its value as text. A float fails as `user.sink.encode_failed`. | ✅ i |
| `clickhouse.replay.duplicates_until_merge` | A batch delivered twice into a ReplacingMergeTree reads twice until a merge, and once with FINAL. An identical block inside the table's deduplication window is dropped at insert. | ✅ i |
| `clickhouse.flush.one_insert` | One flush is one INSERT, however many batches it buffered. | ✅ i |
| `clickhouse.dsn.scheme` | The DSN scheme decides the protocol, TLS and the default port. `@` and `:` in a password parse raw. `/`, `#` and `?` must be percent- encoded. | ✅ u |
| `clickhouse.tls.verifies` | With TLS, the server certificate is verified against the system roots. No option skips verification. | ✅ u |
| `clickhouse.auth.fails_start` | Wrong credentials fail the startup probe, before any message is consumed, with ClickHouse's "Authentication failed: password is incorrect, or there is no user with such name". The HTTP status and code vary by server and user. | ✅ i |
| `clickhouse.type_text.per_destination` | Text into a non-String column must already be in that column's text form. The type table proves each destination with its own value. | ✅ i |

## Liveness invariants: lifecycle

| Invariant | Claim | `manager.watermark` |
Expand Down
14 changes: 13 additions & 1 deletion docs/coverage/status/sink.clickhouse.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
# Generated by `make coverage-matrix`. Do not edit by hand.
# One line per invariant that applies to sink.clickhouse, sorted by id: the status of each level.
clickhouse.auth.fails_start: {unit: skipped, integration: covered, growth: missing, release: missing}
clickhouse.decimal.text_only: {unit: skipped, integration: covered, growth: missing, release: missing}
clickhouse.dsn.scheme: {unit: covered, integration: missing, growth: missing, release: missing}
clickhouse.encode.fails_once: {unit: covered, integration: covered, growth: missing, release: missing}
clickhouse.enum.null_is_zero: {unit: skipped, integration: covered, growth: missing, release: missing}
clickhouse.flush.one_insert: {unit: skipped, integration: covered, growth: missing, release: missing}
clickhouse.insert.by_name: {unit: skipped, integration: covered, growth: missing, release: missing}
clickhouse.replay.duplicates_until_merge: {unit: skipped, integration: covered, growth: missing, release: missing}
clickhouse.table.not_created: {unit: skipped, integration: covered, growth: missing, release: missing}
clickhouse.temporal_text.two_shapes: {unit: skipped, integration: covered, growth: missing, release: missing}
clickhouse.tls.verifies: {unit: covered, integration: missing, growth: missing, release: missing}
clickhouse.type_text.per_destination: {unit: skipped, integration: covered, growth: missing, release: missing}
lifecycle.close.idempotent: {unit: missing, integration: covered, growth: missing, release: missing}
sink.buffer.reports_depth: {unit: missing, integration: covered, growth: missing, release: missing}
sink.error.classifies: {unit: missing, integration: missing, growth: missing, release: missing}
sink.error.classifies: {unit: skipped, integration: covered, growth: missing, release: missing}
sink.flush.empty_is_noop: {unit: missing, integration: covered, growth: missing, release: missing}
sink.flush.honours_context: {unit: missing, integration: covered, growth: missing, release: missing}
sink.flush.idempotent_on_key: {unit: exempt, integration: exempt, growth: exempt, release: exempt}
Expand Down
36 changes: 36 additions & 0 deletions docs/published/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Published pages

Pages SQLFlow publishes in third-party docs. Each page here is the source of
truth for what a vendor's docs say about SQLFlow. The vendor's repository
gets a copy, by hand, after review.

A page carries an anchor comment beside each claim. MDX renders none of them:

| Anchor | What it cites | What the check requires |
| --- | --- | --- |
| `{/* invariant: <id> */}` | a claim in `docs/coverage/invariants.yml` | the id is declared |
| `{/* measured: <path> */}` | a committed output file that names its versions | the file exists |
| `{/* config: <file> */}` | the YAML block that follows | it equals `dev/config/examples/published/<file>` |
| `{/* feature: <id> */}` | a tooling claim, proven by a named test | the id is declared in `features.yml` |
| `{/* generated: type-table */}` | the type table that follows | it equals what `sink.clickhouse.yml` renders |
| `{/* generated: unsupported-types */}` | the list that follows | it equals the rendered unsupported list |

A page named for a namespace cites every invariant in it: `clickhouse.mdx`
must cite each `clickhouse.*` invariant at least once.

The coverage generator runs the check before it reads any test report, so
`make coverage-page`, `make coverage-check` and CI's Coverage job all fail on
a page that drifted from its evidence. An invariant whose test fails fails
the Integration job, which names the page claim through the invariant's id.

## Pages

| Page | Published in | Evidence |
| --- | --- | --- |
| [`clickhouse.mdx`](clickhouse.mdx) | ClickHouse docs, `docs/integrations/connectors/data-ingestion/community-integrations/sqlflow.mdx` | [`clickhouse/`](clickhouse/README.md) |

## Changing a page

1. Change the claim here, and its invariant, test or measurement with it.
2. Run `make coverage-page` and `uv run --locked pytest tests/tooling -q`.
3. After the pull request merges, copy the page to the vendor's repository.
Loading
Loading