Skip to content

chore: sync upstream v1.7.0 into the fork - #8

Merged
kiliman merged 66 commits into
masterfrom
kiliman/sync-upstream-1.7.0
Oct 21, 2026
Merged

kiliman merged 66 commits into
masterfrom
kiliman/sync-upstream-1.7.0

Conversation

@kiliman

@kiliman kiliman commented Oct 20, 2026

Copy link
Copy Markdown

Summary

TL;DR Catches the fork up to upstream v1.7.0. GitHub says we're 63 commits behind, but only 12 upstream changes are actually missing. Our two earlier syncs were squash-merged, which kept the code but dropped upstream's history, so GitHub kept counting commits we already had. After this merge the fork is 0 behind, and our remaining fork-only code gets smaller.

Important

Please merge with "Create a merge commit", not squash. A squash drops the upstream ancestry again, and the "behind" count and the phantom conflicts come right back on the next sync.

What reviewers should look at:

  • The three conflict resolutions, all "keep both". connect now uses upstream's code verbatim.
  • Stacked read retries: a persistent connection failure on a SELECT is now attempted 4 times instead of 2.
  • Consumer-visible changes: timeout error classes change, and TLS verification becomes possible but stays off by default.

The Full Story 🔍

Details

Why "63 behind" was really 12

The July sync (#3) brought in upstream through PNixx#234 (June 9), and its commit body lists every change. Because it was squash-merged, git's merge-base stayed at a July 2025 commit. A plain git merge upstream/master therefore re-fought everything #1 and #3 had already resolved, and produced 15 conflicts.

The first commit on this branch fixes that with git merge -s ours 7a9c62f. It tells git "we already have upstream through PNixx#234" without changing a single file. That's safe because git diff 7a9c62f origin/master showed only fork-specific changes. With the correct merge-base, the real upstream merge had 3 conflicts.

Conflicts

What's left of #7, and why it stays

Only the rescue Net::OpenTimeout → ActiveRecord::ConnectionNotEstablished in connect, plus one spec. The other three open_timeout specs duplicated upstream's connection_hardening_spec, so they're gone.

Invariant: a connect timeout must reach the app as an ActiveRecord error. Swarm's Apiary callers rescue ActiveRecord::ActiveRecordError to fall back to Postgres (for example active_subscriptions_count). The adapter connects eagerly in initialize, which is outside upstream's new read-query retry. So without this rescue, a cold connect timeout surfaces as a bare Net::OpenTimeout: the request fails fast, but it still fails. That's the hang ENT-861 was meant to turn into a fallback. A mutation check confirmed it: with the rescue removed, the spec fails with got #<Net::OpenTimeout>. This rescue is a good candidate to upstream, which would let the fork drop it entirely.

Stacked retries

The fork's raw_execute retries EOFError, ECONNRESET and Net::HTTPBadResponse once, for every statement. Upstream's PNixx#266 adds its own retry around SELECTs in internal_exec_query, which reconnects and also covers Net::OpenTimeout and IOError. Together, a SELECT on a connection that keeps failing makes 4 POSTs instead of 2, and still raises ActiveRecord::ConnectionFailed. Those failures are immediate and re-sending a SELECT is safe, so I documented the count in upstream's spec rather than reworking either layer. Reconciling them would only add fork-only code.

Worth knowing: upstream deliberately never retries writes, and the fork's raw_execute retry has covered writes since 2024. This PR doesn't change that, but it's the one real difference in approach between the two.

Changes consumers will notice

  • Timeouts get Rails error classes. A server-side TIMEOUT_EXCEEDED now raises ActiveRecord::StatementTimeout, and a SELECT Net::ReadTimeout raises ActiveRecord::AdapterTimeout. Both used to be a plain ActiveRecordError. Both are StatementInvalid subclasses, so rescue ActiveRecord::StatementInvalid now catches timeouts it used to let through.
  • TLS verification is opt-in. Connections have always used VERIFY_NONE, and that's still the default (insecure: true). Setting insecure: false turns verification on, and sslca is now actually wired up (it was accepted before but never used). Turning it on in swarm is a separate follow-up.
  • The query cache is cleared after raw writes, along with joins_final, Float64 round-tripping in the schema dumper, DEFAULT NULL read as nil, and server-version comparisons that no longer treat versions as floats.

Verification

  • The fork suite passes against ClickHouse 26.2: 251 examples, 0 failures. Fork master was 210/210 before the merge, and the drop from 254 to 251 is the three removed duplicates.
  • I ran swarm against this branch by putting the fork's lib first on the load path, with no Gemfile change:
    • it reported 1.7.0 loaded from the fork,
    • live queries against dev ClickHouse worked, including Float64, Array, and primary_keys,
    • a max_execution_time overrun raised StatementTimeout,
    • the Apiary model specs and the database config spec passed (222 examples, 0 failures).

From Claudia with 💙

leboshi and others added 30 commits September 5, 2025 08:52
* Allow removal of `FINAL` keyword.

Rewrite to conform to normal ActiveRecord definition structure.

* Rename :check_command to :check_command! to reflect that its purpose is to raise an error on a failed check.

* Rewrite :settings, while we're in here, to conform to the AR query method API, too.

* Allow :unscope to handle :settings and :final.

* Invoke correct method for Rails 7.2 and above.
…Nixx#220)

The regex in assign_database_to_subquery! used a lookbehind (?<=from)
which matched 'from' anywhere in the SQL, including in column names
like 'sourced_from'. This caused the next identifier to be incorrectly
prefixed with the database name.

Bug example:
  SELECT sourced_from, now() FROM table

Would become:
  SELECT sourced_from, default.now() FROM default.table

Causing error:
  Function with name 'default.now' does not exist

Fixed by using \bfrom\s+ to match 'from' only as a complete word
(SQL keyword), not as part of column names.

Added tests covering:
- Column names containing 'from'
- Verification that functions are not prefixed
- Verification that table names are correctly prefixed

All 93 tests pass.

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

Co-authored-by: Claude <noreply@anthropic.com>
* Add JSON column type support

Adds support for ClickHouse JSON column type to fix schema dump failures.

- Add json: { name: 'JSON' } to NATIVE_DATABASE_TYPES constant
- Register JSON type mapping using ActiveRecord::Type::Json
- Add comprehensive test coverage for JSON column operations
- Include tests for column recognition, CRUD operations, and migrations
- Handle ClickHouse JSON type limitations (non-nullable, numbers as strings)

Resolves "Unknown type 'JSON' for column" errors in Rails schema dumps.
Requires allow_experimental_json_type = 1 setting in ClickHouse 21.1+.

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

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix JSON migration test for ClickHouse 25.10 compatibility

Use MergeTree engine for JSON migration test instead of relying on the
default Log engine. The Log engine doesn't support JSON columns with
dynamic subcolumns in ClickHouse, which causes test failures in version
25.10.

This change ensures compatibility with both ClickHouse 24.9 and 25.10.

All 98 tests now pass on both versions.

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

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
The minitest and rspec helpers were changing ActiveRecord::Base.connection
to ClickHouse without restoring it afterward. This broke multi-database
Rails apps where the primary database is PostgreSQL/MySQL.
crespire and others added 23 commits June 9, 2026 10:28
The shared @response_format instance variable on the adapter is mutated
by with_response_format, which is unsafe when an adapter is checked out
across threads (e.g. Capybara system specs where the test thread and
Puma server thread share a connection). One thread's `nil` override
inside a wrapped INSERT path could leak into another thread's SELECT
default, causing the SELECT to be sent without a FORMAT clause and the
response to come back as a raw String body instead of a parsed Hash —
manifesting as `Response: undefined method 'map' for nil` from
internal_exec_query.

Replace the shared ivar with a per-thread, per-adapter stack stored on
Thread.current. with_response_format pushes/pops; the new private
response_format reader falls back to a new @default_response_format
ivar (renamed from @response_format) when no override is in scope.
Public API is unchanged.

Add spec/single/response_format_thread_safety_spec.rb covering the
cross-thread isolation regression and single-thread nesting contract.
* Add URL-based configuration support for the ClickHouse adapter

Allows configuring the adapter via a single `url` key
(e.g. `clickhouse://user:pass@host:8123/db?ssl=true`) instead of
requiring every field to be specified individually. Explicit config keys
take precedence over URL-parsed values.

* Fix URL parsing bypass when using Rails 7.2+ adapter registry

Rails 7.2+ uses the `register` API which calls `ClickhouseAdapter.new`
directly, bypassing `clickhouse_connection` where URL parsing lived.
Move `parse_clickhouse_url` to a public class method on `ClickhouseAdapter`
and call it from `initialize` so it works regardless of entry point.
* Account for arel node scopes without a relation

In this case, we get an error when trying to use the
`Arel::Nodes::Addition` class, since `Arel::Nodes::Addition#relation` is
not defined

* Define matviews above tables in structure dump

* Fix bug with deserializing inserts in rails 8

* Fix specs
* Test ToSql visitor behavior

* Flag cluster specs for setup/teardown
- Add Float32/Float64 subtype handling in Map OID
  - Add Bool subtype handling in Map OID
  - Fix Date deserialize crash when value already deserialized
  - Prevent TimeZoneConverter wrapping Map OID (fixes Hash crash)
  - Add comprehensive tests for new functionality

  Fixes serialization issues where Float/Bool values were quoted as strings,
  causing SYNTAX_ERROR in ClickHouse. Also fixes TypeError on Date re-parsing
  and 'Hash values are not supported' crash with Map(String, DateTime).
Map(String, DateTime) columns crashed with "Hash values are not supported"
when time_zone_aware_attributes was enabled, because TimeZoneConverter
tried to wrap Hash values directly.

Move the create_time_zone_conversion_attribute? guard from the Railtie
into ClickhouseActiverecord.load so it is applied in all contexts
(not only in Rails apps).
…PNixx#239)

When deserialize was called with an already-parsed DateTime or Time value
(e.g. via model.instantiate), it would crash with:

  TypeError: no implicit conversion of DateTime into String

because DateTime.parse was called on an object instead of a string.

Root cause: already_deserialized? used bare DateTime/Date which resolved
to OID::DateTime/OID::Date (sibling classes in the same module) instead
of the standard library types, so the guard never triggered.

Fix by using ::DateTime, ::Date, ::Time with explicit root namespace.
)

This allows applications using clickhouse-activerecord to set `config.active_record.permanent_connection_checkout = :disallowed` without errors being raised.

https://guides.rubyonrails.org/configuring.html#config-active-record-permanent-connection-checkout
…ery limits (PNixx#266)

Every connection was made with verify_mode: OpenSSL::SSL::VERIFY_NONE, so an
SSL connection to ClickHouse never actually checked the server's certificate.
TLS verification is now on by default; set insecure: true to opt back out,
or sslca to point at a CA bundle (which was previously accepted as config but
never actually wired up to the connection - a bug fixed here as well).

Also adds:

- open_timeout: bounds how long establishing the underlying TCP connection
  is allowed to take. Unlike read_timeout/write_timeout, which only apply
  once a connection exists, there was previously no way to bound a dead
  network path that never completes the TCP handshake.
- Read queries (SELECT, via internal_exec_query) are retried once on a fresh
  connection when the failure happens before the server responds at all
  (Net::OpenTimeout, EOFError, ECONNRESET, IOError) - the kind of failure a
  stale pooled connection produces. Writes are never retried, and
  Net::ReadTimeout is deliberately not retried on any query, since the
  server already received the query and re-sending it would just pile
  another copy of the same load onto it.
- max_execution_time / cancel_http_readonly_queries_on_client_close config,
  merged into the session settings sent with every request, so a server-side
  query limit can be configured without a raw SQL SETTINGS clause.
- Timeouts/cancellations now raise Rails-standard error classes instead of a
  generic ActiveRecord::ActiveRecordError: ActiveRecord::StatementTimeout
  (server-side cancellation), ActiveRecord::AdapterTimeout (Net::ReadTimeout
  - client gave up, query may still be running), and
  ActiveRecord::ConnectionFailed (connection retry exhausted).

All four new config keys are also supported as clickhouse:// URL query
params, matching the existing read_timeout/write_timeout/etc. convention.
The base ActiveRecord Column does not define array/array?; each adapter
that supports arrays adds it on its own Column subclass (e.g. PostgreSQL).
The ClickHouse Column never did, so tooling that detects array columns
via `column.respond_to?(:array) && column.array` (such as annotaterb)
silently treated `Array(...)` columns as their scalar subtype.

Add `Column#array`, matching the top-level `Array(...)` SQL type, and
have the schema dumper reuse it instead of re-deriving array-ness from a
regex. Also fix the schema dumper's map-value regex so `Map(_, Array(_))`
columns are dumped as `map: :array` (the intended single-option form)
rather than relying on the previous match-anywhere behavior; both forms
reload to the same SQL type.
Float64 columns were dumped as `t.float "name"`, and `t.float` always
maps to Float32, so a database built from schema.rb ended up with a
narrower column than one built by running migrations. There was also no
DSL to declare Float64 at all: `t.float :x, limit: 8` silently ignored
the limit.

Mirror the integer handling so the width travels with `limit`:

- add `float64` to NATIVE_DATABASE_TYPES (`float` stays Float32)
- add `TableDefinition#float` that routes `limit: 8` to `:float64`
- resolve `:float` with `limit: 8` in `type_to_sql` so add_column /
  change_column do not produce "Float32(8)"
- teach `extract_limit` about Float32 (nil) and Float64 (8) so the
  dumper's generic `schema_limit` writes `t.float "x", limit: 8`

Closes PNixx#268
* Add joins_final for FINAL-aware association joins (sc-214234)

ClickHouse's `final` modifier only wrapped the query's primary FROM
source, so `.final.joins(:assoc)` emitted a plain `INNER JOIN <table>`
against the un-merged table. Code had to work around this with
hand-written `INNER JOIN (SELECT * FROM <table> FINAL) alias ON ...`.

Add `joins_final(*associations)`, which adds the join(s) like `joins`
and renders `INNER JOIN <table> FINAL ON ...` via the ClickHouse Arel
visitor. Implemented with a small `Arel::Nodes::FinalTable` marker that
wraps the join's table source; because every join visitor emits its
table via `visit o.left`, one visitor method covers all join types.
Joins are matched by table name and finalized before the FROM-level
FINAL node is applied. Supports chaining, dedup with existing joins,
and `unscope(:joins_final)`.

Add gem specs covering generated SQL and real execution against
ClickHouse (FINAL requires an engine that supports it, e.g.
ReplacingMergeTree).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SeQx7AkCPiyk5yqj7m3koX

* Add left_joins_final (LEFT OUTER JOIN ... FINAL)

joins_final only emits INNER JOIN ... FINAL. left_joins_final is the
LEFT OUTER counterpart: it adds the association via left_outer_joins and
renders FINAL on the joined table, for optional/catalog joins whose rows
must be preserved when there is no match while still merging the joined
table with FINAL. It shares joins_final_values, so the existing
final-join marking and unscope(:joins_final) handling apply unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uhz69nyLNfuciaTh8UhSa

* Test mixed joins_final + left_joins_final on the same association

Addresses review feedback: cover the edge case where the same
association is added via both joins_final and left_joins_final. The
shared joins_final_values store dedups via |= and ActiveRecord collapses
an association present in both joins and left_outer_joins into a single
INNER JOIN, so FINAL is applied exactly once. Behavior verified
consistent across ActiveRecord 7.2.3, 8.0.1, and 8.1.3.
`QueryCache` wraps `execute` where it is included, in `AbstractAdapter`, but
`Clickhouse::SchemaStatements` is included into the adapter afterwards, so its
own `execute` sits ahead of that wrapper in the ancestors. A statement run
through it therefore leaves the query cache holding the reads it had already
answered, and a repeated `SELECT` inside a request or a job reports the state
from before an `INSERT`.

Other adapters keep the wrapper by calling `super` and implementing the hook
below it, which this adapter cannot do while `execute` owns the HTTP request
and `raw_execute` takes a different signature than the one Rails calls. Reapply
`dirties_query_cache` instead, which restores the wrapper without moving any of
that.
* Treat DEFAULT NULL as a nil column default.

* Add missing assertions
* Compare ClickHouse server versions as versions, not floats

* Fix version comparison in specs
Brings in the 12 upstream changes since the July sync (PNixx#262 through PNixx#276):
TLS verification option and sslca wiring, read-query retry with Rails
error classes, max_execution_time settings, query cache dirtying on raw
writes, joins_final, Float64 round-tripping, DEFAULT NULL handling, and
version-aware server comparisons.

Conflicts: kept the fork's Column equality and write_query? alongside
upstream's Column#array and RETRYABLE_CONNECTION_ERRORS; took upstream's
connect, which passes open_timeout to Net::HTTP.start the same way the
fork did, and kept the fork's ConnectionNotEstablished rescue.
…etry count

Upstream's connection_hardening_spec covers passing open_timeout to
Net::HTTP.start, so only the fork's ConnectionNotEstablished mapping keeps
a spec. The fork's raw_execute retry runs inside upstream's read-query
retry, so a persistent EOFError on a SELECT makes four POSTs, not two.

@kiliman kiliman left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Self-review: flagging the two judgment calls before anyone has to ask.

@connection_parameters[:host],
@connection_parameters[:port],
use_ssl: @connection_parameters[:ssl],
verify_mode: @connection_parameters[:insecure] ? OpenSSL::SSL::VERIFY_NONE : OpenSSL::SSL::VERIFY_PEER,

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Took upstream's connect verbatim, but kept the fork's rescue Net::OpenTimeout → ConnectionNotEstablished a few lines down (unchanged, so it isn't in this diff).

Upstream's PNixx#266 passes open_timeout to Net::HTTP.start exactly the way our #7 did, so I dropped our copy of that plumbing to keep fork-only code small. The rescue is the one piece of #7 upstream doesn't have. Swarm's Apiary callers rescue ActiveRecord::ActiveRecordError to fall back to Postgres. The adapter connects eagerly in initialize, outside upstream's read-query retry, so without the rescue a cold connect timeout escapes as a bare Net::OpenTimeout and the fallback never runs.

The cost is 4 fork-only lines. The connect timeout spec guards them, and it fails with got #<Net::OpenTimeout> when the rescue is removed. I'd rather upstream this rescue than delete it here. Happy to open that PR against PNixx so the fork can drop it on the next sync.


expect { adapter.exec_query('SELECT 1') }.to raise_error(ActiveRecord::ConnectionFailed)
# The fork's raw_execute also retries EOFError once, inside each of these two attempts.
expect(http_connection).to have_received(:post).exactly(4).times

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Tradeoff: I changed upstream's expected count from 2 to 4 instead of reconciling the two retry layers.

The fork's raw_execute retries EOFError, ECONNRESET and Net::HTTPBadResponse once, for every statement. Upstream's internal_exec_query now retries SELECTs once more, with a reconnect, around that. So a SELECT on a connection that keeps failing makes 2 × 2 = 4 POSTs. It still ends in ActiveRecord::ConnectionFailed, which is the behavior this spec is really protecting. These failures are immediate and re-sending a SELECT is safe, so the extra attempts cost milliseconds.

Reconciling the layers, for example by teaching raw_execute to skip errors the outer retry handles, would mean more fork-only code in the hottest path just to save two instant retries. The cost of my choice is a one-line divergence in an upstream spec, which may conflict again on a future sync. If you'd prefer one layer, I'd keep raw_execute's, since it also covers writes and HTTPBadResponse, and narrow upstream's list to Net::OpenTimeout and IOError.

@kiliman
kiliman marked this pull request as ready for review October 20, 2026 15:31
@kiliman
kiliman merged commit d17da77 into master Oct 21, 2026
21 checks passed
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.