Read replicas: TimescaleDB and SQLite
Every file of the TimescaleDB and SQLite versions side by side, with the lines that differ marked.
Configuration
sgl.yamldiffers
TimescaleDB
SQLite
1
version: 11
version: 12
2
3
databases:3
databases:4
- name: sightings4
- name: sightings5
dialect: postgres5
dialect: sqlite6
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/sql12
migrate:13
dsn: ${env:DATABASE_URL}Migrations
db/migrations/0001_sightings.up.sqldiffers
TimescaleDB
SQLite
1
CREATE TABLE sightings (1
CREATE TABLE sightings (2
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,2
id INTEGER PRIMARY KEY,3
species TEXT NOT NULL,3
species TEXT NOT NULL,4
place TEXT NOT NULL4
place TEXT NOT NULL5
);5
);6
7
CREATE FUNCTION sighting_label(species TEXT, place TEXT) RETURNS TEXT8
LANGUAGE sql IMMUTABLE STRICT9
AS $$ SELECT species || ' at ' || place $$;10
11
CREATE FUNCTION log_sighting(new_species TEXT, new_place TEXT) RETURNS BIGINT12
LANGUAGE plpgsql VOLATILE13
AS $$14
DECLARE15
new_id BIGINT;16
BEGIN17
INSERT INTO sightings (species, place) VALUES (new_species, new_place) RETURNING id INTO new_id;18
RETURN new_id;19
END;20
$$;db/migrations/0001_sightings.down.sqldiffers
TimescaleDB
SQLite
1
DROP FUNCTION log_sighting(TEXT, TEXT);2
DROP FUNCTION sighting_label(TEXT, TEXT);3
DROP TABLE sightings;1
DROP TABLE sightings;Queries
RecordSightingdiffers
TimescaleDB · db/queries/sightings.sql
SQLite · db/queries/sightings.sql
1
-- sgl.query(RecordSighting, one)1
-- sgl.query(RecordSighting, one)2
INSERT INTO sightings (species, place) VALUES ($1, $2) RETURNING id;2
INSERT INTO sightings (species, place) VALUES (?, ?) RETURNING id;LogSightingonly TimescaleDB
TimescaleDB · db/queries/sightings.sql
SQLite · not in this version
1
-- sgl.query(LogSighting, one)2
SELECT log_sighting(:species, :place) AS id;ListSightingsdiffers
TimescaleDB · db/queries/sightings.sql
SQLite · db/queries/sightings.sql
1
-- sgl.query(ListSightings, many)1
-- sgl.query(ListSightings, many)2
SELECT id, sighting_label(species, place) AS label FROM sightings ORDER BY id;2
SELECT id, species, place FROM sightings ORDER BY id;CountSightingsidentical
TimescaleDB · db/queries/sightings.sql
SQLite · db/queries/sightings.sql
1
-- sgl.query(CountSightings, one, readonly: false)1
-- sgl.query(CountSightings, one, readonly: false)2
SELECT COUNT(*) AS total FROM sightings;2
SELECT COUNT(*) AS total FROM sightings;ClaimSightingonly TimescaleDB
TimescaleDB · db/queries/sightings.sql
SQLite · not in this version
1
-- sgl.query(ClaimSighting, one)2
SELECT id, species FROM sightings WHERE id = $1 FOR UPDATE;ForgetPlaceonly TimescaleDB
TimescaleDB · db/queries/sightings.sql
SQLite · not in this version
1
-- sgl.query(ForgetPlace, many)2
WITH forgotten AS (DELETE FROM sightings WHERE place = $1 RETURNING id) SELECT id FROM forgotten ORDER BY id;What sgl generates
RecordSightingidentical
TimescaleDB
SQLite
1
func (queries *Queries) RecordSighting(ctx context.Context, params RecordSightingParams) (RecordSightingRow, error)1
func (queries *Queries) RecordSighting(ctx context.Context, params RecordSightingParams) (RecordSightingRow, error)2
2
3
type RecordSightingParams struct {3
type RecordSightingParams struct {4
Species string4
Species string5
Place string5
Place string6
}6
}7
7
8
type RecordSightingRow struct {8
type RecordSightingRow struct {9
ID int64 `json:"id"`9
ID int64 `json:"id"`10
}10
}LogSightingonly TimescaleDB
TimescaleDB
SQLite · not in this version
1
func (queries *Queries) LogSighting(ctx context.Context, params LogSightingParams) (LogSightingRow, error)2
3
type LogSightingParams struct {4
Species string5
Place string6
}7
8
type LogSightingRow struct {9
ID *int64 `json:"id"`10
}ListSightingsdiffers
TimescaleDB
SQLite
1
func (queries *Queries) ListSightings(ctx context.Context) ([]ListSightingsRow, error)1
func (queries *Queries) ListSightings(ctx context.Context) ([]ListSightingsRow, error)2
2
3
type ListSightingsRow struct {3
type ListSightingsRow struct {4
ID int64 `json:"id"`4
ID int64 `json:"id"`5
Label string `json:"label"`5
Species string `json:"species"`6
Place string `json:"place"`6
}7
}CountSightingsidentical
TimescaleDB
SQLite
1
func (queries *Queries) CountSightings(ctx context.Context) (CountSightingsRow, error)1
func (queries *Queries) CountSightings(ctx context.Context) (CountSightingsRow, error)2
2
3
type CountSightingsRow struct {3
type CountSightingsRow struct {4
Total int64 `json:"total"`4
Total int64 `json:"total"`5
}5
}ClaimSightingonly TimescaleDB
TimescaleDB
SQLite · not in this version
1
func (queries *Queries) ClaimSighting(ctx context.Context, id int64) (ClaimSightingRow, error)2
3
type ClaimSightingRow struct {4
ID int64 `json:"id"`5
Species string `json:"species"`6
}ForgetPlaceonly TimescaleDB
TimescaleDB
SQLite · not in this version
1
func (queries *Queries) ForgetPlace(ctx context.Context, place string) ([]ForgetPlaceRow, error)2
3
type ForgetPlaceRow struct {4
ID int64 `json:"id"`5
}The program
example.godiffers
TimescaleDB
SQLite
1
package main1
package main2
2
3
import (3
import (4
"context"4
"context"5
"database/sql"5
"database/sql"6
"errors"6
"errors"7
"fmt"7
"fmt"8
"io"8
"io"9
9
10
"seagull.sh/seagull/examples/postgres/024_read_replicas/internal/store"10
"seagull.sh/seagull/examples/sqlite/024_read_replicas/internal/store"11
)11
)12
12
13
// connection is a store.DBTX that prints its name and each statement before running it on13
// connection is a store.DBTX that prints its name and each statement before running it on14
// the embedded database, so the output shows which connection served every query.14
// the embedded database, so the output shows which connection served every query.15
type connection struct {15
type connection struct {16
*sql.DB16
*sql.DB17
17
18
// output receives a line per statement.18
// output receives a line per statement.19
output io.Writer19
output io.Writer20
20
21
// name labels the connection in the output.21
// name labels the connection in the output.22
name string22
name string23
}23
}24
24
25
// ExecContext prints the statement, then runs it on the database.25
// ExecContext prints the statement, then runs it on the database.26
//26
//27
// Takes query (string) which is the SQL to run.27
// Takes query (string) which is the SQL to run.28
// Takes arguments (...any) which are its bind values.28
// Takes arguments (...any) which are its bind values.29
//29
//30
// Returns sql.Result which is the database's result.30
// Returns sql.Result which is the database's result.31
// Returns error when the statement fails.31
// Returns error when the statement fails.32
func (c connection) ExecContext(ctx context.Context, query string, arguments ...any) (sql.Result, error) {32
func (c connection) ExecContext(ctx context.Context, query string, arguments ...any) (sql.Result, error) {33
fmt.Fprintf(c.output, "%s %s\n", c.name, query)33
fmt.Fprintf(c.output, "%s %s\n", c.name, query)34
return c.DB.ExecContext(ctx, query, arguments...)34
return c.DB.ExecContext(ctx, query, arguments...)35
}35
}36
36
37
// QueryContext prints the query, then runs it on the database.37
// QueryContext prints the query, then runs it on the database.38
//38
//39
// Takes query (string) which is the SQL to run.39
// Takes query (string) which is the SQL to run.40
// Takes arguments (...any) which are its bind values.40
// Takes arguments (...any) which are its bind values.41
//41
//42
// Returns *sql.Rows which holds the result rows.42
// Returns *sql.Rows which holds the result rows.43
// Returns error when the query fails.43
// Returns error when the query fails.44
func (c connection) QueryContext(ctx context.Context, query string, arguments ...any) (*sql.Rows, error) {44
func (c connection) QueryContext(ctx context.Context, query string, arguments ...any) (*sql.Rows, error) {45
fmt.Fprintf(c.output, "%s %s\n", c.name, query)45
fmt.Fprintf(c.output, "%s %s\n", c.name, query)46
return c.DB.QueryContext(ctx, query, arguments...)46
return c.DB.QueryContext(ctx, query, arguments...)47
}47
}48
48
49
// QueryRowContext prints the query, then runs it on the database.49
// QueryRowContext prints the query, then runs it on the database.50
//50
//51
// Takes query (string) which is the SQL to run.51
// Takes query (string) which is the SQL to run.52
// Takes arguments (...any) which are its bind values.52
// Takes arguments (...any) which are its bind values.53
//53
//54
// Returns *sql.Row which holds the result row or the error.54
// Returns *sql.Row which holds the result row or the error.55
func (c connection) QueryRowContext(ctx context.Context, query string, arguments ...any) *sql.Row {55
func (c connection) QueryRowContext(ctx context.Context, query string, arguments ...any) *sql.Row {56
fmt.Fprintf(c.output, "%s %s\n", c.name, query)56
fmt.Fprintf(c.output, "%s %s\n", c.name, query)57
return c.DB.QueryRowContext(ctx, query, arguments...)57
return c.DB.QueryRowContext(ctx, query, arguments...)58
}58
}59
59
60
// sqlStateError is a server error that carries a SQLSTATE code, as pgx's *pgconn.PgError60
// run records two sightings and reads them back through Queries built with61
// does.61
// NewWithReplica, printing which connection each statement went to. It then shows that62
type sqlStateError interface {62
// the reader refuses writes, which is why the routing matters.63
error64
65
// SQLState returns the five-character SQLSTATE code.66
SQLState() string67
}68
69
// run records sightings and reads them back through Queries built with NewWithReplica,70
// printing which connection each statement went to. It then sends the queries that only71
// look like reads straight to the reader, to show why Seagull routes them to the writer.72
//63
//73
// Takes writer (*sql.DB) which is the migrated primary database.64
// Takes writer (*sql.DB) which is the migrated primary database.74
// Takes reader (*sql.DB) which is a read-only connection to the same data.65
// Takes reader (*sql.DB) which is a read-only connection to the same data.75
// Takes output (io.Writer) which receives the routing and the results.66
// Takes output (io.Writer) which receives the routing and the results.76
//67
//77
// Returns error when a query fails other than as the example expects.68
// Returns error when a query fails other than as the example expects.78
func run(ctx context.Context, writer, reader *sql.DB, output io.Writer) error {69
func run(ctx context.Context, writer, reader *sql.DB, output io.Writer) error {79
// Seagull decides whether each query is read-only when it analyses it, and each70
// Seagull marks each query read-only or not when it analyses it, and each generated80
// generated method uses the writer or the reader to match. On PostgreSQL a SELECT goes71
// method uses the writer or the reader to match: INSERT, UPDATE and DELETE go to the81
// to the writer if it locks rows (FOR UPDATE), modifies data in a CTE, or calls a72
// writer, SELECT to the reader.82
// function whose data access is writing or unknown.83
queries := store.NewWithReplica(73
queries := store.NewWithReplica(84
connection{DB: writer, output: output, name: "writer"},74
connection{DB: writer, output: output, name: "writer"},85
connection{DB: reader, output: output, name: "reader"},75
connection{DB: reader, output: output, name: "reader"},86
)76
)87
77
88
herring, err := queries.RecordSighting(ctx, store.RecordSightingParams{Species: "herring gull", Place: "the pier"})78
sightings := []store.RecordSightingParams{89
if err != nil {79
{Species: "herring gull", Place: "the pier"},90
return fmt.Errorf("recording a herring gull: %w", err)80
{Species: "kittiwake", Place: "the harbour wall"},91
}92
if _, err := queries.RecordSighting(ctx, store.RecordSightingParams{Species: "kittiwake", Place: "the harbour wall"}); err != nil {93
return fmt.Errorf("recording a kittiwake: %w", err)94
}95
96
// LogSighting is a SELECT, but log_sighting inserts a row, so it goes to the writer. A97
// plpgsql function may return NULL, so ID is a pointer.98
logged, err := queries.LogSighting(ctx, store.LogSightingParams{Species: "puffin", Place: "the cliffs"})99
if err != nil {100
return fmt.Errorf("logging a puffin: %w", err)101
}81
}102
if logged.ID == nil {82
for _, sighting := range sightings {103
return errors.New("log_sighting returned NULL")83
if _, err := queries.RecordSighting(ctx, sighting); err != nil {84
return fmt.Errorf("recording a %s: %w", sighting.Species, err)85
}104
}86
}105
fmt.Fprintf(output, " logged sighting %d\n", *logged.ID)106
87
107
// ListSightings calls sighting_label, whose body only concatenates its arguments, so it108
// stays read-only and goes to the reader. With these non-NULL species and place columns,109
// the concatenation produces a non-NULL Label string.110
listed, err := queries.ListSightings(ctx)88
listed, err := queries.ListSightings(ctx)111
if err != nil {89
if err != nil {112
return fmt.Errorf("listing sightings: %w", err)90
return fmt.Errorf("listing sightings: %w", err)113
}91
}114
for _, sighting := range listed {92
for _, sighting := range listed {115
fmt.Fprintf(output, " %d %s\n", sighting.ID, sighting.Label)93
fmt.Fprintf(output, " %d %s at %s\n", sighting.ID, sighting.Species, sighting.Place)116
}94
}117
95
118
// CountSightings is a SELECT, but its header says readonly: false, so it reads from the96
// CountSightings is a SELECT, but its header says readonly: false, so it reads from the119
// writer. A real replica lags behind the primary; a read that must see a write made a97
// writer. A real replica lags behind the primary; a read that must see a write made a120
// moment ago belongs on the writer.98
// moment ago belongs on the writer.121
count, err := queries.CountSightings(ctx)99
count, err := queries.CountSightings(ctx)122
if err != nil {100
if err != nil {123
return fmt.Errorf("counting sightings: %w", err)101
return fmt.Errorf("counting sightings: %w", err)124
}102
}125
fmt.Fprintf(output, " %d sightings on the primary\n", count.Total)103
fmt.Fprintf(output, " %d sightings on the primary\n", count.Total)126
104
127
// FOR UPDATE locks the row, which only the primary can do.105
// The reader was opened with mode=ro, so a write sent to it fails.128
claimed, err := queries.ClaimSighting(ctx, herring.ID)106
_, err = store.New(reader).RecordSighting(ctx, store.RecordSightingParams{Species: "puffin", Place: "the cliffs"})129
if err != nil {107
if err == nil {130
return fmt.Errorf("claiming sighting %d: %w", herring.ID, err)108
return errors.New("the read-only connection accepted a write")131
}132
fmt.Fprintf(output, " locked sighting %d, a %s\n", claimed.ID, claimed.Species)133
134
// The DELETE inside the CTE makes the whole statement a write.135
forgotten, err := queries.ForgetPlace(ctx, "the cliffs")136
if err != nil {137
return fmt.Errorf("forgetting the cliffs: %w", err)138
}139
fmt.Fprintf(output, " forgot %d sighting at the cliffs\n", len(forgotten))140
141
return tryOnReader(ctx, store.New(reader), herring.ID, output)142
}143
144
// tryOnReader sends three queries that write or lock straight to the reader, which145
// refuses each one, as a replica would, with SQLSTATE 25006, read_only_sql_transaction.146
// pgx reports a server error as a *pgconn.PgError, and its SQLState method gives the147
// code, which stays the same where the wording of the message does not.148
//149
// Takes onReader (*store.Queries) which runs everything on the reader.150
// Takes sighting (int64) which is a sighting to try to lock.151
// Takes output (io.Writer) which receives each refusal.152
//153
// Returns error when the reader does not refuse one of them.154
func tryOnReader(ctx context.Context, onReader *store.Queries, sighting int64, output io.Writer) error {155
attempts := []struct {156
name string157
call func() error158
}{159
{name: "RecordSighting", call: func() error {160
_, err := onReader.RecordSighting(ctx, store.RecordSightingParams{Species: "gannet", Place: "the bay"})161
return err162
}},163
{name: "LogSighting", call: func() error {164
_, err := onReader.LogSighting(ctx, store.LogSightingParams{Species: "gannet", Place: "the bay"})165
return err166
}},167
{name: "ClaimSighting", call: func() error {168
_, err := onReader.ClaimSighting(ctx, sighting)169
return err170
}},171
}172
fmt.Fprintln(output, "on the reader:")173
for _, attempt := range attempts {174
err := attempt.call()175
if err == nil {176
return fmt.Errorf("%s on the reader: want a refusal from the server, got no error", attempt.name)177
}178
refusal, hasState := errors.AsType[sqlStateError](err)179
if !hasState {180
return fmt.Errorf("%s on the reader: want a refusal from the server, got %w", attempt.name, err)181
}182
fmt.Fprintf(output, " %s refused: SQLSTATE %s\n", attempt.name, refusal.SQLState())183
}109
}110
fmt.Fprintf(output, "a write on the reader fails: %v\n", err)184
return nil111
return nil185
}112
}