Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,14 @@ test-postgres: test-cleanup-postgres
docker rm -vf authorizer_postgres; \
exit $$status

test-serenedb: test-cleanup-serenedb
docker run -d --name authorizer_serenedb -p 7890:7890 -e POSTGRES_PASSWORD=postgres serenedb/serenedb:26.08.2
sh scripts/wait-for-test-dbs.sh serenedb && \
{ go clean --testcache; TEST_DBS="serenedb" $(GO_TEST_ALL); }; \
status=$$?; \
docker rm -vf authorizer_serenedb; \
exit $$status

test-sqlite:
go clean --testcache && TEST_DBS="sqlite" $(GO_TEST_ALL)

Expand Down Expand Up @@ -203,7 +211,7 @@ test-all-db: test-cleanup test-docker-up
@# failed to start them and produced a second, misleading failure. Same
@# capture-status-then-clean shape the e2e-playground target already uses.
go clean --testcache; \
TEST_DBS="couchbase,postgres,sqlite,mongodb,arangodb,scylladb,dynamodb" $(GO_TEST_ALL); \
TEST_DBS="couchbase,postgres,serenedb,sqlite,mongodb,arangodb,scylladb,dynamodb" $(GO_TEST_ALL); \
status=$$?; \
$(MAKE) test-cleanup; \
exit $$status
Expand All @@ -212,6 +220,7 @@ test-all-db: test-cleanup test-docker-up
test-docker-up:
docker run -d --name authorizer_redis -p 6380:6379 redis
docker run -d --name authorizer_postgres -p 5434:5432 -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=postgres postgres
docker run -d --name authorizer_serenedb -p 7890:7890 -e POSTGRES_PASSWORD=postgres serenedb/serenedb:26.08.2
docker run -d --name authorizer_mongodb_db -p 27017:27017 mongo:4.4.15
docker run -d --name authorizer_scylla_db -p 9042:9042 scylladb/scylla
docker run -d --name authorizer_arangodb -p 8529:8529 -e ARANGO_NO_AUTH=1 arangodb/arangodb:3.10.3
Expand All @@ -223,6 +232,7 @@ test-docker-up:
# Remove all test database containers
test-cleanup:
-docker rm -vf authorizer_postgres
-docker rm -vf authorizer_serenedb
-docker rm -vf authorizer_scylla_db
-docker rm -vf authorizer_mongodb_db
-docker rm -vf authorizer_arangodb
Expand All @@ -232,6 +242,8 @@ test-cleanup:

test-cleanup-postgres:
-docker rm -vf authorizer_postgres
test-cleanup-serenedb:
-docker rm -vf authorizer_serenedb
test-cleanup-mongodb:
-docker rm -vf authorizer_mongodb_db
test-cleanup-scylladb:
Expand Down
4 changes: 2 additions & 2 deletions internal/config/fga.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ func (c *Config) FGAStoreConfig() (store string, url string, enabled bool) {

// Derive from the main database when OpenFGA supports it. Postgres- and
// mysql-compatible variants beyond these (cockroachdb, yugabyte, libsql,
// planetscale) are intentionally NOT auto-mapped — they require an explicit
// --fga-store to avoid silent incompatibilities.
// planetscale, serenedb) are intentionally NOT auto-mapped — they require an
// explicit --fga-store to avoid silent incompatibilities.
switch c.DatabaseType {
case constants.DbTypePostgres:
return "postgres", c.DatabaseURL, true
Expand Down
8 changes: 8 additions & 0 deletions internal/config/fga_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,14 @@ func TestFGAStoreConfig(t *testing.T) {
cfg: Config{DatabaseType: "cockroachdb", DatabaseURL: "postgres://h/db"},
wantEnabled: false,
},
{
// SereneDB speaks the Postgres wire protocol but is a search-OLAP
// engine, not an OpenFGA datastore. Auto-mapping it would point the
// authorization store at a backend OpenFGA never migrated.
name: "serenedb is NOT auto-mapped (needs explicit store)",
cfg: Config{DatabaseType: "serenedb", DatabaseURL: "postgres://h/db"},
wantEnabled: false,
},
}

for _, tc := range cases {
Expand Down
2 changes: 2 additions & 0 deletions internal/constants/db_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ const (
DbTypePlanetScaleDB = "planetscale"
// DbTypeCockroachDB is the cockroach database type
DbTypeCockroachDB = "cockroachdb"
// DbTypeSereneDB is the serenedb database type (PostgreSQL wire protocol)
DbTypeSereneDB = "serenedb"

// DbTypeArangoDB is the arangodb database type
DbTypeArangoDB = "arangodb"
Expand Down
103 changes: 103 additions & 0 deletions internal/storage/db/serenedb/connpool.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package serenedb

import (
"context"
stdsql "database/sql"
"errors"
"time"

"github.com/jackc/pgx/v5/pgconn"
"gorm.io/gorm"
)

// maxWriteConflictRetries bounds the retry loop below. SereneDB resolves a
// conflict by aborting one writer immediately rather than making it wait, so
// the retries are cheap and a contended row converges in a few rounds.
const maxWriteConflictRetries = 8

// sqlState reports whether err carries the given SQLSTATE.
func sqlState(err error, code string) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == code
}

// uniqueViolation reports whether err is a duplicate-key rejection from a
// unique index. SereneDB returns the standard SQLSTATE 23505 for these.
func uniqueViolation(err error) bool { return sqlState(err, "23505") }

// retryPool retries statements SereneDB aborts with a write conflict.
//
// Its MVCC is optimistic: two transactions touching the same row abort one of
// them (SQLSTATE 40001, "Conflict on tuple deletion") rather than blocking it,
// so retrying — not failing — is the resolution. Without this, concurrent
// writes to one row fail most of the time and leak the driver's message to API
// callers: 20 simultaneous profile updates for one user produced 17 errors.
//
// The retry sits at the pool so every write path gets it, not just the ones
// this package overrides. It applies only to autocommit statements: BeginTx
// hands back the raw *sql.Tx, because replaying one statement inside an already
// aborted transaction is wrong — the whole transaction has to be replayed, and
// GORM's Transaction() offers no hook for that. This is also why the provider
// sets SkipDefaultTransaction: GORM otherwise wraps every single Create/Update
// in a transaction, which would route all writes through BeginTx and past this
// retry. The four explicit Transaction() call sites in the SQL provider
// (user/client/organization/webhook cascade deletes) still surface 40001 under
// contention.
type retryPool struct {
db *stdsql.DB
}

// newRetryPool adapts a database/sql handle for gorm.
func newRetryPool(db *stdsql.DB) gorm.ConnPool { return &retryPool{db: db} }

func (p *retryPool) retry(fn func() error) error {
var err error
for attempt := 0; attempt < maxWriteConflictRetries; attempt++ {
if err = fn(); err == nil || !sqlState(err, "40001") {
return err
}
time.Sleep(time.Duration(attempt+1) * 5 * time.Millisecond)
}
return err
}

func (p *retryPool) PrepareContext(ctx context.Context, query string) (*stdsql.Stmt, error) {
return p.db.PrepareContext(ctx, query)
}

func (p *retryPool) ExecContext(ctx context.Context, query string, args ...any) (stdsql.Result, error) {
var res stdsql.Result
err := p.retry(func() error {
var err error
res, err = p.db.ExecContext(ctx, query, args...)
return err
})
return res, err
}

func (p *retryPool) QueryContext(ctx context.Context, query string, args ...any) (*stdsql.Rows, error) {
var rows *stdsql.Rows
err := p.retry(func() error {
var err error
rows, err = p.db.QueryContext(ctx, query, args...)
return err
})
return rows, err
}

// QueryRowContext cannot retry: *sql.Row defers its error to Scan, so the
// conflict is not visible here. Single-row reads do not raise 40001 — it is a
// write conflict — so this is a read-path no-op rather than a gap.
func (p *retryPool) QueryRowContext(ctx context.Context, query string, args ...any) *stdsql.Row {
return p.db.QueryRowContext(ctx, query, args...)
}

// BeginTx implements gorm.ConnPoolBeginner. The transaction is deliberately
// unwrapped — see the type comment.
func (p *retryPool) BeginTx(ctx context.Context, opts *stdsql.TxOptions) (gorm.ConnPool, error) {
return p.db.BeginTx(ctx, opts)
}

// GetDBConn implements gorm.GetDBConnector so gorm.DB.DB() keeps working —
// HealthCheck and Close both go through it.
func (p *retryPool) GetDBConn() (*stdsql.DB, error) { return p.db, nil }
86 changes: 86 additions & 0 deletions internal/storage/db/serenedb/provider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Package serenedb adapts the SQL storage provider to SereneDB, a search-OLAP
// engine that speaks the PostgreSQL wire protocol. It reuses the SQL provider
// wholesale and overrides only the two places where SereneDB's SQL differs from
// PostgreSQL's:
//
// 1. Migration. GORM AutoMigrate reconciles column types on every boot, but
// SereneDB reports varchar(n) as text and refuses ALTER COLUMN ... TYPE on
// an indexed column, so the second startup fails. migrate below creates what
// is missing and never alters what exists.
// 2. Upserts with an explicit conflict target (see upsert.go).
//
// SereneDB's MVCC is optimistic, so contended same-row writes abort with
// SQLSTATE 40001 instead of blocking. That is handled at the connection pool
// (see connpool.go) so every write path gets it, not just the two overridden
// here — but only for autocommit statements. The SQL provider's four explicit
// Transaction() call sites still surface 40001 under sustained same-row
// contention.
package serenedb

import (
"github.com/rs/zerolog"
"gorm.io/gorm"

"github.com/authorizerdev/authorizer/internal/config"
"github.com/authorizerdev/authorizer/internal/storage/db/sql"
)

// Dependencies struct for the serenedb data store provider
type Dependencies struct {
Log *zerolog.Logger
}

type provider struct {
*sql.Provider
}

// NewProvider returns a new SereneDB provider
func NewProvider(cfg *config.Config, deps *Dependencies) (*provider, error) {
base, err := sql.NewProviderWithOptions(cfg, &sql.Dependencies{Log: deps.Log}, sql.Options{
Migrate: migrate,
WrapPool: newRetryPool,
SkipDefaultTransaction: true,
})
if err != nil {
return nil, err
}
return &provider{Provider: base}, nil
}

// migrate creates missing tables, columns and indexes, and nothing else.
// SereneDB supports CREATE TABLE, ALTER TABLE ADD COLUMN and CREATE INDEX, but
// not the ALTER COLUMN ... TYPE / SET NOT NULL / SET DEFAULT that GORM's
// AutoMigrate issues when reconciling an existing table.
func migrate(db *gorm.DB) error {
m := db.Migrator()
for _, model := range sql.Models() {
if !m.HasTable(model) {
if err := m.CreateTable(model); err != nil {
return err
}
continue
}
stmt := &gorm.Statement{DB: db}
if err := stmt.Parse(model); err != nil {
return err
}
for _, field := range stmt.Schema.Fields {
if field.DBName == "" || field.IgnoreMigration {
continue
}
if !m.HasColumn(model, field.DBName) {
if err := m.AddColumn(model, field.DBName); err != nil {
return err
}
}
}
for name := range stmt.Schema.ParseIndexes() {
if !m.HasIndex(model, name) {
if err := m.CreateIndex(model, name); err != nil {
return err
}
}
}
}
return nil
}
Loading
Loading