Batch and copyfrom: TimescaleDB and ClickHouse
Every file of the TimescaleDB and ClickHouse versions side by side, with the lines that differ marked. 4 parameter or column types differ in Go.
Configuration
sgl.yamldiffers
TimescaleDB
ClickHouse
1
version: 11
version: 12
2
3
databases:3
databases:4
- name: tides4
- name: tides5
dialect: postgres5
dialect: clickhouse6
migrations: db/migrations6
migrations: db/migrations7
queries: db/queries7
queries: db/queries8
go:8
go:9
package: store9
package: store10
out: internal/store10
out: internal/store11
driver: database/sql11
driver: clickhouse12
migrate:13
dsn: ${env:DATABASE_URL}Migrations
db/migrations/0001_readings.up.sqldiffers
TimescaleDB
ClickHouse
1
CREATE TABLE readings (1
CREATE TABLE readings (2
station TEXT NOT NULL,2
station String,3
minute BIGINT NOT NULL,3
minute UInt16,4
height_cm BIGINT NOT NULL,4
height_cm UInt16,5
note TEXT,5
note Nullable(String)6
PRIMARY KEY (station, minute)6
) ENGINE = MergeTree() ORDER BY (station, minute);7
);db/migrations/0001_readings.down.sqlidentical
TimescaleDB
ClickHouse
1
DROP TABLE readings;1
DROP TABLE readings;Queries
RecordReadingsdiffers
TimescaleDB · db/queries/readings.sql
ClickHouse · db/queries/readings.sql
1
-- sgl.query(RecordReadings, batch)1
-- sgl.query(RecordReadings, batch)2
INSERT INTO readings (station, minute, height_cm, note) VALUES ($1, $2, $3, $4);2
INSERT INTO readings (station, minute, height_cm, note)3
VALUES ({station:String}, {minute:UInt16}, {height_cm:UInt16}, {note:Nullable(String)});SummariseReadingsdiffers
TimescaleDB · db/queries/readings.sql
ClickHouse · db/queries/readings.sql
1
-- sgl.query(SummariseReadings, many)1
-- sgl.query(SummariseReadings, many)2
SELECT station, COUNT(*) AS readings, COUNT(note) AS noted2
SELECT station, count() AS readings, count(note) AS noted3
FROM readings3
FROM readings4
GROUP BY station4
GROUP BY station5
ORDER BY station;5
ORDER BY station;CountPartsonly ClickHouse
TimescaleDB · not in this version
ClickHouse · db/queries/readings.sql
1
-- sgl.query(CountParts, one)2
SELECT uniqExact(_part) AS parts FROM readings;What sgl generates
RecordReadingsdiffers
TimescaleDB
ClickHouse
1
func (queries *Queries) RecordReadings(ctx context.Context, params []RecordReadingsParams) error1
func (queries *Queries) RecordReadings(ctx context.Context, params []RecordReadingsParams) error2
2
3
type RecordReadingsParams struct {3
type RecordReadingsParams struct {4
Station string4
Station string5
Minute int645
Minute uint166
HeightCm int646
HeightCm uint167
Note *string7
Note *string8
}8
}SummariseReadingsdiffers
TimescaleDB
ClickHouse
1
func (queries *Queries) SummariseReadings(ctx context.Context) ([]SummariseReadingsRow, error)1
func (queries *Queries) SummariseReadings(ctx context.Context) ([]SummariseReadingsRow, error)2
2
3
type SummariseReadingsRow struct {3
type SummariseReadingsRow struct {4
Station string `json:"station"`4
Station string `json:"station"`5
Readings int64 `json:"readings"`5
Readings uint64 `json:"readings"`6
Noted int64 `json:"noted"`6
Noted uint64 `json:"noted"`7
}7
}CountPartsonly ClickHouse
TimescaleDB · not in this version
ClickHouse
1
func (queries *Queries) CountParts(ctx context.Context) (CountPartsRow, error)2
3
type CountPartsRow struct {4
Parts uint64 `json:"parts"`5
}The program
example.godiffers
TimescaleDB
ClickHouse
1
package main1
package main2
2
3
import (3
import (4
"context"4
"context"5
"database/sql"6
"fmt"5
"fmt"7
"io"6
"io"8
7
9
"seagull.sh/seagull/examples/postgres/022_bulk_writes/internal/store"8
"seagull.sh/seagull/examples/clickhouse/022_bulk_writes_native/internal/store"10
)9
)11
10
12
const (11
const (13
// minutesPerWeek is how many readings each tide gauge takes in a week.12
// minutesPerDay is how many readings each tide gauge takes in a day.14
minutesPerWeek = 7 * 24 * 6013
minutesPerDay = 24 * 6015
)14
)16
15
17
// statementCounter is a store.DBTX that counts the statements sent through ExecContext.16
// run records a day of readings from two tide gauges in one batch call, shows how many18
// QueryContext and QueryRowContext come from the embedded *sql.DB.17
// parts that wrote, and summarises what landed in the table.19
type statementCounter struct {20
*sql.DB21
22
// statements is how many times ExecContext has been called.23
statements int24
}25
26
// ExecContext counts the statement, then runs it on the database.27
//18
//28
// Takes query (string) which is the SQL to run.19
// A batch query takes a slice of Params, one per row. On the native driver the generated29
// Takes arguments (...any) which are its bind values.20
// method calls PrepareBatch, appends every row with batch.Append and sends them with21
// batch.Send: one INSERT, its rows sent column by column in blocks, with no bind22
// variables and so no chunking. The {name:Type} placeholders in the VALUES list only name23
// and type the columns; the driver takes the values from Append. copyfrom generates the24
// same code.30
//25
//31
// Returns sql.Result which is the database's result.26
// Each INSERT writes a new part, which ClickHouse merges in the background, so writing32
// Returns error when the statement fails.27
// rows one at a time leaves many small parts to merge. CountParts reads the _part virtual33
func (counter *statementCounter) ExecContext(ctx context.Context, query string, arguments ...any) (sql.Result, error) {28
// column to show that the whole day landed as one part.34
counter.statements++35
return counter.DB.ExecContext(ctx, query, arguments...)36
}37
38
// run records a week of readings from two tide gauges in one batch call, shows how many39
// INSERT statements that took, and summarises what landed in the table.40
//29
//41
// Takes database (*sql.DB) which is a migrated database.30
// Takes connection (store.DBTX) which is a native connection to a migrated database.42
// Takes output (io.Writer) which receives the statement counts and the summary.31
// Takes output (io.Writer) which receives the part counts and the summary.43
//32
//44
// Returns error when a query fails.33
// Returns error when a query fails.45
func run(ctx context.Context, database *sql.DB, output io.Writer) error {34
func run(ctx context.Context, connection store.DBTX, output io.Writer) error {46
counter := &statementCounter{DB: database}35
queries := store.New(connection)47
queries := store.New(counter)48
36
49
// An empty batch is a no-op: nothing is sent to the database.37
// An empty batch returns at once, without preparing an INSERT, so it writes no part.50
if err := queries.RecordReadings(ctx, nil); err != nil {38
if err := queries.RecordReadings(ctx, nil); err != nil {51
return fmt.Errorf("recording no readings: %w", err)39
return fmt.Errorf("recording no readings: %w", err)52
}40
}53
fmt.Fprintf(output, "0 readings sent in %d statements\n", counter.statements)41
if err := printParts(ctx, queries, 0, output); err != nil {42
return err43
}54
44
55
readings := tideReadings("harbour", "pier")45
readings := tideReadings("harbour", "pier")56
57
// A batch query takes a slice of Params, one per row. On database/sql the generated58
// method writes them as multi-row INSERTs, renumbering the placeholders $1, $2 and on,59
// chunked so that no statement binds more than 65535 variables, the most the PostgreSQL60
// protocol can carry: four columns a row allows 16383 rows a statement, so a week from61
// two gauges takes two. copyfrom generates the same code on database/sql; only the pgx62
// driver uses COPY.63
//64
// Each chunk is its own statement, so run the call inside RunInTx (concept 23) when65
// every row must land or none.66
if err := queries.RecordReadings(ctx, readings); err != nil {46
if err := queries.RecordReadings(ctx, readings); err != nil {67
return fmt.Errorf("recording readings: %w", err)47
return fmt.Errorf("recording readings: %w", err)68
}48
}69
fmt.Fprintf(output, "%d readings sent in %d statements\n", len(readings), counter.statements)49
if err := printParts(ctx, queries, len(readings), output); err != nil {50
return err51
}70
52
71
summary, err := queries.SummariseReadings(ctx)53
summary, err := queries.SummariseReadings(ctx)72
if err != nil {54
if err != nil {73
return fmt.Errorf("summarising readings: %w", err)55
return fmt.Errorf("summarising readings: %w", err)74
}56
}75
for _, row := range summary {57
for _, row := range summary {76
fmt.Fprintf(output, "%-8s %d readings, %d with a note\n", row.Station, row.Readings, row.Noted)58
fmt.Fprintf(output, "%-8s %d readings, %d with a note\n", row.Station, row.Readings, row.Noted)77
}59
}78
return nil60
return nil79
}61
}80
62
81
// tideReadings makes one reading a minute for a week at each station. The height rises63
// printParts prints how many parts hold the table's rows.82
// and falls in a simple saw-tooth, and the reading on the hour carries a note.83
//64
//65
// Takes queries (*store.Queries) which runs the query.66
// Takes sent (int) which is how many readings were sent.67
// Takes output (io.Writer) which receives the line.68
//69
// Returns error when the query fails.70
func printParts(ctx context.Context, queries *store.Queries, sent int, output io.Writer) error {71
parts, err := queries.CountParts(ctx)72
if err != nil {73
return fmt.Errorf("counting parts: %w", err)74
}75
fmt.Fprintf(output, "%d readings sent, parts holding rows: %d\n", sent, parts.Parts)76
return nil77
}78
79
// tideReadings makes one reading a minute for a day at each station. The height rises and80
// falls in a simple saw-tooth, and the reading on the hour carries a note.81
//84
// Takes stations (...string) which name the gauges.82
// Takes stations (...string) which name the gauges.85
//83
//86
// Returns []store.RecordReadingsParams which holds every reading, ready for the batch.84
// Returns []store.RecordReadingsParams which holds every reading, ready for the batch.87
func tideReadings(stations ...string) []store.RecordReadingsParams {85
func tideReadings(stations ...string) []store.RecordReadingsParams {88
readings := make([]store.RecordReadingsParams, 0, len(stations)*minutesPerWeek)86
readings := make([]store.RecordReadingsParams, 0, len(stations)*minutesPerDay)89
for _, station := range stations {87
for _, station := range stations {90
for minute := range int64(minutesPerWeek) {88
for minute := range uint16(minutesPerDay) {91
var note *string89
var note *string92
if minute%60 == 0 {90
if minute%60 == 0 {93
note = new(fmt.Sprintf("hour %d check", minute/60))91
note = new(fmt.Sprintf("hour %d check", minute/60))94
}92
}95
readings = append(readings, store.RecordReadingsParams{93
readings = append(readings, store.RecordReadingsParams{96
Station: station,94
Station: station,97
Minute: minute,95
Minute: minute,98
HeightCm: 200 + minute%360,96
HeightCm: 200 + minute%360,99
Note: note,97
Note: note,100
})98
})101
}99
}102
}100
}103
return readings101
return readings104
}102
}Go type differences
The same SQL types map to different Go types, because each database reports them differently.
| Query | Field | TimescaleDB | ClickHouse |
|---|---|---|---|
| RecordReadings | parameter minute | int64 | uint16 |
| RecordReadings | parameter height_cm | int64 | uint16 |
| SummariseReadings | column readings | int64 | uint64 |
| SummariseReadings | column noted | int64 | uint64 |