Skip to content

Expose the X-ClickHouse-Summary header as last_summary - #6

Open
NYDrewReynolds wants to merge 3 commits into
masterfrom
drewreynolds/expose-clickhouse-summary
Open

NYDrewReynolds wants to merge 3 commits into
masterfrom
drewreynolds/expose-clickhouse-summary

Conversation

@NYDrewReynolds

@NYDrewReynolds NYDrewReynolds commented Sep 15, 2026 •

Copy link
Copy Markdown

Why

ClickHouse reports what a statement read and wrote in the X-ClickHouse-Summary response header. raw_execute passes the response to processed_response, which returns only the parsed body, so the header was reachable only inside exec_delete — which builds its own request and keeps the raw response.

swarm needs written_rows for the nightly S3 events export. An INSERT ... SELECT that matched no rows and one that wrote millions are indistinguishable from the response body alone, so a run that exported nothing currently records the same "succeeded" as one that delivered 16M rows.

What

  • Record the summary per thread in raw_execute, keyed the same way as the existing response_format_stack, and read it back through last_summary.
  • exec_delete now shares the parser instead of repeating it.

One behaviour change worth calling out: the old inline parse was JSON.parse(res.header['x-clickhouse-summary']) rescuing only JSON::ParserError. A response with no summary header passes nil and raises TypeError, which that rescue does not cover. The shared parser coerces with .to_s first, so a missing header now yields nil rather than raising.

A statement that reports no summary clears the previous one, so a stale value can't be misread as belonging to the current statement.

Verified

INSERT ... SELECT against a local ClickHouse returns:

X-ClickHouse-Summary: {"read_rows":"12345",...,"written_rows":"12345",...}

and a plain SELECT returns "written_rows":"0". Values are strings, so callers need .to_i.

Specs added in spec/single/last_summary_spec.rb cover written rows on insert, zero on select, read rows, that the summary tracks the latest statement, and that it does not leak across threads.

ClickHouse reports what a statement read and wrote in the
X-ClickHouse-Summary response header. raw_execute hands the response to
processed_response, which returns only the parsed body, so the header was
reachable only inside exec_delete, which builds its own request.

Record it per thread, keyed the same way as response_format_stack, and read
it back through last_summary. exec_delete now shares the parser rather than
repeating it; that also stops a response with no summary header raising
TypeError from JSON.parse(nil), which the inline rescue did not cover.

Callers want written_rows: an INSERT ... SELECT that matched no rows and one
that wrote millions are otherwise indistinguishable from the response body.
raw_execute now reads the summary off the response, so the doubles that
stand in for one have to expose header as the real class does.
json 3.0 takes one argument to JSON.parse, ActiveSupport's JSON.decode still
passes two, and every spec touching a JSON column fails on deserialize. The
gem has no lockfile, so CI resolves the newest json on each run and master is
red for the same reason.
@NYDrewReynolds
NYDrewReynolds marked this pull request as ready for review September 15, 2026 23:57

@neecholai neecholai left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nice. approved with comments!

end

# Rows and bytes the most recent statement on this thread read and wrote, from X-ClickHouse-Summary.
def last_summary

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

this gets overwritten by any later statement on the same thread, including ones the adapter runs itself like do_system_execute, so the export has to read it on the line right after the insert

when the swarm side lands, should that be a small wrapper that runs the statement and reads the summary in one place? curious your thoughts

retry if (retries -= 1) > 0 # rubocop:disable Style/NumericPredicate
raise
end
record_summary(response)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

execute_to_file builds its own request so it never reaches this, and a raise after the retries skips it too. both leave the previous statement's summary in place, so should those clear it as well?

expect(connection.last_summary['written_rows'].to_i).to eq(0)
end

it 'keeps one thread summary out of another' do

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nothing covers the no-header case where record_summary rescues and this comes back nil, and exec_delete's new return path isn't asserted anywhere either (model_spec only checks that delete_all doesn't raise). worth a stubbed-response spec for both?

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.

2 participants