Read replicas: SQLite and PostgreSQL
Every file of the SQLite and PostgreSQL versions side by side, with the lines that differ marked.
Configuration
sgl.yamldiffers
SQLite
PostgreSQL
1
version: 11
version: 12
2
3
databases:3
databases:4
- name: sightings4
- name: sightings5
dialect: sqlite5
dialect: postgres6
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
SQLite
PostgreSQL
1
CREATE TABLE sightings (1
CREATE TABLE sightings (2
id INTEGER PRIMARY KEY,2
id BIGINT GENERATED BY DEFAULT AS IDENTITY 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
SQLite
PostgreSQL
1
DROP FUNCTION log_sighting(TEXT, TEXT);2
DROP FUNCTION sighting_label(TEXT, TEXT);1
DROP TABLE sightings;3
DROP TABLE sightings;Queries
RecordSightingdiffers
SQLite · db/queries/sightings.sql
PostgreSQL · db/queries/sightings.sql
1
-- sgl.query(RecordSighting, one)1
-- sgl.query(RecordSighting, one)2
INSERT INTO sightings (species, place) VALUES (?, ?) RETURNING id;2
INSERT INTO sightings (species, place) VALUES ($1, $2) RETURNING id;ListSightingsdiffers
SQLite · db/queries/sightings.sql
PostgreSQL · db/queries/sightings.sql
1
-- sgl.query(ListSightings, many)1
-- sgl.query(ListSightings, many)2
SELECT id, species, place FROM sightings ORDER BY id;2
SELECT id, sighting_label(species, place) AS label FROM sightings ORDER BY id;CountSightingsidentical
SQLite · db/queries/sightings.sql
PostgreSQL · 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;LogSightingonly PostgreSQL
SQLite · not in this version
PostgreSQL · db/queries/sightings.sql
1
-- sgl.query(LogSighting, one)2
SELECT log_sighting(:species, :place) AS id;ClaimSightingonly PostgreSQL
SQLite · not in this version
PostgreSQL · db/queries/sightings.sql
1
-- sgl.query(ClaimSighting, one)2
SELECT id, species FROM sightings WHERE id = $1 FOR UPDATE;ForgetPlaceonly PostgreSQL
SQLite · not in this version
PostgreSQL · db/queries/sightings.sql
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
SQLite
PostgreSQL
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
}ListSightingsdiffers
SQLite
PostgreSQL
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
Species string `json:"species"`5
Label string `json:"label"`6
Place string `json:"place"`7
}6
}CountSightingsidentical
SQLite
PostgreSQL
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
}LogSightingonly PostgreSQL
SQLite · not in this version
PostgreSQL
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
}ClaimSightingonly PostgreSQL
SQLite · not in this version
PostgreSQL
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 PostgreSQL
SQLite · not in this version
PostgreSQL
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
SQLite
PostgreSQL
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/sqlite/024_read_replicas/internal/store"10
"seagull.sh/seagull/examples/postgres/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
// run records two sightings and reads them back through Queries built with60
// sqlStateError is a server error that carries a SQLSTATE code, as pgx's *pgconn.PgError61
// NewWithReplica, printing which connection each statement went to. It then shows that61
// does.62
// the reader refuses writes, which is why the routing matters.62
type sqlStateError interface {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.63
//72
//64
// Takes writer (*sql.DB) which is the migrated primary database.73
// Takes writer (*sql.DB) which is the migrated primary database.65
// Takes reader (*sql.DB) which is a read-only connection to the same data.74
// Takes reader (*sql.DB) which is a read-only connection to the same data.66
// Takes output (io.Writer) which receives the routing and the results.75
// Takes output (io.Writer) which receives the routing and the results.67
//76
//68
// Returns error when a query fails other than as the example expects.77
// Returns error when a query fails other than as the example expects.69
func run(ctx context.Context, writer, reader *sql.DB, output io.Writer) error {78
func run(ctx context.Context, writer, reader *sql.DB, output io.Writer) error {70
// Seagull marks each query read-only or not when it analyses it, and each generated79
// Seagull decides whether each query is read-only when it analyses it, and each71
// method uses the writer or the reader to match: INSERT, UPDATE and DELETE go to the80
// generated method uses the writer or the reader to match. On PostgreSQL a SELECT goes72
// writer, SELECT to the reader.81
// to the writer if it locks rows (FOR UPDATE), modifies data in a CTE, or calls a82
// function whose data access is writing or unknown.73
queries := store.NewWithReplica(83
queries := store.NewWithReplica(74
connection{DB: writer, output: output, name: "writer"},84
connection{DB: writer, output: output, name: "writer"},75
connection{DB: reader, output: output, name: "reader"},85
connection{DB: reader, output: output, name: "reader"},76
)86
)77
87
78
sightings := []store.RecordSightingParams{88
herring, err := queries.RecordSighting(ctx, store.RecordSightingParams{Species: "herring gull", Place: "the pier"})79
{Species: "herring gull", Place: "the pier"},89
if err != nil {80
{Species: "kittiwake", Place: "the harbour wall"},90
return fmt.Errorf("recording a herring gull: %w", err)81
}91
}82
for _, sighting := range sightings {92
if _, err := queries.RecordSighting(ctx, store.RecordSightingParams{Species: "kittiwake", Place: "the harbour wall"}); err != nil {83
if _, err := queries.RecordSighting(ctx, sighting); err != nil {93
return fmt.Errorf("recording a kittiwake: %w", err)84
return fmt.Errorf("recording a %s: %w", sighting.Species, err)85
}86
}94
}87
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
}102
if logged.ID == nil {103
return errors.New("log_sighting returned NULL")104
}105
fmt.Fprintf(output, " logged sighting %d\n", *logged.ID)106
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.88
listed, err := queries.ListSightings(ctx)110
listed, err := queries.ListSightings(ctx)89
if err != nil {111
if err != nil {90
return fmt.Errorf("listing sightings: %w", err)112
return fmt.Errorf("listing sightings: %w", err)91
}113
}92
for _, sighting := range listed {114
for _, sighting := range listed {93
fmt.Fprintf(output, " %d %s at %s\n", sighting.ID, sighting.Species, sighting.Place)115
fmt.Fprintf(output, " %d %s\n", sighting.ID, sighting.Label)94
}116
}95
117
96
// CountSightings is a SELECT, but its header says readonly: false, so it reads from the118
// CountSightings is a SELECT, but its header says readonly: false, so it reads from the97
// writer. A real replica lags behind the primary; a read that must see a write made a119
// writer. A real replica lags behind the primary; a read that must see a write made a98
// moment ago belongs on the writer.120
// moment ago belongs on the writer.99
count, err := queries.CountSightings(ctx)121
count, err := queries.CountSightings(ctx)100
if err != nil {122
if err != nil {101
return fmt.Errorf("counting sightings: %w", err)123
return fmt.Errorf("counting sightings: %w", err)102
}124
}103
fmt.Fprintf(output, " %d sightings on the primary\n", count.Total)125
fmt.Fprintf(output, " %d sightings on the primary\n", count.Total)104
126
105
// The reader was opened with mode=ro, so a write sent to it fails.127
// FOR UPDATE locks the row, which only the primary can do.106
_, err = store.New(reader).RecordSighting(ctx, store.RecordSightingParams{Species: "puffin", Place: "the cliffs"})128
claimed, err := queries.ClaimSighting(ctx, herring.ID)107
if err == nil {129
if err != nil {108
return errors.New("the read-only connection accepted a write")130
return fmt.Errorf("claiming sighting %d: %w", herring.ID, err)109
}131
}110
fmt.Fprintf(output, "a write on the reader fails: %v\n", err)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
}111
return nil184
return nil112
}185
}