diff --git a/CHANGELOG.md b/CHANGELOG.md index ca10f81eaf..74cc05e987 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Try to keep listed changes to a concise bulleted list of simple explanations of changes. Aim for the amount of information needed so that readers can understand where they would look in the codebase to investigate the changes' implementation, or where they would look in the documentation to understand how to make use of the change in practice - better yet, link directly to the docs and provide detailed information there. Only elaborate if doing so is required to avoid breaking changes or experimental features from ruining someone's day. ## [Unreleased] +### Added +- Add Amazon Aurora DSQL as a supported storage backend. + ### Changed +- Update Aurora DSQL Go connector to v0.2.0. Pool settings are now passed via `pgxpool.Config` instead of `dsql.Config`. - HTTP gateway's internal gRPC client now uses dynamic TLS credentials that automatically update on certificate rotation via certwatcher, preventing connection failures when certificates are rotated (e.g., by cert-manager). [#2951](https://github.com/openfga/openfga/pull/2951) ### Fixed diff --git a/assets/assets.go b/assets/assets.go index 2ae310d31f..6067d63cae 100644 --- a/assets/assets.go +++ b/assets/assets.go @@ -7,6 +7,7 @@ const ( MySQLMigrationDir = "migrations/mysql" PostgresMigrationDir = "migrations/postgres" SqliteMigrationDir = "migrations/sqlite" + DSQLMigrationDir = "migrations/dsql" ) // EmbedMigrations within the openfga binary. diff --git a/assets/migrations/dsql/001_initialize_schema.sql b/assets/migrations/dsql/001_initialize_schema.sql new file mode 100644 index 0000000000..beea64b2c4 --- /dev/null +++ b/assets/migrations/dsql/001_initialize_schema.sql @@ -0,0 +1,60 @@ +-- +goose Up +-- +goose NO TRANSACTION +-- DSQL schema: uses full indexes (no partial), ASYNC index creation, C collation by default + +CREATE TABLE tuple ( + store TEXT NOT NULL, + object_type TEXT NOT NULL, + object_id TEXT NOT NULL, + relation TEXT NOT NULL, + _user TEXT NOT NULL, + user_type TEXT NOT NULL, + ulid TEXT NOT NULL, + inserted_at TIMESTAMPTZ NOT NULL, + PRIMARY KEY (store, object_type, object_id, relation, _user) +); + +CREATE INDEX ASYNC idx_tuple_user ON tuple (store, object_type, object_id, relation, _user, user_type); +CREATE UNIQUE INDEX ASYNC idx_tuple_ulid ON tuple (ulid); + +CREATE TABLE authorization_model ( + store TEXT NOT NULL, + authorization_model_id TEXT NOT NULL, + type TEXT NOT NULL, + type_definition BYTEA, + PRIMARY KEY (store, authorization_model_id, type) +); + +CREATE TABLE store ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ +); + +CREATE TABLE assertion ( + store TEXT NOT NULL, + authorization_model_id TEXT NOT NULL, + assertions BYTEA, + PRIMARY KEY (store, authorization_model_id) +); + +CREATE TABLE changelog ( + store TEXT NOT NULL, + object_type TEXT NOT NULL, + object_id TEXT NOT NULL, + relation TEXT NOT NULL, + _user TEXT NOT NULL, + operation INTEGER NOT NULL, + ulid TEXT NOT NULL, + inserted_at TIMESTAMPTZ NOT NULL, + PRIMARY KEY (store, ulid, object_type) +); + +-- +goose Down +DROP TABLE IF EXISTS tuple; +DROP TABLE IF EXISTS authorization_model; +DROP TABLE IF EXISTS store; +DROP TABLE IF EXISTS assertion; +DROP TABLE IF EXISTS changelog; diff --git a/assets/migrations/dsql/002_add_authorization_model_version.sql b/assets/migrations/dsql/002_add_authorization_model_version.sql new file mode 100644 index 0000000000..0b6eeb7f20 --- /dev/null +++ b/assets/migrations/dsql/002_add_authorization_model_version.sql @@ -0,0 +1,9 @@ +-- +goose Up +-- +goose NO TRANSACTION +-- DSQL: ADD COLUMN without DEFAULT, then UPDATE existing rows +ALTER TABLE authorization_model ADD COLUMN schema_version TEXT; +UPDATE authorization_model SET schema_version = '1.0' WHERE schema_version IS NULL; + +-- +goose Down +-- +goose NO TRANSACTION +ALTER TABLE authorization_model DROP COLUMN schema_version; diff --git a/assets/migrations/dsql/003_add_reverse_lookup_index.sql b/assets/migrations/dsql/003_add_reverse_lookup_index.sql new file mode 100644 index 0000000000..5c63012402 --- /dev/null +++ b/assets/migrations/dsql/003_add_reverse_lookup_index.sql @@ -0,0 +1,7 @@ +-- +goose Up +-- +goose NO TRANSACTION +CREATE INDEX ASYNC idx_reverse_lookup_user ON tuple (store, object_type, relation, _user); + +-- +goose Down +-- +goose NO TRANSACTION +DROP INDEX IF EXISTS idx_reverse_lookup_user; diff --git a/assets/migrations/dsql/004_add_authorization_model_serialized_protobuf.sql b/assets/migrations/dsql/004_add_authorization_model_serialized_protobuf.sql new file mode 100644 index 0000000000..bcb43336bf --- /dev/null +++ b/assets/migrations/dsql/004_add_authorization_model_serialized_protobuf.sql @@ -0,0 +1,7 @@ +-- +goose Up +-- +goose NO TRANSACTION +ALTER TABLE authorization_model ADD COLUMN serialized_protobuf BYTEA; + +-- +goose Down +-- +goose NO TRANSACTION +ALTER TABLE authorization_model DROP COLUMN serialized_protobuf; diff --git a/assets/migrations/dsql/005_add_conditions_to_tuples.sql b/assets/migrations/dsql/005_add_conditions_to_tuples.sql new file mode 100644 index 0000000000..9b796aaaa7 --- /dev/null +++ b/assets/migrations/dsql/005_add_conditions_to_tuples.sql @@ -0,0 +1,14 @@ +-- +goose Up +-- +goose NO TRANSACTION +-- DSQL: separate ALTER statements (one DDL per transaction) +ALTER TABLE tuple ADD COLUMN condition_name TEXT; +ALTER TABLE tuple ADD COLUMN condition_context BYTEA; +ALTER TABLE changelog ADD COLUMN condition_name TEXT; +ALTER TABLE changelog ADD COLUMN condition_context BYTEA; + +-- +goose Down +-- +goose NO TRANSACTION +ALTER TABLE tuple DROP COLUMN condition_name; +ALTER TABLE tuple DROP COLUMN condition_context; +ALTER TABLE changelog DROP COLUMN condition_name; +ALTER TABLE changelog DROP COLUMN condition_context; diff --git a/assets/migrations/dsql/006_add_collate_index.sql b/assets/migrations/dsql/006_add_collate_index.sql new file mode 100644 index 0000000000..17d6df66fd --- /dev/null +++ b/assets/migrations/dsql/006_add_collate_index.sql @@ -0,0 +1,17 @@ +-- +goose Up +-- +goose NO TRANSACTION +-- DSQL: uses C collation by default, ASYNC instead of CONCURRENTLY +CREATE INDEX ASYNC idx_user_lookup ON tuple ( + store, + _user, + relation, + object_type, + object_id +); + +DROP INDEX IF EXISTS idx_reverse_lookup_user; + +-- +goose Down +-- +goose NO TRANSACTION +DROP INDEX IF EXISTS idx_user_lookup; +CREATE INDEX ASYNC idx_reverse_lookup_user ON tuple (store, object_type, relation, _user); diff --git a/assets/migrations/dsql/README.md b/assets/migrations/dsql/README.md new file mode 100644 index 0000000000..6397f5645a --- /dev/null +++ b/assets/migrations/dsql/README.md @@ -0,0 +1,39 @@ +# DSQL Migrations for OpenFGA + +These migrations are adapted from the PostgreSQL migrations for Aurora DSQL compatibility. + +## Key Differences from PostgreSQL Migrations + +1. **ASYNC Indexes**: All `CREATE INDEX` statements use `CREATE INDEX ASYNC` (DSQL requirement) +2. **NO TRANSACTION**: All migrations use `-- +goose NO TRANSACTION` as DSQL runs one DDL statement per transaction +3. **BIGINT IDENTITY**: The goose version table uses `BIGINT GENERATED BY DEFAULT AS IDENTITY`, as required by DSQL +4. **No Partial Indexes**: DSQL doesn't support partial indexes, so full indexes are used + +## Migration Files + +| File | Description | +|------|-------------| +| 001_initialize_schema.sql | Creates core tables (tuple, authorization_model, store, assertion, changelog) | +| 002_add_authorization_model_version.sql | Adds schema_version column | +| 003_add_reverse_lookup_index.sql | Adds reverse lookup index | +| 004_add_authorization_model_serialized_protobuf.sql | Adds serialized_protobuf column | +| 005_add_conditions_to_tuples.sql | Adds condition columns to tuple and changelog | +| 006_add_collate_index.sql | Adds user lookup index with C collation | + +## Future Consideration: Splitting Migrations + +Per DSQL best practices, each migration should ideally contain only one DDL statement. Currently, migrations 002 and 005 contain multiple statements: + +- **002**: ALTER TABLE + UPDATE (mixed DDL and DML) +- **005**: Four ALTER TABLE statements + +While these work correctly with `-- +goose NO TRANSACTION` (goose executes each statement separately), consider splitting them into individual migration files if you encounter OCC errors during migration. For example: + +``` +005a_add_condition_name_to_tuple.sql +005b_add_condition_context_to_tuple.sql +005c_add_condition_name_to_changelog.sql +005d_add_condition_context_to_changelog.sql +``` + +See [DSQL Development Guide](https://docs.aws.amazon.com/aurora-dsql/latest/userguide/) for more information on DDL best practices. diff --git a/cmd/run/run.go b/cmd/run/run.go index f9b302e368..d509a78ca7 100644 --- a/cmd/run/run.go +++ b/cmd/run/run.go @@ -503,6 +503,13 @@ func (s *ServerContext) datastoreConfig(config *serverconfig.Config) (storage.Op if err != nil { return nil, nil, fmt.Errorf("initialize sqlite datastore: %w", err) } + case "dsql": + // Aurora DSQL uses PostgreSQL wire protocol with IAM authentication + // The postgres.New function handles dsql:// URIs automatically + datastore, err = postgres.New(config.Datastore.URI, dsCfg) + if err != nil { + return nil, nil, fmt.Errorf("initialize dsql datastore: %w", err) + } default: return nil, nil, fmt.Errorf("storage engine '%s' is unsupported", config.Datastore.Engine) } diff --git a/go.mod b/go.mod index 20cac6de69..89cbef931b 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/Masterminds/squirrel v1.5.4 github.com/MicahParks/keyfunc/v2 v2.1.0 github.com/Yiling-J/theine-go v0.6.2 + github.com/awslabs/aurora-dsql-connectors/go/pgx v0.2.0 github.com/cenkalti/backoff/v4 v4.3.0 github.com/cespare/xxhash/v2 v2.3.0 github.com/containerd/errdefs v1.0.0 @@ -68,6 +69,21 @@ require ( github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/aws/aws-sdk-go-v2 v1.41.3 // indirect + github.com/aws/aws-sdk-go-v2/config v1.32.11 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.11 // indirect + github.com/aws/aws-sdk-go-v2/feature/dsql/auth v1.1.19 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 // indirect + github.com/aws/smithy-go v1.24.2 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect diff --git a/go.sum b/go.sum index 3471c4b2c6..5a9cfd36fe 100644 --- a/go.sum +++ b/go.sum @@ -22,6 +22,38 @@ github.com/Yiling-J/theine-go v0.6.2 h1:1GeoXeQ0O0AUkiwj2S9Jc0Mzx+hpqzmqsJ4kIC4M github.com/Yiling-J/theine-go v0.6.2/go.mod h1:08QpMa5JZ2pKN+UJCRrCasWYO1IKCdl54Xa836rpmDU= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/aws/aws-sdk-go-v2 v1.41.3 h1:4kQ/fa22KjDt13QCy1+bYADvdgcxpfH18f0zP542kZA= +github.com/aws/aws-sdk-go-v2 v1.41.3/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= +github.com/aws/aws-sdk-go-v2/config v1.32.11 h1:ftxI5sgz8jZkckuUHXfC/wMUc8u3fG1vQS0plr2F2Zs= +github.com/aws/aws-sdk-go-v2/config v1.32.11/go.mod h1:twF11+6ps9aNRKEDimksp923o44w/Thk9+8YIlzWMmo= +github.com/aws/aws-sdk-go-v2/credentials v1.19.11 h1:NdV8cwCcAXrCWyxArt58BrvZJ9pZ9Fhf9w6Uh5W3Uyc= +github.com/aws/aws-sdk-go-v2/credentials v1.19.11/go.mod h1:30yY2zqkMPdrvxBqzI9xQCM+WrlrZKSOpSJEsylVU+8= +github.com/aws/aws-sdk-go-v2/feature/dsql/auth v1.1.19 h1:jS9Xb83Lr9ilcmR8NhhIh2G52eQ2Dd2Ng2p8xv23uK8= +github.com/aws/aws-sdk-go-v2/feature/dsql/auth v1.1.19/go.mod h1:XExMXXv/juteYG/NHNCMzsExbXzByiAJMmVMcdgPrOo= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 h1:INUvJxmhdEbVulJYHI061k4TVuS3jzzthNvjqvVvTKM= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19/go.mod h1:FpZN2QISLdEBWkayloda+sZjVJL+e9Gl0k1SyTgcswU= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 h1:/sECfyq2JTifMI2JPyZ4bdRN77zJmr6SrS1eL3augIA= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19/go.mod h1:dMf8A5oAqr9/oxOfLkC/c2LU/uMcALP0Rgn2BD5LWn0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 h1:AWeJMk33GTBf6J20XJe6qZoRSJo0WfUhsMdUKhoODXE= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19/go.mod h1:+GWrYoaAsV7/4pNHpwh1kiNLXkKaSoppxQq9lbH8Ejw= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 h1:clHU5fm//kWS1C2HgtgWxfQbFbx4b6rx+5jzhgX9HrI= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 h1:XAq62tBTJP/85lFD5oqOOe7YYgWxY9LvWq8plyDvDVg= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 h1:X1Tow7suZk9UCJHE1Iw9GMZJJl0dAnKXXP1NaSDHwmw= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19/go.mod h1:/rARO8psX+4sfjUQXp5LLifjUt8DuATZ31WptNJTyQA= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 h1:Y2cAXlClHsXkkOvWZFXATr34b0hxxloeQu/pAZz2row= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.7/go.mod h1:idzZ7gmDeqeNrSPkdbtMp9qWMgcBwykA7P7Rzh5DXVU= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 h1:iSsvB9EtQ09YrsmIc44Heqlx5ByGErqhPK1ZQLppias= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.12/go.mod h1:fEWYKTRGoZNl8tZ77i61/ccwOMJdGxwOhWCkp6TXAr0= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 h1:EnUdUqRP1CNzt2DkV67tJx6XDN4xlfBFm+bzeNOQVb0= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16/go.mod h1:Jic/xv0Rq/pFNCh3WwpH4BEqdbSAl+IyHro8LbibHD8= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 h1:XQTQTF75vnug2TXS8m7CVJfC2nniYPZnO1D4Np761Oo= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.8/go.mod h1:Xgx+PR1NUOjNmQY+tRMnouRp83JRM8pRMw/vCaVhPkI= +github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= +github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/awslabs/aurora-dsql-connectors/go/pgx v0.2.0 h1:irsxnXcNaqkVmkmp5lkzpJ+dNxTCojuuEWGWL+gKBcg= +github.com/awslabs/aurora-dsql-connectors/go/pgx v0.2.0/go.mod h1:KmZA9VBz4ubBFxTN7/FWo+V6sbcwPr/n1Qup4KXBV34= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= diff --git a/internal/dsql/dsql.go b/internal/dsql/dsql.go new file mode 100644 index 0000000000..d26c3618e3 --- /dev/null +++ b/internal/dsql/dsql.go @@ -0,0 +1,90 @@ +// Package dsql provides shared utilities for Aurora DSQL connections. +package dsql + +import ( + "context" + "database/sql" + "fmt" + "net/url" + "strings" + + "github.com/awslabs/aurora-dsql-connectors/go/pgx/dsql" +) + +// PreparePostgresURI converts a dsql:// URI to a postgres:// URI with IAM authentication. +// If username is provided, it overrides any username in the URI for token generation. +func PreparePostgresURI(uri string, username string) (string, error) { + // Inject username into URI for token generation if provided. + tokenURI := uri + if username != "" { + pgURI := "postgres" + strings.TrimPrefix(uri, "dsql") + parsed, err := url.Parse(pgURI) + if err != nil { + return "", fmt.Errorf("parse URI for username override: %w", err) + } + parsed.User = url.User(username) + tokenURI = "dsql" + strings.TrimPrefix(parsed.String(), "postgres") + } + + token, err := dsql.GenerateTokenConnString(context.Background(), tokenURI) + if err != nil { + return "", fmt.Errorf("generate DSQL auth token: %w", err) + } + + pgURI := "postgres" + strings.TrimPrefix(uri, "dsql") + dbURI, err := url.Parse(pgURI) + if err != nil { + return "", fmt.Errorf("parse database URI: %w", err) + } + + // Determine final username: param > URI > default "admin" + finalUser := username + if finalUser == "" { + if dbURI.User != nil { + finalUser = dbURI.User.Username() + } + if finalUser == "" { + finalUser = "admin" + } + } + dbURI.User = url.UserPassword(finalUser, token) + + q := dbURI.Query() + q.Set("sslmode", "require") + q.Del("region") + dbURI.RawQuery = q.Encode() + + return dbURI.String(), nil +} + +// GooseTableDDL is the DDL for creating a DSQL-compatible goose version table. +// Uses BIGINT GENERATED BY DEFAULT AS IDENTITY, as required by DSQL. +const GooseTableDDL = ` + CREATE TABLE IF NOT EXISTS goose_db_version ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY (CACHE 1) PRIMARY KEY, + version_id BIGINT NOT NULL, + is_applied BOOLEAN NOT NULL, + tstamp TIMESTAMP DEFAULT now() + ) +` + +// EnsureGooseTable creates the goose_db_version table if it doesn't exist. +func EnsureGooseTable(db *sql.DB) error { + if _, err := db.Exec(GooseTableDDL); err != nil { + return fmt.Errorf("create goose table: %w", err) + } + + // Goose expects an initial row with version 0 to exist. + var hasRows bool + if err := db.QueryRow(`SELECT EXISTS (SELECT 1 FROM goose_db_version)`).Scan(&hasRows); err != nil { + return fmt.Errorf("check goose table: %w", err) + } + + if !hasRows { + if _, err := db.Exec(`INSERT INTO goose_db_version (version_id, is_applied) VALUES (0, TRUE)`); err != nil { + return fmt.Errorf("insert initial goose row: %w", err) + } + } + + return nil +} diff --git a/pkg/storage/migrate/dsql.go b/pkg/storage/migrate/dsql.go new file mode 100644 index 0000000000..0061a86e3a --- /dev/null +++ b/pkg/storage/migrate/dsql.go @@ -0,0 +1,55 @@ +package migrate + +import ( + "fmt" + + "github.com/pressly/goose/v3" + + "github.com/openfga/openfga/assets" + "github.com/openfga/openfga/internal/dsql" + "github.com/openfga/openfga/pkg/logger" +) + +// dsqlMigrationConfig holds DSQL-specific migration configuration. +type dsqlMigrationConfig struct { + driver string + migrationsPath string + uri string +} + +// prepareDSQLMigration prepares the migration configuration for DSQL. +func prepareDSQLMigration(uri string, username string, log logger.Logger) (*dsqlMigrationConfig, error) { + pgURI, err := dsql.PreparePostgresURI(uri, username) + if err != nil { + return nil, fmt.Errorf("prepare DSQL URI: %w", err) + } + + if err := ensureGooseTableForDSQL(pgURI, log); err != nil { + return nil, fmt.Errorf("create goose version table: %w", err) + } + + log.Info("using DSQL datastore with IAM authentication") + + return &dsqlMigrationConfig{ + driver: "pgx", + migrationsPath: assets.DSQLMigrationDir, + uri: pgURI, + }, nil +} + +// ensureGooseTableForDSQL creates the goose_db_version table if it doesn't exist. +// Uses a custom DDL with BIGINT IDENTITY, as required by DSQL. +func ensureGooseTableForDSQL(uri string, log logger.Logger) error { + db, err := goose.OpenDBWithDriver("pgx", uri) + if err != nil { + return fmt.Errorf("open connection: %w", err) + } + defer db.Close() + + if err := dsql.EnsureGooseTable(db); err != nil { + return err + } + + log.Info("ensured goose_db_version table exists for DSQL") + return nil +} diff --git a/pkg/storage/migrate/migrate.go b/pkg/storage/migrate/migrate.go index cc59bbabd0..53d0612ee0 100644 --- a/pkg/storage/migrate/migrate.go +++ b/pkg/storage/migrate/migrate.go @@ -103,6 +103,14 @@ func RunMigrations(cfg MigrationConfig) error { if err != nil { return err } + case "dsql": + dsqlCfg, err := prepareDSQLMigration(uri, cfg.Username, log) + if err != nil { + return err + } + driver = dsqlCfg.driver + migrationsPath = dsqlCfg.migrationsPath + uri = dsqlCfg.uri case "": return fmt.Errorf("missing datastore engine type") default: diff --git a/pkg/storage/postgres/dsql.go b/pkg/storage/postgres/dsql.go new file mode 100644 index 0000000000..bb12c0c946 --- /dev/null +++ b/pkg/storage/postgres/dsql.go @@ -0,0 +1,82 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/awslabs/aurora-dsql-connectors/go/pgx/dsql" + "github.com/cenkalti/backoff/v4" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/openfga/openfga/pkg/storage/sqlcommon" +) + +// isOCCError checks if the error is a DSQL optimistic concurrency control conflict. +// DSQL returns OC000 for mutation conflicts and OC001 for schema conflicts. +func isOCCError(err error) bool { + if err == nil { + return false + } + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + return pgErr.Code == "OC000" || pgErr.Code == "OC001" || pgErr.Code == "40001" + } + return false +} + +// withOCCRetry executes fn with automatic retry on DSQL OCC errors. +func withOCCRetry(ctx context.Context, fn func() error) error { + policy := backoff.NewExponentialBackOff() + policy.InitialInterval = 10 * time.Millisecond + policy.MaxElapsedTime = 5 * time.Second + + return backoff.Retry(func() error { + err := fn() + if err == nil { + return nil + } + if isOCCError(err) { + return err + } + return backoff.Permanent(err) + }, backoff.WithContext(policy, ctx)) +} + +// initDSQLDB initializes a new Aurora DSQL database connection. +// DSQL uses IAM authentication which the connector handles automatically. +func initDSQLDB(uri string, cfg *sqlcommon.Config) (*pgxpool.Pool, error) { + dsqlCfg, err := dsql.ParseConnectionString(uri) + if err != nil { + return nil, fmt.Errorf("parse DSQL URI: %w", err) + } + + // Override username from config (DSQL uses IAM tokens, not passwords). + if cfg.Username != "" { + dsqlCfg.User = cfg.Username + } + + // Apply OpenFGA pool settings via pgxpool.Config. + poolCfg, _ := pgxpool.ParseConfig("") + if cfg.MaxOpenConns != 0 { + poolCfg.MaxConns = int32(cfg.MaxOpenConns) + } + if cfg.MinOpenConns != 0 { + poolCfg.MinConns = int32(cfg.MinOpenConns) + } + if cfg.ConnMaxLifetime != 0 { + poolCfg.MaxConnLifetime = cfg.ConnMaxLifetime + } + if cfg.ConnMaxIdleTime != 0 { + poolCfg.MaxConnIdleTime = cfg.ConnMaxIdleTime + } + + pool, err := dsql.NewPool(context.Background(), dsqlCfg, poolCfg) + if err != nil { + return nil, fmt.Errorf("create DSQL pool: %w", err) + } + + return pool.Pool, nil +} diff --git a/pkg/storage/postgres/postgres.go b/pkg/storage/postgres/postgres.go index 3d9a911cc6..7a28da651c 100644 --- a/pkg/storage/postgres/postgres.go +++ b/pkg/storage/postgres/postgres.go @@ -54,6 +54,7 @@ type Datastore struct { maxTuplesPerWriteField int maxTypesPerModelField int versionReady bool + isDSQL bool // true when using Aurora DSQL (affects query behavior) } // Ensures that Datastore implements the OpenFGADatastore interface. @@ -208,6 +209,11 @@ func createBeforeConnect(logger logger.Logger, provider PassfileProvider) func(c // initDB initializes a new postgres database connection. func initDB(uri string, override bool, cfg *sqlcommon.Config) (*pgxpool.Pool, error) { + // DSQL uses dsql:// URI scheme + if strings.HasPrefix(uri, "dsql://") { + return initDSQLDB(uri, cfg) + } + c, err := parseConfig(uri, override, cfg) if err != nil { return nil, err @@ -223,6 +229,9 @@ func initDB(uri string, override bool, cfg *sqlcommon.Config) (*pgxpool.Pool, er // New creates a new [Datastore] storage. func New(uri string, cfg *sqlcommon.Config) (*Datastore, error) { + // Detect if this is a DSQL connection + isDSQL := strings.HasPrefix(uri, "dsql://") + primaryDB, err := initDB(uri, cfg.Username != "" || cfg.Password != "", cfg) if err != nil { return nil, fmt.Errorf("initialize postgres connection: %w", err) @@ -236,7 +245,12 @@ func New(uri string, cfg *sqlcommon.Config) (*Datastore, error) { } } - return NewWithDB(primaryDB, secondaryDB, cfg) + ds, err := NewWithDB(primaryDB, secondaryDB, cfg) + if err != nil { + return nil, err + } + ds.isDSQL = isDSQL + return ds, nil } func configureDB(db *pgxpool.Pool, cfg *sqlcommon.Config, dbName string) (prometheus.Collector, error) { @@ -423,15 +437,20 @@ func (s *Datastore) Write( ) error { ctx, span := startTrace(ctx, "Write") defer span.End() - return s.write(ctx, store, deletes, writes, storage.NewTupleWriteOptions(opts...), time.Now().UTC()) + writeOpts := storage.NewTupleWriteOptions(opts...) + if s.isDSQL { + return withOCCRetry(ctx, func() error { return s.write(ctx, store, deletes, writes, writeOpts, time.Now().UTC()) }) + } + return s.write(ctx, store, deletes, writes, writeOpts, time.Now().UTC()) } -// execute SELECT … FOR UPDATE statement for all the rows indicated by the lockKeys -// return a map of all the existing keys. +// selectAllExistingRowsForUpdate executes SELECT … FOR UPDATE for all lockKeys. +// For PostgreSQL (isDSQL=false), uses FOR UPDATE. For DSQL (isDSQL=true), uses OCC. func selectAllExistingRowsForUpdate(ctx context.Context, lockKeys []sqlcommon.TupleLockKey, txn PgxQuery, - store string) (map[string]*openfgav1.Tuple, error) { + store string, + isDSQL bool) (map[string]*openfgav1.Tuple, error) { total := len(lockKeys) stbl := sq.StatementBuilder.PlaceholderFormat(sq.Dollar) existing := make(map[string]*openfgav1.Tuple, total) @@ -443,7 +462,7 @@ func selectAllExistingRowsForUpdate(ctx context.Context, } keys := lockKeys[start:end] - if err := selectExistingRowsForWrite(ctx, stbl, txn, store, keys, existing); err != nil { + if err := selectExistingRowsForWrite(ctx, stbl, txn, store, keys, existing, isDSQL); err != nil { return nil, err } } @@ -451,7 +470,7 @@ func selectAllExistingRowsForUpdate(ctx context.Context, } // For the prepared deleteConditions, execute delete tuples. -func executeDeleteTuples(ctx context.Context, txn PgxExec, store string, deleteConditions sq.Or) error { +func executeDeleteTuples(ctx context.Context, txn PgxExec, store string, deleteConditions sq.Or, isDSQL bool) error { stbl := sq.StatementBuilder.PlaceholderFormat(sq.Dollar) for start, totalDeletes := 0, len(deleteConditions); start < totalDeletes; start += storage.DefaultMaxTuplesPerWrite { @@ -462,8 +481,24 @@ func executeDeleteTuples(ctx context.Context, txn PgxExec, store string, deleteC deleteConditionsBatch := deleteConditions[start:end] - stmt, args, err := stbl.Delete("tuple").Where(sq.Eq{"store": store}). - Where(deleteConditionsBatch).ToSql() + var stmt string + var args []interface{} + var err error + + if isDSQL { + // DSQL: Use USING (VALUES ...) AS k(...) for better query plan + suffix, suffixArgs := sqlcommon.BuildDSQLDeleteUsing(store, deleteConditionsBatch) + stmt, args, err = stbl.Delete("tuple t"). + Suffix(suffix, suffixArgs...). + ToSql() + } else { + // PostgreSQL: Use standard DELETE with OR conditions + stmt, args, err = stbl.Delete("tuple"). + Where(sq.Eq{"store": store}). + Where(deleteConditionsBatch). + ToSql() + } + if err != nil { // Should never happen because we craft the delete statement return HandleSQLError(err) @@ -585,7 +620,14 @@ func (s *Datastore) write( opts storage.TupleWriteOptions, now time.Time, ) error { - txn, err := s.primaryDB.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.ReadCommitted}) + // DSQL uses strong snapshot isolation; skip explicit isolation level. + var txn pgx.Tx + var err error + if s.isDSQL { + txn, err = s.primaryDB.Begin(ctx) + } else { + txn, err = s.primaryDB.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.ReadCommitted}) + } if err != nil { return HandleSQLError(err) } @@ -603,7 +645,8 @@ func (s *Datastore) write( } // 3. If list compiled in step 2 is not empty, execute SELECT … FOR UPDATE statement - existing, err := selectAllExistingRowsForUpdate(ctx, lockKeys, txn, store) + // DSQL uses OCC, so skip FOR UPDATE (conflicts detected at commit time). + existing, err := selectAllExistingRowsForUpdate(ctx, lockKeys, txn, store, s.isDSQL) if err != nil { return err } @@ -620,7 +663,7 @@ func (s *Datastore) write( return err } - err = executeDeleteTuples(ctx, txn, store, deleteConditions) + err = executeDeleteTuples(ctx, txn, store, deleteConditions, s.isDSQL) if err != nil { return err } @@ -1394,22 +1437,50 @@ func HandleSQLError(err error, args ...interface{}) error { return fmt.Errorf("sql error: %w", err) } -// selectExistingRowsForWrite selects existing rows for the given keys and locks them FOR UPDATE. -// The existing rows are added to the existing map. -func selectExistingRowsForWrite(ctx context.Context, stbl sq.StatementBuilderType, txn PgxQuery, store string, keys []sqlcommon.TupleLockKey, existing map[string]*openfgav1.Tuple) error { - inExpr, args := sqlcommon.BuildRowConstructorIN(keys) +// selectExistingRowsForWrite selects existing rows for the given keys. +// For PostgreSQL (isDSQL=false), locks rows with FOR UPDATE. +// For DSQL (isDSQL=true), uses a CTE with JOIN for better query plan performance and OCC. +func selectExistingRowsForWrite(ctx context.Context, stbl sq.StatementBuilderType, txn PgxQuery, store string, keys []sqlcommon.TupleLockKey, existing map[string]*openfgav1.Tuple, isDSQL bool) error { + var poolGetRows *PgxTxnIterQuery + var err error + + if isDSQL { + // DSQL: Use CTE with JOIN for better query plan + ctePrefix, joinCond, cteArgs := sqlcommon.BuildCTESelectJoin(keys) + + // Prefix all columns with "t." to avoid ambiguity + cols := sqlcommon.SQLIteratorColumns() + selectCols := make([]string, len(cols)) + for i, col := range cols { + selectCols[i] = "t." + col + } - sb := stbl. - Select(sqlcommon.SQLIteratorColumns()...). - From("tuple"). - Where(sq.Eq{"store": store}). - // Row-constructor IN on full composite key for precise point locks. - Where(sq.Expr("(object_type, object_id, relation, _user, user_type) IN "+inExpr, args...)). - Suffix("FOR UPDATE") + sb := stbl. + Select(selectCols...). + Prefix(ctePrefix, cteArgs...). + From("tuple t"). + Join(joinCond). + Where(sq.Eq{"t.store": store}) - poolGetRows, err := NewPgxTxnGetRows(txn, sb) - if err != nil { - return HandleSQLError(err) + poolGetRows, err = NewPgxTxnGetRows(txn, sb) + if err != nil { + return HandleSQLError(err) + } + } else { + // PostgreSQL: Use row-constructor IN with FOR UPDATE + inExpr, args := sqlcommon.BuildRowConstructorIN(keys) + + sb := stbl. + Select(sqlcommon.SQLIteratorColumns()...). + From("tuple"). + Where(sq.Eq{"store": store}). + Where(sq.Expr("(object_type, object_id, relation, _user, user_type) IN "+inExpr, args...)). + Suffix("FOR UPDATE") + + poolGetRows, err = NewPgxTxnGetRows(txn, sb) + if err != nil { + return HandleSQLError(err) + } } iter := sqlcommon.NewSQLTupleIterator(poolGetRows, HandleSQLError) diff --git a/pkg/storage/postgres/postgres_test.go b/pkg/storage/postgres/postgres_test.go index 8e5ecf3eea..59a353c400 100644 --- a/pkg/storage/postgres/postgres_test.go +++ b/pkg/storage/postgres/postgres_test.go @@ -1626,7 +1626,7 @@ func TestExecuteDeleteTuples(t *testing.T) { ctrl := gomock.NewController(t) t.Cleanup(ctrl.Finish) mockPgxExec := mocks.NewMockpgxExec(ctrl) - err := executeDeleteTuples(context.Background(), mockPgxExec, "123", deleteConditions) + err := executeDeleteTuples(context.Background(), mockPgxExec, "123", deleteConditions, false) require.NoError(t, err) }) @@ -1640,7 +1640,7 @@ func TestExecuteDeleteTuples(t *testing.T) { t.Cleanup(ctrl.Finish) mockPgxExec := mocks.NewMockpgxExec(ctrl) mockPgxExec.EXPECT().Exec(gomock.Any(), gomock.Any(), gomock.Any()).Return(pgconn.NewCommandTag(""), fmt.Errorf("error")) - err := executeDeleteTuples(context.Background(), mockPgxExec, "123", deleteConditions) + err := executeDeleteTuples(context.Background(), mockPgxExec, "123", deleteConditions, false) require.Error(t, err) }) @@ -1654,7 +1654,7 @@ func TestExecuteDeleteTuples(t *testing.T) { t.Cleanup(ctrl.Finish) mockPgxExec := mocks.NewMockpgxExec(ctrl) mockPgxExec.EXPECT().Exec(gomock.Any(), gomock.Any(), gomock.Any()).Return(pgconn.NewCommandTag(""), nil) - err := executeDeleteTuples(context.Background(), mockPgxExec, "123", deleteConditions) + err := executeDeleteTuples(context.Background(), mockPgxExec, "123", deleteConditions, false) require.ErrorIs(t, err, storage.ErrWriteConflictOnDelete) }) t.Run("correct_row", func(t *testing.T) { @@ -1667,7 +1667,7 @@ func TestExecuteDeleteTuples(t *testing.T) { t.Cleanup(ctrl.Finish) mockPgxExec := mocks.NewMockpgxExec(ctrl) mockPgxExec.EXPECT().Exec(gomock.Any(), gomock.Any(), gomock.Any()).Return(pgconn.NewCommandTag("DELETE 1"), nil) - err := executeDeleteTuples(context.Background(), mockPgxExec, "123", deleteConditions) + err := executeDeleteTuples(context.Background(), mockPgxExec, "123", deleteConditions, false) require.NoError(t, err) }) } diff --git a/pkg/storage/sqlcommon/dsql.go b/pkg/storage/sqlcommon/dsql.go new file mode 100644 index 0000000000..bd8d318d1b --- /dev/null +++ b/pkg/storage/sqlcommon/dsql.go @@ -0,0 +1,78 @@ +package sqlcommon + +import ( + "strings" + + sq "github.com/Masterminds/squirrel" +) + +// BuildCTESelectJoin builds a CTE with VALUES and JOIN conditions for DSQL-optimized SELECT. +// Returns the CTE prefix and JOIN conditions that can be used with Squirrel's Prefix() and Join(). +// Example usage with Squirrel: +// +// ctePrefix, joinCond, args := BuildCTESelectJoin(keys) +// sb := stbl.Select(sqlcommon.SQLIteratorColumns()...). +// Prefix(ctePrefix, args...). +// From("tuple t"). +// Join(joinCond). +// Where(sq.Eq{"t.store": store}) +func BuildCTESelectJoin(keys []TupleLockKey) (string, string, []interface{}) { + if len(keys) == 0 { + return "", "", nil + } + + // Build the CTE VALUES clause + var sb strings.Builder + args := make([]interface{}, 0, len(keys)*5) // 5 params per key + + sb.WriteString("WITH keys(object_type, object_id, relation, _user, user_type) AS (VALUES ") + for i, k := range keys { + if i > 0 { + sb.WriteString(", ") + } + sb.WriteString("(?,?,?,?,?)") + args = append(args, k.objectType, k.objectID, k.relation, k.user, k.userType) + } + sb.WriteString(")") + + // Build the JOIN condition + joinCond := "keys k ON t.object_type = k.object_type AND t.object_id = k.object_id AND t.relation = k.relation AND t._user = k._user AND t.user_type = k.user_type" + + return sb.String(), joinCond, args +} + +// BuildDSQLDeleteUsing builds a USING clause with VALUES for DSQL-optimized DELETE. +// Returns the USING suffix string and args that can be used with Squirrel's Suffix(). +func BuildDSQLDeleteUsing(store string, deleteConditions sq.Or) (string, []interface{}) { + if len(deleteConditions) == 0 { + return "", nil + } + + // Build the VALUES clause for USING + var sb strings.Builder + args := make([]interface{}, 0, len(deleteConditions)*5+1) // 5 params per condition + store + + sb.WriteString("USING (VALUES ") + for i, cond := range deleteConditions { + if i > 0 { + sb.WriteString(", ") + } + sb.WriteString("(?,?,?,?,?)") + + // Extract values from sq.Eq condition + eqCond := cond.(sq.Eq) + args = append(args, eqCond["object_type"], eqCond["object_id"], eqCond["relation"], eqCond["_user"], eqCond["user_type"]) + } + sb.WriteString(") AS k(object_type, object_id, relation, _user, user_type)") + sb.WriteString(" WHERE t.store = ?") + sb.WriteString(" AND t.object_type = k.object_type") + sb.WriteString(" AND t.object_id = k.object_id") + sb.WriteString(" AND t.relation = k.relation") + sb.WriteString(" AND t._user = k._user") + sb.WriteString(" AND t.user_type = k.user_type") + + // Append store as last arg (matches the WHERE t.store = ? position) + args = append(args, store) + + return sb.String(), args +} diff --git a/pkg/testfixtures/storage/dsql.go b/pkg/testfixtures/storage/dsql.go new file mode 100644 index 0000000000..55a78ecec0 --- /dev/null +++ b/pkg/testfixtures/storage/dsql.go @@ -0,0 +1,134 @@ +package storage + +import ( + "database/sql" + "fmt" + "os" + "testing" + "time" + + "github.com/cenkalti/backoff/v4" + _ "github.com/jackc/pgx/v5/stdlib" // PostgreSQL driver. + "github.com/pressly/goose/v3" + "github.com/stretchr/testify/require" + + "github.com/openfga/openfga/assets" + "github.com/openfga/openfga/internal/dsql" +) + +type dsqlTestContainer struct { + clusterEndpoint string + region string + version int64 +} + +// NewDSQLTestContainer returns an implementation of the DatastoreTestContainer interface +// for Aurora DSQL. +func NewDSQLTestContainer() *dsqlTestContainer { + return &dsqlTestContainer{} +} + +func (d *dsqlTestContainer) GetDatabaseSchemaVersion() int64 { + return d.version +} + +// RunDSQLTestContainer connects to a DSQL cluster, runs migrations, and returns a +// bootstrapped implementation of the DatastoreTestContainer interface wired up for the +// DSQL datastore engine. +func (d *dsqlTestContainer) RunDSQLTestContainer(t testing.TB) DatastoreTestContainer { + clusterEndpoint := os.Getenv("OPENFGA_DSQL_CLUSTER_ENDPOINT") + if clusterEndpoint == "" { + t.Skip("OPENFGA_DSQL_CLUSTER_ENDPOINT not set, skipping DSQL tests") + } + + region := os.Getenv("AWS_REGION") + if region == "" { + region = os.Getenv("AWS_DEFAULT_REGION") + } + if region == "" { + t.Skip("AWS_REGION not set, skipping DSQL tests") + } + + d.clusterEndpoint = clusterEndpoint + d.region = region + + pgURI, err := dsql.PreparePostgresURI(d.GetConnectionURI(false), "") + require.NoError(t, err, "failed to prepare DSQL URI") + + goose.SetLogger(goose.NopLogger()) + + db, err := goose.OpenDBWithDriver("pgx", pgURI) + require.NoError(t, err) + + err = ensureGooseTableWithRetry(db) + require.NoError(t, err, "failed to create goose table") + + goose.SetBaseFS(assets.EmbedMigrations) + + err = goose.Up(db, assets.DSQLMigrationDir) + require.NoError(t, err, "failed to run DSQL migrations") + + version, err := goose.GetDBVersion(db) + require.NoError(t, err) + d.version = version + + err = db.Close() + require.NoError(t, err) + + t.Cleanup(func() { + pgURI, err := dsql.PreparePostgresURI(d.GetConnectionURI(false), "") + if err != nil { + t.Logf("failed to prepare DSQL URI for cleanup: %v", err) + return + } + + db, err := goose.OpenDBWithDriver("pgx", pgURI) + if err != nil { + t.Logf("failed to connect for cleanup: %v", err) + return + } + defer db.Close() + + tables := []string{"changelog", "tuple", "assertion", "authorization_model", "store"} + for _, table := range tables { + if _, err := db.Exec("DELETE FROM " + table); err != nil { + t.Logf("failed to clean up table %s: %v", table, err) + } + } + + t.Log("DSQL test cleanup complete") + }) + + return d +} + +// GetConnectionURI returns the DSQL connection URI. +func (d *dsqlTestContainer) GetConnectionURI(includeCredentials bool) string { + return fmt.Sprintf("dsql://admin@%s/postgres?region=%s", d.clusterEndpoint, d.region) +} + +func (d *dsqlTestContainer) GetUsername() string { + return "admin" +} + +func (d *dsqlTestContainer) GetPassword() string { + return "" +} + +func (d *dsqlTestContainer) CreateSecondary(t testing.TB) error { + return fmt.Errorf("secondary datastores not supported for DSQL") +} + +func (d *dsqlTestContainer) GetSecondaryConnectionURI(includeCredentials bool) string { + return "" +} + +// ensureGooseTableWithRetry wraps dsql.EnsureGooseTable with retry logic for test resilience. +func ensureGooseTableWithRetry(db *sql.DB) error { + backoffPolicy := backoff.NewExponentialBackOff() + backoffPolicy.MaxElapsedTime = 30 * time.Second + + return backoff.Retry(func() error { + return dsql.EnsureGooseTable(db) + }, backoffPolicy) +} diff --git a/pkg/testfixtures/storage/storage.go b/pkg/testfixtures/storage/storage.go index c379b17d89..fddc9b9e5d 100644 --- a/pkg/testfixtures/storage/storage.go +++ b/pkg/testfixtures/storage/storage.go @@ -66,6 +66,8 @@ func RunDatastoreTestContainer(t testing.TB, engine string) DatastoreTestContain return memoryTestContainer{} case "sqlite": return NewSqliteTestContainer().RunSqliteTestDatabase(t) + case "dsql": + return NewDSQLTestContainer().RunDSQLTestContainer(t) default: t.Fatalf("unsupported datastore engine: %q", engine) return nil diff --git a/tests/check/check_test.go b/tests/check/check_test.go index 9c9a1d0156..ab026c56a4 100644 --- a/tests/check/check_test.go +++ b/tests/check/check_test.go @@ -51,6 +51,10 @@ func TestMatrixMysql(t *testing.T) { runMatrixWithEngine(t, "mysql") } +func TestMatrixDSQL(t *testing.T) { + runMatrixWithEngine(t, "dsql") +} + // TODO: re-enable after investigating write contention in test // func TestMatrixSqlite(t *testing.T) { // runMatrixWithEngine(t, "sqlite") @@ -89,6 +93,10 @@ func TestCheckSQLite(t *testing.T) { testRunAll(t, "sqlite", config.ExperimentalCheckOptimizations) } +func TestCheckDSQL(t *testing.T) { + testRunAll(t, "dsql") +} + // TODO move elsewhere as this isn't asserting on just Check API logs. func TestServerLogs(t *testing.T) { t.Cleanup(func() { diff --git a/tests/listobjects/listobjects_test.go b/tests/listobjects/listobjects_test.go index 19d208d7fb..badc188503 100644 --- a/tests/listobjects/listobjects_test.go +++ b/tests/listobjects/listobjects_test.go @@ -20,6 +20,10 @@ func TestMatrixPostgres(t *testing.T) { runMatrixWithEngine(t, "postgres") } +func TestMatrixDSQL(t *testing.T) { + runMatrixWithEngine(t, "dsql") +} + // TODO: re-enable // func TestMatrixMysql(t *testing.T) { // runMatrixWithEngine(t, "mysql") @@ -59,6 +63,10 @@ func TestListObjectsSQLite(t *testing.T) { testRunAll(t, "sqlite") } +func TestListObjectsDSQL(t *testing.T) { + testRunAll(t, "dsql") +} + func testRunAll(t *testing.T, engine string) { t.Cleanup(func() { // [Goroutine 60101 in state select, with github.com/go-sql-driver/mysql.(*mysqlConn).startWatcher.func1 on top of the stack: diff --git a/tests/listusers/listusers_test.go b/tests/listusers/listusers_test.go index 98015bf2be..7afc94192d 100644 --- a/tests/listusers/listusers_test.go +++ b/tests/listusers/listusers_test.go @@ -43,6 +43,10 @@ func TestListUsersSQLite(t *testing.T) { testRunAll(t, "sqlite") } +func TestListUsersDSQL(t *testing.T) { + testRunAll(t, "dsql") +} + func testRunAll(t *testing.T, engine string) { t.Cleanup(func() { // [Goroutine 60101 in state select, with github.com/go-sql-driver/mysql.(*mysqlConn).startWatcher.func1 on top of the stack: