Repository: openfga/openfga

Issue:
Make it possible to configure 2 connection strings (one for reads and one for writes)
### Checklist

- [X] I agree to the terms within the [OpenFGA Code of Conduct](https://github.com/openfga/.github/blob/main/CODE_OF_CONDUCT.md).

### Describe the problem you'd like to have solved

Hello there,

Our SRE team would love to be able to set up a read replica for our OpenFGA database. 
From our perspective, the "read" queries need to be the fastest possible and require the highest availability, more than the "write" queries (I'm talking from the APIs point of view). Basically, the usages and latency requirements are differents for reads and writes.
So we'd like to be able to configure 2 databases, one used when calling the "write" endpoints and another one used when calling the "read" endpoints (this one would be a read replica of the one used for the writes).

### Describe the ideal solution

So we'd like to be able to configure 2 connection strings, one for the writes and another one for the reads. 
Also the database settings (stuff like `OPENFGA_DATASTORE_MAX_OPEN_CONNS`) should be configurable for each database (maybe something like `OPENFGA_READ_DATASTORE_MAX_OPEN_CONNS` and `OPENFGA_WRITE_DATASTORE_MAX_OPEN_CONNS` that would fallback to `OPENFGA_DATASTORE_MAX_OPEN_CONNS` when not specified).


### Alternatives and current workarounds

_No response_

### Additional context

_No response_

Hints from issue comments:
@aaguiarz: We'll be working on this as part of our improvements our consistency options https://github.com/openfga/roadmap/issues/54.

When you query OpenFGA with higher consistency, we'll always use the write replica, and if not, the read replica.@matthisholleville: Hello @aaguiarz,

I'm the Head of SRE at Agicap, and we would like to isolate read requests to a pool of read replicas.

Would you happen to know if this feature is planned? You mentioned that it was being worked on in roadmap 54. Unless I'm mistaken, I don't see any details about this feature in the release notes ?@aaguiarz: Hi! 

Yes, we ended up not implementing it as part of that roadmap item. It is something we believe should be built but we don't have that work scheduled yet.

An alternative solution is to have 2 OpenFGA servers, one that you use for Writes and another for Reads, each of them with their own connection string.
@matthisholleville: Hello @aaguiarz. I just submitted a [PR](https://github.com/openfga/openfga/pull/2479) that adds this feature for PostgreSQL. Looking forward to your feedback. 

It’s a feature we've been eagerly waiting for, and it will help us improve our resilience and scale.

Solution patch:
diff --git a/.config-schema.json b/.config-schema.json
index 5dda9cd8..88d8de87 100644
--- a/.config-schema.json
+++ b/.config-schema.json
@@ -218,16 +218,34 @@
                     ],
                     "x-env-variable": "OPENFGA_DATASTORE_URI"
                 },
+                "secondaryUri": {
+                    "description": "The connection uri to use to connect to the secondary datastore (for postgres only).",
+                    "type": "string",
+                    "examples": [
+                        "postgres://user:pass@host:port/datastore?opts"
+                    ],
+                    "x-env-variable": "OPENFGA_DATASTORE_SECONDARY_URI"
+                },
                 "username": {
                     "description": "The connection username to connect to the datastore (overwrites any username provided in the connection uri).",
                     "type": "string",
                     "x-env-variable": "OPENFGA_DATASTORE_USERNAME"
                 },
+                "secondaryUsername": {
+                    "description": "The connection username to connect to the secondary datastore (overwrites any username provided in the connection uri).",
+                    "type": "string",
+                    "x-env-variable": "OPENFGA_DATASTORE_SECONDARY_USERNAME"
+                },
                 "password": {
                     "description": "The connection password to connect to the datastore (overwrites any password provided in the connection uri).",
                     "type": "string",
                     "x-env-variable": "OPENFGA_DATASTORE_PASSWORD"
                 },
+                "secondaryPassword": {
+                    "description": "The connection password to connect to the secondary datastore (overwrites any password provided in the connection uri).",
+                    "type": "string",
+                    "x-env-variable": "OPENFGA_DATASTORE_SECONDARY_PASSWORD"
+                },
                 "maxCacheSize": {
                     "description": "The maximum number of authorization models that will be cached in memory",
                     "type": "integer",
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 58bf4603..05057c3e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,9 @@ 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 support for separate read and write datastores for PostgreSQL. [#2479](https://github.com/openfga/openfga/pull/2479)
+
 ### Fixed
 - Reverts the base docker image back to `cgr.dev/chainguard/static` [#2473](https://github.com/openfga/openfga/issues/2473)
 
diff --git a/cmd/run/flags.go b/cmd/run/flags.go
index c786fe51..ea9cb754 100644
--- a/cmd/run/flags.go
+++ b/cmd/run/flags.go
@@ -92,6 +92,15 @@ func bindRunFlagsFunc(flags *pflag.FlagSet) func(*cobra.Command, []string) {
 		util.MustBindPFlag("datastore.uri", flags.Lookup("datastore-uri"))
 		util.MustBindEnv("datastore.uri", "OPENFGA_DATASTORE_URI")
 
+		util.MustBindPFlag("datastore.secondaryUri", flags.Lookup("datastore-secondary-uri"))
+		util.MustBindEnv("datastore.secondaryUri", "OPENFGA_DATASTORE_SECONDARY_URI")
+
+		util.MustBindPFlag("datastore.secondaryUsername", flags.Lookup("datastore-secondary-username"))
+		util.MustBindEnv("datastore.secondaryUsername", "OPENFGA_DATASTORE_SECONDARY_USERNAME")
+
+		util.MustBindPFlag("datastore.secondaryPassword", flags.Lookup("datastore-secondary-password"))
+		util.MustBindEnv("datastore.secondaryPassword", "OPENFGA_DATASTORE_SECONDARY_PASSWORD")
+
 		util.MustBindPFlag("datastore.username", flags.Lookup("datastore-username"))
 		util.MustBindEnv("datastore.username", "OPENFGA_DATASTORE_USERNAME")
 
diff --git a/cmd/run/run.go b/cmd/run/run.go
index 94457a6f..1a50681e 100644
--- a/cmd/run/run.go
+++ b/cmd/run/run.go
@@ -148,10 +148,16 @@ func NewRunCommand() *cobra.Command {
 
 	flags.String("datastore-uri", defaultConfig.Datastore.URI, "the connection uri to use to connect to the datastore (for any engine other than 'memory')")
 
+	flags.String("datastore-secondary-uri", defaultConfig.Datastore.SecondaryURI, "the connection uri to use to connect to the secondary datastore (for postgres only)")
+
 	flags.String("datastore-username", "", "the connection username to use to connect to the datastore (overwrites any username provided in the connection uri)")
 
 	flags.String("datastore-password", "", "the connection password to use to connect to the datastore (overwrites any password provided in the connection uri)")
 
+	flags.String("datastore-secondary-username", "", "the connection username to use to connect to the secondary datastore (overwrites any username provided in the connection uri)")
+
+	flags.String("datastore-secondary-password", "", "the connection password to use to connect to the secondary datastore (overwrites any password provided in the connection uri)")
+
 	flags.Int("datastore-max-cache-size", defaultConfig.Datastore.MaxCacheSize, "the maximum number of authorization models that will be cached in memory")
 
 	flags.Int("datastore-max-open-conns", defaultConfig.Datastore.MaxOpenConns, "the maximum number of open connections to the datastore")
@@ -405,8 +411,11 @@ func (s *ServerContext) datastoreConfig(config *serverconfig.Config) (storage.Op
 	// SQL Token Serializer by default
 	tokenSerializer := sqlcommon.NewSQLContinuationTokenSerializer()
 	datastoreOptions := []sqlcommon.DatastoreOption{
+		sqlcommon.WithSecondaryURI(config.Datastore.SecondaryURI),
 		sqlcommon.WithUsername(config.Datastore.Username),
 		sqlcommon.WithPassword(config.Datastore.Password),
+		sqlcommon.WithSecondaryUsername(config.Datastore.SecondaryUsername),
+		sqlcommon.WithSecondaryPassword(config.Datastore.SecondaryPassword),
 		sqlcommon.WithLogger(s.Logger),
 		sqlcommon.WithMaxTuplesPerWrite(config.MaxTuplesPerWrite),
 		sqlcommon.WithMaxTypesPerAuthorizationModel(config.MaxTypesPerAuthorizationModel),
diff --git a/cmd/util/util.go b/cmd/util/util.go
index bdba502a..6b109136 100644
--- a/cmd/util/util.go
+++ b/cmd/util/util.go
@@ -57,15 +57,17 @@ func MustBootstrapDatastore(t testing.TB, engine string) (storagefixtures.Datast
 	var ds storage.OpenFGADatastore
 	var err error
 
+	cfg := sqlcommon.NewConfig()
+
 	switch engine {
 	case "memory":
 		ds = memory.New()
 	case "postgres":
-		ds, err = postgres.New(uri, sqlcommon.NewConfig())
+		ds, err = postgres.New(uri, cfg)
 	case "mysql":
-		ds, err = mysql.New(uri, sqlcommon.NewConfig())
+		ds, err = mysql.New(uri, cfg)
 	case "sqlite":
-		ds, err = sqlite.New(uri, sqlcommon.NewConfig())
+		ds, err = sqlite.New(uri, cfg)
 	default:
 		t.Fatalf("unsupported datastore engine: %q", engine)
 	}
diff --git a/cmd/validatemodels/validate_models.go b/cmd/validatemodels/validate_models.go
index 963a9069..f1694749 100644
--- a/cmd/validatemodels/validate_models.go
+++ b/cmd/validatemodels/validate_models.go
@@ -59,13 +59,14 @@ func runValidate(_ *cobra.Command, _ []string) error {
 		db  storage.OpenFGADatastore
 		err error
 	)
+	cfg := sqlcommon.NewConfig()
 	switch engine {
 	case "mysql":
-		db, err = mysql.New(uri, sqlcommon.NewConfig())
+		db, err = mysql.New(uri, cfg)
 	case "postgres":
-		db, err = postgres.New(uri, sqlcommon.NewConfig())
+		db, err = postgres.New(uri, cfg)
 	case "sqlite":
-		db, err = sqlite.New(uri, sqlcommon.NewConfig())
+		db, err = sqlite.New(uri, cfg)
 	case "":
 		return fmt.Errorf("missing datastore engine type")
 	case "memory":
diff --git a/pkg/server/config/config.go b/pkg/server/config/config.go
index 98d301ec..0a14f02a 100644
--- a/pkg/server/config/config.go
+++ b/pkg/server/config/config.go
@@ -99,10 +99,13 @@ type DatastoreMetricsConfig struct {
 // DatastoreConfig defines OpenFGA server configurations for datastore specific settings.
 type DatastoreConfig struct {
 	// Engine is the datastore engine to use (e.g. 'memory', 'postgres', 'mysql', 'sqlite')
-	Engine   string
-	URI      string `json:"-"` // private field, won't be logged
-	Username string
-	Password string `json:"-"` // private field, won't be logged
+	Engine            string
+	URI               string `json:"-"` // private field, won't be logged
+	SecondaryURI      string `json:"-"` // private field, won't be logged
+	Username          string
+	Password          string `json:"-"` // private field, won't be logged
+	SecondaryUsername string
+	SecondaryPassword string `json:"-"` // private field, won't be logged
 
 	// MaxCacheSize is the maximum number of authorization models that will be cached in memory.
 	MaxCacheSize int
diff --git a/pkg/storage/postgres/postgres.go b/pkg/storage/postgres/postgres.go
index d624f2ce..d46dd79d 100644
--- a/pkg/storage/postgres/postgres.go
+++ b/pkg/storage/postgres/postgres.go
@@ -37,22 +37,26 @@ func startTrace(ctx context.Context, name string) (context.Context, trace.Span)
 
 // Datastore provides a PostgreSQL based implementation of [storage.OpenFGADatastore].
 type Datastore struct {
-	stbl                   sq.StatementBuilderType
-	db                     *sql.DB
-	dbInfo                 *sqlcommon.DBInfo
-	logger                 logger.Logger
-	dbStatsCollector       prometheus.Collector
-	maxTuplesPerWriteField int
-	maxTypesPerModelField  int
-	versionReady           bool
+	primaryStbl               sq.StatementBuilderType
+	secondaryStbl             sq.StatementBuilderType
+	primaryDB                 *sql.DB
+	secondaryDB               *sql.DB
+	primaryDBInfo             *sqlcommon.DBInfo
+	secondaryDBInfo           *sqlcommon.DBInfo
+	logger                    logger.Logger
+	primaryDBStatsCollector   prometheus.Collector
+	secondaryDBStatsCollector prometheus.Collector
+	maxTuplesPerWriteField    int
+	maxTypesPerModelField     int
+	versionReady              bool
 }
 
 // Ensures that Datastore implements the OpenFGADatastore interface.
 var _ storage.OpenFGADatastore = (*Datastore)(nil)
 
-// New creates a new [Datastore] storage.
-func New(uri string, cfg *sqlcommon.Config) (*Datastore, error) {
-	if cfg.Username != "" || cfg.Password != "" {
+// initDB initializes a new postgres database connection.
+func initDB(uri string, username string, password string, cfg *sqlcommon.Config) (*sql.DB, error) {
+	if username != "" || password != "" {
 		parsed, err := url.Parse(uri)
 		if err != nil {
 			return nil, fmt.Errorf("parse postgres connection uri: %w", err)
@@ -85,11 +89,7 @@ func New(uri string, cfg *sqlcommon.Config) (*Datastore, error) {
 	if err != nil {
 		return nil, fmt.Errorf("initialize postgres connection: %w", err)
 	}
-	return NewWithDB(db, cfg)
-}
 
-// NewWithDB creates a new [Datastore] storage with the provided database connection.
-func NewWithDB(db *sql.DB, cfg *sqlcommon.Config) (*Datastore, error) {
 	if cfg.MaxIdleConns != 0 {
 		db.SetMaxIdleConns(cfg.MaxIdleConns) // default is 2, not retaining connections(0) would be detrimental for performance
 	}
@@ -98,6 +98,29 @@ func NewWithDB(db *sql.DB, cfg *sqlcommon.Config) (*Datastore, error) {
 	db.SetConnMaxIdleTime(cfg.ConnMaxIdleTime)
 	db.SetConnMaxLifetime(cfg.ConnMaxLifetime)
 
+	return db, nil
+}
+
+// New creates a new [Datastore] storage.
+func New(uri string, cfg *sqlcommon.Config) (*Datastore, error) {
+	primaryDB, err := initDB(uri, cfg.Username, cfg.Password, cfg)
+	if err != nil {
+		return nil, fmt.Errorf("initialize postgres connection: %w", err)
+	}
+
+	var secondaryDB *sql.DB
+	if cfg.SecondaryURI != "" {
+		secondaryDB, err = initDB(cfg.SecondaryURI, cfg.SecondaryUsername, cfg.SecondaryPassword, cfg)
+		if err != nil {
+			return nil, fmt.Errorf("initialize postgres connection: %w", err)
+		}
+	}
+
+	return NewWithDB(primaryDB, secondaryDB, cfg)
+}
+
+func configureDB(db *sql.DB, cfg *sqlcommon.Config) (*sqlcommon.DBInfo, sq.StatementBuilderType, prometheus.Collector, error) {
+	var stbl sq.StatementBuilderType
 	policy := backoff.NewExponentialBackOff()
 	policy.MaxElapsedTime = 1 * time.Minute
 	attempt := 1
@@ -111,38 +134,94 @@ func NewWithDB(db *sql.DB, cfg *sqlcommon.Config) (*Datastore, error) {
 		return nil
 	}, policy)
 	if err != nil {
-		return nil, fmt.Errorf("ping db: %w", err)
+		return nil, stbl, nil, fmt.Errorf("ping db: %w", err)
 	}
 
 	var collector prometheus.Collector
 	if cfg.ExportMetrics {
 		collector = collectors.NewDBStatsCollector(db, "openfga")
 		if err := prometheus.Register(collector); err != nil {
-			return nil, fmt.Errorf("initialize metrics: %w", err)
+			return nil, stbl, nil, fmt.Errorf("initialize metrics: %w", err)
 		}
 	}
 
-	stbl := sq.StatementBuilder.PlaceholderFormat(sq.Dollar).RunWith(db)
+	stbl = sq.StatementBuilder.PlaceholderFormat(sq.Dollar).RunWith(db)
 	dbInfo := sqlcommon.NewDBInfo(db, stbl, HandleSQLError, "postgres")
 
+	return dbInfo, stbl, collector, nil
+}
+
+// NewWithDB creates a new [Datastore] storage with the provided database connection.
+func NewWithDB(primaryDB, secondaryDB *sql.DB, cfg *sqlcommon.Config) (*Datastore, error) {
+	primaryDBInfo, primaryStbl, primaryCollector, err := configureDB(primaryDB, cfg)
+	if err != nil {
+		return nil, fmt.Errorf("configure primary db: %w", err)
+	}
+
+	var secondaryDBInfo *sqlcommon.DBInfo
+	var secondaryStbl sq.StatementBuilderType
+	var secondaryCollector prometheus.Collector
+	if secondaryDB != nil {
+		secondaryDBInfo, secondaryStbl, secondaryCollector, err = configureDB(secondaryDB, cfg)
+		if err != nil {
+			return nil, fmt.Errorf("configure secondary db: %w", err)
+		}
+	}
+
 	return &Datastore{
-		stbl:                   stbl,
-		db:                     db,
-		dbInfo:                 dbInfo,
-		logger:                 cfg.Logger,
-		dbStatsCollector:       collector,
-		maxTuplesPerWriteField: cfg.MaxTuplesPerWriteField,
-		maxTypesPerModelField:  cfg.MaxTypesPerModelField,
-		versionReady:           false,
+		primaryStbl:               primaryStbl,
+		secondaryStbl:             secondaryStbl,
+		primaryDB:                 primaryDB,
+		secondaryDB:               secondaryDB,
+		primaryDBInfo:             primaryDBInfo,
+		secondaryDBInfo:           secondaryDBInfo,
+		logger:                    cfg.Logger,
+		primaryDBStatsCollector:   primaryCollector,
+		secondaryDBStatsCollector: secondaryCollector,
+		maxTuplesPerWriteField:    cfg.MaxTuplesPerWriteField,
+		maxTypesPerModelField:     cfg.MaxTypesPerModelField,
+		versionReady:              false,
 	}, nil
 }
 
+func (s *Datastore) isSecondaryConfigured() bool {
+	return s.secondaryDB != nil
+}
+
 // Close see [storage.OpenFGADatastore].Close.
 func (s *Datastore) Close() {
-	if s.dbStatsCollector != nil {
-		prometheus.Unregister(s.dbStatsCollector)
+	if s.primaryDBStatsCollector != nil {
+		prometheus.Unregister(s.primaryDBStatsCollector)
+	}
+	s.primaryDB.Close()
+	if s.isSecondaryConfigured() {
+		if s.secondaryDBStatsCollector != nil {
+			prometheus.Unregister(s.secondaryDBStatsCollector)
+		}
+		s.secondaryDB.Close()
+	}
+}
+
+// getReadDBInfo returns the appropriate database info based on consistency options.
+func (s *Datastore) getReadDBInfo() *sqlcommon.DBInfo {
+	if s.isSecondaryConfigured() {
+		return s.secondaryDBInfo
+	}
+	return s.primaryDBInfo
+}
+
+// getReadStbl returns the appropriate statement builder based on consistency options.
+func (s *Datastore) getReadStbl(consistency *openfgav1.ConsistencyPreference) sq.StatementBuilderType {
+	if consistency != nil && *consistency == openfgav1.ConsistencyPreference_HIGHER_CONSISTENCY {
+		// If we are using higher consistency, we need to use the write database.
+		return s.primaryStbl
+	}
+	if s.isSecondaryConfigured() {
+		// If we are using lower consistency, we can use the read database.
+		return s.secondaryStbl
 	}
-	s.db.Close()
+	// If we are not using a secondary database, we can only use the primary database.
+	return s.primaryStbl
 }
 
 // Read see [storage.RelationshipTupleReader].Read.
@@ -150,12 +229,13 @@ func (s *Datastore) Read(
 	ctx context.Context,
 	store string,
 	tupleKey *openfgav1.TupleKey,
-	_ storage.ReadOptions,
+	options storage.ReadOptions,
 ) (storage.TupleIterator, error) {
 	ctx, span := startTrace(ctx, "Read")
 	defer span.End()
 
-	return s.read(ctx, store, tupleKey, nil)
+	readStbl := s.getReadStbl(&options.Consistency.Preference)
+	return s.read(ctx, store, tupleKey, nil, readStbl)
 }
 
 // ReadPage see [storage.RelationshipTupleReader].ReadPage.
@@ -163,7 +243,8 @@ func (s *Datastore) ReadPage(ctx context.Context, store string, tupleKey *openfg
 	ctx, span := startTrace(ctx, "ReadPage")
 	defer span.End()
 
-	iter, err := s.read(ctx, store, tupleKey, &options)
+	readStbl := s.getReadStbl(&options.Consistency.Preference)
+	iter, err := s.read(ctx, store, tupleKey, &options, readStbl)
 	if err != nil {
 		return nil, "", err
 	}
@@ -172,11 +253,11 @@ func (s *Datastore) ReadPage(ctx context.Context, store string, tupleKey *openfg
 	return iter.ToArray(ctx, options.Pagination)
 }
 
-func (s *Datastore) read(ctx context.Context, store string, tupleKey *openfgav1.TupleKey, options *storage.ReadPageOptions) (*sqlcommon.SQLTupleIterator, error) {
+func (s *Datastore) read(ctx context.Context, store string, tupleKey *openfgav1.TupleKey, options *storage.ReadPageOptions, readStbl sq.StatementBuilderType) (*sqlcommon.SQLTupleIterator, error) {
 	_, span := startTrace(ctx, "read")
 	defer span.End()
 
-	sb := s.stbl.
+	sb := readStbl.
 		Select(
 			"store", "object_type", "object_id", "relation",
 			"_user",
@@ -222,14 +303,15 @@ func (s *Datastore) Write(
 	ctx, span := startTrace(ctx, "Write")
 	defer span.End()
 
-	return sqlcommon.Write(ctx, s.dbInfo, store, deletes, writes, time.Now().UTC())
+	return sqlcommon.Write(ctx, s.primaryDBInfo, store, deletes, writes, time.Now().UTC())
 }
 
 // ReadUserTuple see [storage.RelationshipTupleReader].ReadUserTuple.
-func (s *Datastore) ReadUserTuple(ctx context.Context, store string, tupleKey *openfgav1.TupleKey, _ storage.ReadUserTupleOptions) (*openfgav1.Tuple, error) {
+func (s *Datastore) ReadUserTuple(ctx context.Context, store string, tupleKey *openfgav1.TupleKey, options storage.ReadUserTupleOptions) (*openfgav1.Tuple, error) {
 	ctx, span := startTrace(ctx, "ReadUserTuple")
 	defer span.End()
 
+	readStbl := s.getReadStbl(&options.Consistency.Preference)
 	objectType, objectID := tupleUtils.SplitObject(tupleKey.GetObject())
 	userType := tupleUtils.GetUserTypeFromUser(tupleKey.GetUser())
 
@@ -237,7 +319,7 @@ func (s *Datastore) ReadUserTuple(ctx context.Context, store string, tupleKey *o
 	var conditionContext []byte
 	var record storage.TupleRecord
 
-	err := s.stbl.
+	err := readStbl.
 		Select(
 			"object_type", "object_id", "relation",
 			"_user",
@@ -285,12 +367,13 @@ func (s *Datastore) ReadUsersetTuples(
 	ctx context.Context,
 	store string,
 	filter storage.ReadUsersetTuplesFilter,
-	_ storage.ReadUsersetTuplesOptions,
+	options storage.ReadUsersetTuplesOptions,
 ) (storage.TupleIterator, error) {
 	_, span := startTrace(ctx, "ReadUsersetTuples")
 	defer span.End()
 
-	sb := s.stbl.
+	readStbl := s.getReadStbl(&options.Consistency.Preference)
+	sb := readStbl.
 		Select(
 			"store", "object_type", "object_id", "relation",
 			"_user",
@@ -335,11 +418,12 @@ func (s *Datastore) ReadStartingWithUser(
 	ctx context.Context,
 	store string,
 	filter storage.ReadStartingWithUserFilter,
-	_ storage.ReadStartingWithUserOptions,
+	options storage.ReadStartingWithUserOptions,
 ) (storage.TupleIterator, error) {
 	_, span := startTrace(ctx, "ReadStartingWithUser")
 	defer span.End()
 
+	readStbl := s.getReadStbl(&options.Consistency.Preference)
 	var targetUsersArg []string
 	for _, u := range filter.UserFilter {
 		targetUser := u.GetObject()
@@ -349,7 +433,7 @@ func (s *Datastore) ReadStartingWithUser(
 		targetUsersArg = append(targetUsersArg, targetUser)
 	}
 
-	builder := s.stbl.
+	builder := readStbl.
 		Select(
 			"store", "object_type", "object_id", "relation",
 			"_user",
@@ -380,7 +464,7 @@ func (s *Datastore) ReadAuthorizationModel(ctx context.Context, store string, mo
 	ctx, span := startTrace(ctx, "ReadAuthorizationModel")
 	defer span.End()
 
-	return sqlcommon.ReadAuthorizationModel(ctx, s.dbInfo, store, modelID)
+	return sqlcommon.ReadAuthorizationModel(ctx, s.getReadDBInfo(), store, modelID)
 }
 
 // ReadAuthorizationModels see [storage.AuthorizationModelReadBackend].ReadAuthorizationModels.
@@ -388,7 +472,7 @@ func (s *Datastore) ReadAuthorizationModels(ctx context.Context, store string, o
 	ctx, span := startTrace(ctx, "ReadAuthorizationModels")
 	defer span.End()
 
-	sb := s.stbl.
+	sb := s.getReadStbl(nil).
 		Select("authorization_model_id").
 		Distinct().
 		From("authorization_model").
@@ -451,7 +535,7 @@ func (s *Datastore) FindLatestAuthorizationModel(ctx context.Context, store stri
 	ctx, span := startTrace(ctx, "FindLatestAuthorizationModel")
 	defer span.End()
 
-	return sqlcommon.FindLatestAuthorizationModel(ctx, s.dbInfo, store)
+	return sqlcommon.FindLatestAuthorizationModel(ctx, s.getReadDBInfo(), store)
 }
 
 // MaxTypesPerAuthorizationModel see [storage.TypeDefinitionWriteBackend].MaxTypesPerAuthorizationModel.
@@ -464,7 +548,7 @@ func (s *Datastore) WriteAuthorizationModel(ctx context.Context, store string, m
 	ctx, span := startTrace(ctx, "WriteAuthorizationModel")
 	defer span.End()
 
-	return sqlcommon.WriteAuthorizationModel(ctx, s.dbInfo, store, model)
+	return sqlcommon.WriteAuthorizationModel(ctx, s.primaryDBInfo, store, model)
 }
 
 // CreateStore adds a new store to storage.
@@ -475,7 +559,7 @@ func (s *Datastore) CreateStore(ctx context.Context, store *openfgav1.Store) (*o
 	var id, name string
 	var createdAt, updatedAt time.Time
 
-	err := s.stbl.
+	err := s.primaryStbl.
 		Insert("store").
 		Columns("id", "name", "created_at", "updated_at").
 		Values(store.GetId(), store.GetName(), sq.Expr("NOW()"), sq.Expr("NOW()")).
@@ -499,7 +583,7 @@ func (s *Datastore) GetStore(ctx context.Context, id string) (*openfgav1.Store,
 	ctx, span := startTrace(ctx, "GetStore")
 	defer span.End()
 
-	row := s.stbl.
+	row := s.getReadStbl(nil).
 		Select("id", "name", "created_at", "updated_at").
 		From("store").
 		Where(sq.Eq{
@@ -547,7 +631,7 @@ func (s *Datastore) ListStores(ctx context.Context, options storage.ListStoresOp
 		whereClause = append(whereClause, sq.GtOrEq{"id": options.Pagination.From})
 	}
 
-	sb := s.stbl.
+	sb := s.getReadStbl(nil).
 		Select("id", "name", "created_at", "updated_at").
 		From("store").
 		Where(whereClause).
@@ -597,7 +681,7 @@ func (s *Datastore) DeleteStore(ctx context.Context, id string) error {
 	ctx, span := startTrace(ctx, "DeleteStore")
 	defer span.End()
 
-	_, err := s.stbl.
+	_, err := s.primaryStbl.
 		Update("store").
 		Set("deleted_at", sq.Expr("NOW()")).
 		Where(sq.Eq{"id": id}).
@@ -619,7 +703,7 @@ func (s *Datastore) WriteAssertions(ctx context.Context, store, modelID string,
 		return err
 	}
 
-	_, err = s.stbl.
+	_, err = s.primaryStbl.
 		Insert("assertion").
 		Columns("store", "authorization_model_id", "assertions").
 		Values(store, modelID, marshalledAssertions).
@@ -638,7 +722,7 @@ func (s *Datastore) ReadAssertions(ctx context.Context, store, modelID string) (
 	defer span.End()
 
 	var marshalledAssertions []byte
-	err := s.stbl.
+	err := s.getReadStbl(nil).
 		Select("assertions").
 		From("assertion").
 		Where(sq.Eq{
@@ -676,7 +760,7 @@ func (s *Datastore) ReadChanges(ctx context.Context, store string, filter storag
 		orderBy = "ulid desc"
 	}
 
-	sb := s.stbl.
+	sb := s.getReadStbl(nil).
 		Select(
 			"ulid", "object_type", "object_id", "relation",
 			"_user",
@@ -761,12 +845,40 @@ func (s *Datastore) ReadChanges(ctx context.Context, store string, filter storag
 
 // IsReady see [sqlcommon.IsReady].
 func (s *Datastore) IsReady(ctx context.Context) (storage.ReadinessStatus, error) {
-	versionReady, err := sqlcommon.IsReady(ctx, s.versionReady, s.db)
+	primaryStatus, err := sqlcommon.IsReady(ctx, s.versionReady, s.primaryDB)
 	if err != nil {
-		return versionReady, err
+		return primaryStatus, err
 	}
-	s.versionReady = versionReady.IsReady
-	return versionReady, nil
+
+	// if secondary is not configured, return primary status only
+	if !s.isSecondaryConfigured() {
+		s.versionReady = primaryStatus.IsReady
+		return primaryStatus, nil
+	}
+
+	if primaryStatus.IsReady && primaryStatus.Message == "" {
+		primaryStatus.Message = "ready"
+	}
+
+	// check if secondary is ready
+	secondaryStatus, err := sqlcommon.IsReady(ctx, s.versionReady, s.secondaryDB)
+	if err != nil {
+		secondaryStatus.Message = err.Error()
+		secondaryStatus.IsReady = false
+	}
+
+	if secondaryStatus.IsReady && secondaryStatus.Message == "" {
+		secondaryStatus.Message = "ready"
+	}
+
+	multipleReadyStatus := storage.ReadinessStatus{}
+	messageTpl := "primary: %s, secondary: %s"
+	multipleReadyStatus.IsReady = primaryStatus.IsReady && secondaryStatus.IsReady
+	multipleReadyStatus.Message = fmt.Sprintf(messageTpl, primaryStatus.Message, secondaryStatus.Message)
+
+	s.versionReady = multipleReadyStatus.IsReady
+
+	return multipleReadyStatus, nil
 }
 
 // HandleSQLError processes an SQL error and converts it into a more
diff --git a/pkg/storage/sqlcommon/sqlcommon.go b/pkg/storage/sqlcommon/sqlcommon.go
index ec5a986b..3a8321e0 100644
--- a/pkg/storage/sqlcommon/sqlcommon.go
+++ b/pkg/storage/sqlcommon/sqlcommon.go
@@ -31,8 +31,11 @@ var tracer = otel.Tracer("pkg/storage/sqlcommon")
 // Config defines the configuration parameters
 // for setting up and managing a sql connection.
 type Config struct {
+	SecondaryURI           string
 	Username               string
 	Password               string
+	SecondaryUsername      string
+	SecondaryPassword      string
 	Logger                 logger.Logger
 	MaxTuplesPerWriteField int
 	MaxTypesPerModelField  int
@@ -49,6 +52,13 @@ type Config struct {
 // used for configuring a Config object.
 type DatastoreOption func(*Config)
 
+// WithSecondaryURI returns a DatastoreOption that sets the secondary URI in the Config.
+func WithSecondaryURI(uri string) DatastoreOption {
+	return func(config *Config) {
+		config.SecondaryURI = uri
+	}
+}
+
 // WithUsername returns a DatastoreOption that sets the username in the Config.
 func WithUsername(username string) DatastoreOption {
 	return func(config *Config) {
@@ -63,6 +73,20 @@ func WithPassword(password string) DatastoreOption {
 	}
 }
 
+// WithSecondaryUsername returns a DatastoreOption that sets the secondary username in the Config.
+func WithSecondaryUsername(username string) DatastoreOption {
+	return func(config *Config) {
+		config.SecondaryUsername = username
+	}
+}
+
+// WithSecondaryPassword returns a DatastoreOption that sets the secondary password in the Config.
+func WithSecondaryPassword(password string) DatastoreOption {
+	return func(config *Config) {
+		config.SecondaryPassword = password
+	}
+}
+
 // WithLogger returns a DatastoreOption that sets the Logger in the Config.
 func WithLogger(l logger.Logger) DatastoreOption {
 	return func(cfg *Config) {



Test patch:
diff --git a/pkg/server/server_test.go b/pkg/server/server_test.go
index 587ea66e..a3a10711 100644
--- a/pkg/server/server_test.go
+++ b/pkg/server/server_test.go
@@ -838,13 +838,18 @@ func BenchmarkOpenFGAServer(b *testing.B) {
 			// https://github.com/uber-go/goleak/discussions/89
 			goleak.IgnoreTopFunction("testing.(*B).run1"),
 			goleak.IgnoreTopFunction("testing.(*B).doBench"),
+			// Ignore CPU profiler goroutine when running benchmarks with -cpuprofile
+			goleak.IgnoreAnyFunction("runtime/pprof.profileWriter"),
 		)
 	})
 	b.Run("BenchmarkPostgresDatastore", func(b *testing.B) {
 		testDatastore := storagefixtures.RunDatastoreTestContainer(b, "postgres")
 
 		uri := testDatastore.GetConnectionURI(true)
-		ds, err := postgres.New(uri, sqlcommon.NewConfig(sqlcommon.WithMaxOpenConns(10)))
+		ds, err := postgres.New(uri, sqlcommon.NewConfig(
+			sqlcommon.WithMaxOpenConns(10),
+			sqlcommon.WithMaxIdleConns(10),
+		))
 		require.NoError(b, err)
 		b.Cleanup(ds.Close)
 		test.RunAllBenchmarks(b, ds)
@@ -860,7 +865,10 @@ func BenchmarkOpenFGAServer(b *testing.B) {
 		testDatastore := storagefixtures.RunDatastoreTestContainer(b, "mysql")
 
 		uri := testDatastore.GetConnectionURI(true)
-		ds, err := mysql.New(uri, sqlcommon.NewConfig(sqlcommon.WithMaxOpenConns(10)))
+		ds, err := mysql.New(uri, sqlcommon.NewConfig(
+			sqlcommon.WithMaxOpenConns(10),
+			sqlcommon.WithMaxIdleConns(10),
+		))
 		require.NoError(b, err)
 		b.Cleanup(ds.Close)
 		test.RunAllBenchmarks(b, ds)
diff --git a/pkg/storage/mysql/mysql_test.go b/pkg/storage/mysql/mysql_test.go
index 58da3592..e4cdd2a3 100644
--- a/pkg/storage/mysql/mysql_test.go
+++ b/pkg/storage/mysql/mysql_test.go
@@ -28,7 +28,8 @@ func TestMySQLDatastore(t *testing.T) {
 	testDatastore := storagefixtures.RunDatastoreTestContainer(t, "mysql")
 
 	uri := testDatastore.GetConnectionURI(true)
-	ds, err := New(uri, sqlcommon.NewConfig())
+	cfg := sqlcommon.NewConfig()
+	ds, err := New(uri, cfg)
 	require.NoError(t, err)
 	defer ds.Close()
 
@@ -39,7 +40,8 @@ func TestMySQLDatastoreAfterCloseIsNotReady(t *testing.T) {
 	testDatastore := storagefixtures.RunDatastoreTestContainer(t, "mysql")
 
 	uri := testDatastore.GetConnectionURI(true)
-	ds, err := New(uri, sqlcommon.NewConfig())
+	cfg := sqlcommon.NewConfig()
+	ds, err := New(uri, cfg)
 	require.NoError(t, err)
 	ds.Close()
 	status, err := ds.IsReady(context.Background())
@@ -67,7 +69,8 @@ func TestReadEnsureNoOrder(t *testing.T) {
 			testDatastore := storagefixtures.RunDatastoreTestContainer(t, "mysql")
 
 			uri := testDatastore.GetConnectionURI(true)
-			ds, err := New(uri, sqlcommon.NewConfig())
+			cfg := sqlcommon.NewConfig()
+			ds, err := New(uri, cfg)
 			require.NoError(t, err)
 			defer ds.Close()
 
@@ -168,7 +171,8 @@ func TestCtxCancel(t *testing.T) {
 			testDatastore := storagefixtures.RunDatastoreTestContainer(t, "mysql")
 
 			uri := testDatastore.GetConnectionURI(true)
-			ds, err := New(uri, sqlcommon.NewConfig())
+			cfg := sqlcommon.NewConfig()
+			ds, err := New(uri, cfg)
 			require.NoError(t, err)
 			defer ds.Close()
 
@@ -230,7 +234,8 @@ func TestReadPageEnsureOrder(t *testing.T) {
 	testDatastore := storagefixtures.RunDatastoreTestContainer(t, "mysql")
 
 	uri := testDatastore.GetConnectionURI(true)
-	ds, err := New(uri, sqlcommon.NewConfig())
+	cfg := sqlcommon.NewConfig()
+	ds, err := New(uri, cfg)
 	require.NoError(t, err)
 	defer ds.Close()
 
@@ -276,7 +281,8 @@ func TestReadAuthorizationModelUnmarshallError(t *testing.T) {
 	testDatastore := storagefixtures.RunDatastoreTestContainer(t, "mysql")
 
 	uri := testDatastore.GetConnectionURI(true)
-	ds, err := New(uri, sqlcommon.NewConfig())
+	cfg := sqlcommon.NewConfig()
+	ds, err := New(uri, cfg)
 	require.NoError(t, err)
 
 	ctx := context.Background()
@@ -301,7 +307,8 @@ func TestReadAuthorizationModelReturnValue(t *testing.T) {
 	testDatastore := storagefixtures.RunDatastoreTestContainer(t, "mysql")
 
 	uri := testDatastore.GetConnectionURI(true)
-	ds, err := New(uri, sqlcommon.NewConfig())
+	cfg := sqlcommon.NewConfig()
+	ds, err := New(uri, cfg)
 	require.NoError(t, err)
 
 	ctx := context.Background()
@@ -328,7 +335,8 @@ func TestFindLatestModel(t *testing.T) {
 	testDatastore := storagefixtures.RunDatastoreTestContainer(t, "mysql")
 
 	uri := testDatastore.GetConnectionURI(true)
-	ds, err := New(uri, sqlcommon.NewConfig())
+	cfg := sqlcommon.NewConfig()
+	ds, err := New(uri, cfg)
 	require.NoError(t, err)
 	defer ds.Close()
 
@@ -409,7 +417,8 @@ func TestAllowNullCondition(t *testing.T) {
 	testDatastore := storagefixtures.RunDatastoreTestContainer(t, "mysql")
 
 	uri := testDatastore.GetConnectionURI(true)
-	ds, err := New(uri, sqlcommon.NewConfig())
+	cfg := sqlcommon.NewConfig()
+	ds, err := New(uri, cfg)
 	require.NoError(t, err)
 	defer ds.Close()
 
@@ -511,7 +520,8 @@ func TestMarshalledAssertions(t *testing.T) {
 	testDatastore := storagefixtures.RunDatastoreTestContainer(t, "mysql")
 
 	uri := testDatastore.GetConnectionURI(true)
-	ds, err := New(uri, sqlcommon.NewConfig())
+	cfg := sqlcommon.NewConfig()
+	ds, err := New(uri, cfg)
 	require.NoError(t, err)
 	defer ds.Close()
 
@@ -595,7 +605,8 @@ func TestNew(t *testing.T) {
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			got, err := New(tt.args.uri, tt.args.cfg)
+			cfg := sqlcommon.NewConfig()
+			got, err := New(tt.args.uri, cfg)
 			if got != nil {
 				defer got.Close()
 			}
diff --git a/pkg/storage/postgres/postgres_test.go b/pkg/storage/postgres/postgres_test.go
index 888280a8..bdd65d53 100644
--- a/pkg/storage/postgres/postgres_test.go
+++ b/pkg/storage/postgres/postgres_test.go
@@ -26,17 +26,38 @@ func TestPostgresDatastore(t *testing.T) {
 	testDatastore := storagefixtures.RunDatastoreTestContainer(t, "postgres")
 
 	uri := testDatastore.GetConnectionURI(true)
-	ds, err := New(uri, sqlcommon.NewConfig())
+	cfg := sqlcommon.NewConfig()
+	ds, err := New(uri, cfg)
 	require.NoError(t, err)
 	defer ds.Close()
 	test.RunAllTests(t, ds)
 }
 
+func TestPostgresDatastoreStatusWithSecondaryDB(t *testing.T) {
+	primaryDatastore := storagefixtures.RunDatastoreTestContainer(t, "postgres")
+	primaryDatastore.CreateSecondary(t)
+
+	primaryURI := primaryDatastore.GetConnectionURI(true)
+
+	cfg := sqlcommon.NewConfig()
+	cfg.SecondaryURI = primaryDatastore.GetSecondaryConnectionURI(true)
+
+	ds, err := New(primaryURI, cfg)
+	require.NoError(t, err)
+	defer ds.Close()
+
+	status, err := ds.IsReady(context.Background())
+	require.NoError(t, err)
+	require.True(t, status.IsReady)
+	require.Equal(t, "primary: ready, secondary: ready", status.Message)
+}
+
 func TestPostgresDatastoreAfterCloseIsNotReady(t *testing.T) {
 	testDatastore := storagefixtures.RunDatastoreTestContainer(t, "postgres")
 
 	uri := testDatastore.GetConnectionURI(true)
-	ds, err := New(uri, sqlcommon.NewConfig())
+	cfg := sqlcommon.NewConfig()
+	ds, err := New(uri, cfg)
 	require.NoError(t, err)
 	ds.Close()
 	status, err := ds.IsReady(context.Background())
@@ -64,7 +85,8 @@ func TestReadEnsureNoOrder(t *testing.T) {
 			testDatastore := storagefixtures.RunDatastoreTestContainer(t, "postgres")
 
 			uri := testDatastore.GetConnectionURI(true)
-			ds, err := New(uri, sqlcommon.NewConfig())
+			cfg := sqlcommon.NewConfig()
+			ds, err := New(uri, cfg)
 			require.NoError(t, err)
 			defer ds.Close()
 
@@ -76,7 +98,7 @@ func TestReadEnsureNoOrder(t *testing.T) {
 			thirdTuple := tuple.NewTupleKey("doc:object_id_3", "relation", "user:user_3")
 
 			err = sqlcommon.Write(ctx,
-				sqlcommon.NewDBInfo(ds.db, ds.stbl, HandleSQLError, "postgres"),
+				sqlcommon.NewDBInfo(ds.primaryDB, ds.primaryStbl, HandleSQLError, "postgres"),
 				store,
 				[]*openfgav1.TupleKeyWithoutCondition{},
 				[]*openfgav1.TupleKey{firstTuple},
@@ -85,7 +107,7 @@ func TestReadEnsureNoOrder(t *testing.T) {
 
 			// Tweak time so that ULID is smaller.
 			err = sqlcommon.Write(ctx,
-				sqlcommon.NewDBInfo(ds.db, ds.stbl, HandleSQLError, "postgres"),
+				sqlcommon.NewDBInfo(ds.primaryDB, ds.primaryStbl, HandleSQLError, "postgres"),
 				store,
 				[]*openfgav1.TupleKeyWithoutCondition{},
 				[]*openfgav1.TupleKey{secondTuple},
@@ -93,7 +115,7 @@ func TestReadEnsureNoOrder(t *testing.T) {
 			require.NoError(t, err)
 
 			err = sqlcommon.Write(ctx,
-				sqlcommon.NewDBInfo(ds.db, ds.stbl, HandleSQLError, "postgres"),
+				sqlcommon.NewDBInfo(ds.primaryDB, ds.primaryStbl, HandleSQLError, "postgres"),
 				store,
 				[]*openfgav1.TupleKeyWithoutCondition{},
 				[]*openfgav1.TupleKey{thirdTuple},
@@ -165,7 +187,8 @@ func TestCtxCancel(t *testing.T) {
 			testDatastore := storagefixtures.RunDatastoreTestContainer(t, "postgres")
 
 			uri := testDatastore.GetConnectionURI(true)
-			ds, err := New(uri, sqlcommon.NewConfig())
+			cfg := sqlcommon.NewConfig()
+			ds, err := New(uri, cfg)
 			require.NoError(t, err)
 			defer ds.Close()
 
@@ -177,7 +200,7 @@ func TestCtxCancel(t *testing.T) {
 			thirdTuple := tuple.NewTupleKey("doc:object_id_3", "relation", "user:user_3")
 
 			err = sqlcommon.Write(ctx,
-				sqlcommon.NewDBInfo(ds.db, ds.stbl, HandleSQLError, "postgres"),
+				sqlcommon.NewDBInfo(ds.primaryDB, ds.primaryStbl, HandleSQLError, "postgres"),
 				store,
 				[]*openfgav1.TupleKeyWithoutCondition{},
 				[]*openfgav1.TupleKey{firstTuple},
@@ -186,7 +209,7 @@ func TestCtxCancel(t *testing.T) {
 
 			// Tweak time so that ULID is smaller.
 			err = sqlcommon.Write(ctx,
-				sqlcommon.NewDBInfo(ds.db, ds.stbl, HandleSQLError, "postgres"),
+				sqlcommon.NewDBInfo(ds.primaryDB, ds.primaryStbl, HandleSQLError, "postgres"),
 				store,
 				[]*openfgav1.TupleKeyWithoutCondition{},
 				[]*openfgav1.TupleKey{secondTuple},
@@ -194,7 +217,7 @@ func TestCtxCancel(t *testing.T) {
 			require.NoError(t, err)
 
 			err = sqlcommon.Write(ctx,
-				sqlcommon.NewDBInfo(ds.db, ds.stbl, HandleSQLError, "postgres"),
+				sqlcommon.NewDBInfo(ds.primaryDB, ds.primaryStbl, HandleSQLError, "postgres"),
 				store,
 				[]*openfgav1.TupleKeyWithoutCondition{},
 				[]*openfgav1.TupleKey{thirdTuple},
@@ -225,7 +248,8 @@ func TestReadPageEnsureOrder(t *testing.T) {
 	testDatastore := storagefixtures.RunDatastoreTestContainer(t, "postgres")
 
 	uri := testDatastore.GetConnectionURI(true)
-	ds, err := New(uri, sqlcommon.NewConfig())
+	cfg := sqlcommon.NewConfig()
+	ds, err := New(uri, cfg)
 	require.NoError(t, err)
 	defer ds.Close()
 
@@ -236,7 +260,7 @@ func TestReadPageEnsureOrder(t *testing.T) {
 	secondTuple := tuple.NewTupleKey("doc:object_id_2", "relation", "user:user_2")
 
 	err = sqlcommon.Write(ctx,
-		sqlcommon.NewDBInfo(ds.db, ds.stbl, HandleSQLError, "postgres"),
+		sqlcommon.NewDBInfo(ds.primaryDB, ds.primaryStbl, HandleSQLError, "postgres"),
 		store,
 		[]*openfgav1.TupleKeyWithoutCondition{},
 		[]*openfgav1.TupleKey{firstTuple},
@@ -245,7 +269,7 @@ func TestReadPageEnsureOrder(t *testing.T) {
 
 	// Tweak time so that ULID is smaller.
 	err = sqlcommon.Write(ctx,
-		sqlcommon.NewDBInfo(ds.db, ds.stbl, HandleSQLError, "postgres"),
+		sqlcommon.NewDBInfo(ds.primaryDB, ds.primaryStbl, HandleSQLError, "postgres"),
 		store,
 		[]*openfgav1.TupleKeyWithoutCondition{},
 		[]*openfgav1.TupleKey{secondTuple},
@@ -271,7 +295,8 @@ func TestReadAuthorizationModelUnmarshallError(t *testing.T) {
 	testDatastore := storagefixtures.RunDatastoreTestContainer(t, "postgres")
 
 	uri := testDatastore.GetConnectionURI(true)
-	ds, err := New(uri, sqlcommon.NewConfig())
+	cfg := sqlcommon.NewConfig()
+	ds, err := New(uri, cfg)
 	require.NoError(t, err)
 
 	ctx := context.Background()
@@ -284,7 +309,7 @@ func TestReadAuthorizationModelUnmarshallError(t *testing.T) {
 	require.NoError(t, err)
 	pbdata := []byte{0x01, 0x02, 0x03}
 
-	_, err = ds.db.ExecContext(ctx, "INSERT INTO authorization_model (store, authorization_model_id, schema_version, type, type_definition, serialized_protobuf) VALUES ($1, $2, $3, $4, $5, $6)", store, modelID, schemaVersion, "document", bytes, pbdata)
+	_, err = ds.primaryDB.ExecContext(ctx, "INSERT INTO authorization_model (store, authorization_model_id, schema_version, type, type_definition, serialized_protobuf) VALUES ($1, $2, $3, $4, $5, $6)", store, modelID, schemaVersion, "document", bytes, pbdata)
 	require.NoError(t, err)
 
 	_, err = ds.ReadAuthorizationModel(ctx, store, modelID)
@@ -296,7 +321,8 @@ func TestReadAuthorizationModelReturnValue(t *testing.T) {
 	testDatastore := storagefixtures.RunDatastoreTestContainer(t, "postgres")
 
 	uri := testDatastore.GetConnectionURI(true)
-	ds, err := New(uri, sqlcommon.NewConfig())
+	cfg := sqlcommon.NewConfig()
+	ds, err := New(uri, cfg)
 	require.NoError(t, err)
 
 	ctx := context.Background()
@@ -308,7 +334,7 @@ func TestReadAuthorizationModelReturnValue(t *testing.T) {
 	bytes, err := proto.Marshal(&openfgav1.TypeDefinition{Type: "document"})
 	require.NoError(t, err)
 
-	_, err = ds.db.ExecContext(ctx, "INSERT INTO authorization_model (store, authorization_model_id, schema_version, type, type_definition, serialized_protobuf) VALUES ($1, $2, $3, $4, $5, $6)", store, modelID, schemaVersion, "document", bytes, nil)
+	_, err = ds.primaryDB.ExecContext(ctx, "INSERT INTO authorization_model (store, authorization_model_id, schema_version, type, type_definition, serialized_protobuf) VALUES ($1, $2, $3, $4, $5, $6)", store, modelID, schemaVersion, "document", bytes, nil)
 
 	require.NoError(t, err)
 
@@ -324,7 +350,8 @@ func TestFindLatestModel(t *testing.T) {
 	testDatastore := storagefixtures.RunDatastoreTestContainer(t, "postgres")
 
 	uri := testDatastore.GetConnectionURI(true)
-	ds, err := New(uri, sqlcommon.NewConfig())
+	cfg := sqlcommon.NewConfig()
+	ds, err := New(uri, cfg)
 	require.NoError(t, err)
 	defer ds.Close()
 
@@ -349,14 +376,14 @@ func TestFindLatestModel(t *testing.T) {
 		// write type "document"
 		bytesDocumentType, err := proto.Marshal(&openfgav1.TypeDefinition{Type: "document"})
 		require.NoError(t, err)
-		_, err = ds.db.ExecContext(ctx, "INSERT INTO authorization_model (store, authorization_model_id, schema_version, type, type_definition, serialized_protobuf) VALUES ($1, $2, $3, $4, $5, $6)",
+		_, err = ds.primaryDB.ExecContext(ctx, "INSERT INTO authorization_model (store, authorization_model_id, schema_version, type, type_definition, serialized_protobuf) VALUES ($1, $2, $3, $4, $5, $6)",
 			store, modelID, schemaVersion, "document", bytesDocumentType, nil)
 		require.NoError(t, err)
 
 		// write type "user"
 		bytesUserType, err := proto.Marshal(&openfgav1.TypeDefinition{Type: "user"})
 		require.NoError(t, err)
-		_, err = ds.db.ExecContext(ctx, "INSERT INTO authorization_model (store, authorization_model_id, schema_version, type, type_definition, serialized_protobuf) VALUES ($1, $2, $3, $4, $5, $6)",
+		_, err = ds.primaryDB.ExecContext(ctx, "INSERT INTO authorization_model (store, authorization_model_id, schema_version, type, type_definition, serialized_protobuf) VALUES ($1, $2, $3, $4, $5, $6)",
 			store, modelID, schemaVersion, "user", bytesUserType, nil)
 		require.NoError(t, err)
 
@@ -370,14 +397,14 @@ func TestFindLatestModel(t *testing.T) {
 		// write type "document"
 		bytesDocumentType, err := proto.Marshal(&openfgav1.TypeDefinition{Type: "document"})
 		require.NoError(t, err)
-		_, err = ds.db.ExecContext(ctx, "INSERT INTO authorization_model (store, authorization_model_id, schema_version, type, type_definition, serialized_protobuf) VALUES ($1, $2, $3, $4, $5, $6)",
+		_, err = ds.primaryDB.ExecContext(ctx, "INSERT INTO authorization_model (store, authorization_model_id, schema_version, type, type_definition, serialized_protobuf) VALUES ($1, $2, $3, $4, $5, $6)",
 			store, modelID, schemaVersion, "document", bytesDocumentType, nil)
 		require.NoError(t, err)
 
 		// write type "user"
 		bytesUserType, err := proto.Marshal(&openfgav1.TypeDefinition{Type: "user"})
 		require.NoError(t, err)
-		_, err = ds.db.ExecContext(ctx, "INSERT INTO authorization_model (store, authorization_model_id, schema_version, type, type_definition, serialized_protobuf) VALUES ($1, $2, $3, $4, $5, $6)",
+		_, err = ds.primaryDB.ExecContext(ctx, "INSERT INTO authorization_model (store, authorization_model_id, schema_version, type, type_definition, serialized_protobuf) VALUES ($1, $2, $3, $4, $5, $6)",
 			store, modelID, schemaVersion, "user", bytesUserType, nil)
 		require.NoError(t, err)
 
@@ -405,7 +432,8 @@ func TestAllowNullCondition(t *testing.T) {
 	testDatastore := storagefixtures.RunDatastoreTestContainer(t, "postgres")
 
 	uri := testDatastore.GetConnectionURI(true)
-	ds, err := New(uri, sqlcommon.NewConfig())
+	cfg := sqlcommon.NewConfig()
+	ds, err := New(uri, cfg)
 	require.NoError(t, err)
 	defer ds.Close()
 
@@ -415,7 +443,7 @@ func TestAllowNullCondition(t *testing.T) {
 			condition_name, condition_context, inserted_at
 		) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW());
 	`
-	_, err = ds.db.ExecContext(
+	_, err = ds.primaryDB.ExecContext(
 		ctx, stmt, "store", "folder", "2021-budget", "owner", "user:anne", "user",
 		ulid.Make().String(), nil, nil,
 	)
@@ -443,7 +471,7 @@ func TestAllowNullCondition(t *testing.T) {
 	require.Equal(t, tk, userTuple.GetKey())
 
 	tk2 := tuple.NewTupleKey("folder:2022-budget", "viewer", "user:anne")
-	_, err = ds.db.ExecContext(
+	_, err = ds.primaryDB.ExecContext(
 		ctx, stmt, "store", "folder", "2022-budget", "viewer", "user:anne", "userset",
 		ulid.Make().String(), nil, nil,
 	)
@@ -477,13 +505,13 @@ func TestAllowNullCondition(t *testing.T) {
 		condition_name, condition_context, inserted_at, operation
 	) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), $9);
 `
-	_, err = ds.db.ExecContext(
+	_, err = ds.primaryDB.ExecContext(
 		ctx, stmt, "store", "folder", "2021-budget", "owner", "user:anne",
 		ulid.Make().String(), nil, nil, openfgav1.TupleOperation_TUPLE_OPERATION_WRITE,
 	)
 	require.NoError(t, err)
 
-	_, err = ds.db.ExecContext(
+	_, err = ds.primaryDB.ExecContext(
 		ctx, stmt, "store", "folder", "2021-budget", "owner", "user:anne",
 		ulid.Make().String(), nil, nil, openfgav1.TupleOperation_TUPLE_OPERATION_DELETE,
 	)
@@ -507,7 +535,8 @@ func TestMarshalledAssertions(t *testing.T) {
 	testDatastore := storagefixtures.RunDatastoreTestContainer(t, "postgres")
 
 	uri := testDatastore.GetConnectionURI(true)
-	ds, err := New(uri, sqlcommon.NewConfig())
+	cfg := sqlcommon.NewConfig()
+	ds, err := New(uri, cfg)
 	require.NoError(t, err)
 	defer ds.Close()
 
@@ -517,7 +546,7 @@ func TestMarshalledAssertions(t *testing.T) {
 			store, authorization_model_id, assertions
 		) VALUES ($1, $2, DECODE('0a2b0a270a12666f6c6465723a323032312d62756467657412056f776e65721a0a757365723a616e6e657a1001','hex'));
 	`
-	_, err = ds.db.ExecContext(ctx, stmt, "store", "model")
+	_, err = ds.primaryDB.ExecContext(ctx, stmt, "store", "model")
 	require.NoError(t, err)
 
 	assertions, err := ds.ReadAssertions(ctx, "store", "model")
diff --git a/pkg/storage/sqlite/sqlite_test.go b/pkg/storage/sqlite/sqlite_test.go
index 10ebdff1..6ea07345 100644
--- a/pkg/storage/sqlite/sqlite_test.go
+++ b/pkg/storage/sqlite/sqlite_test.go
@@ -20,7 +20,8 @@ func TestSQLiteDatastore(t *testing.T) {
 	testDatastore := storagefixtures.RunDatastoreTestContainer(t, "sqlite")
 
 	uri := testDatastore.GetConnectionURI(true)
-	ds, err := New(uri, sqlcommon.NewConfig())
+	cfg := sqlcommon.NewConfig()
+	ds, err := New(uri, cfg)
 	require.NoError(t, err)
 	defer ds.Close()
 	test.RunAllTests(t, ds)
@@ -30,7 +31,8 @@ func TestSQLiteDatastoreAfterCloseIsNotReady(t *testing.T) {
 	testDatastore := storagefixtures.RunDatastoreTestContainer(t, "sqlite")
 
 	uri := testDatastore.GetConnectionURI(true)
-	ds, err := New(uri, sqlcommon.NewConfig())
+	cfg := sqlcommon.NewConfig()
+	ds, err := New(uri, cfg)
 	require.NoError(t, err)
 	ds.Close()
 	status, err := ds.IsReady(context.Background())
@@ -58,7 +60,8 @@ func TestReadEnsureNoOrder(t *testing.T) {
 			testDatastore := storagefixtures.RunDatastoreTestContainer(t, "sqlite")
 
 			uri := testDatastore.GetConnectionURI(true)
-			ds, err := New(uri, sqlcommon.NewConfig())
+			cfg := sqlcommon.NewConfig()
+			ds, err := New(uri, cfg)
 			require.NoError(t, err)
 			defer ds.Close()
 
@@ -146,7 +149,8 @@ func TestReadPageEnsureOrder(t *testing.T) {
 	testDatastore := storagefixtures.RunDatastoreTestContainer(t, "sqlite")
 
 	uri := testDatastore.GetConnectionURI(true)
-	ds, err := New(uri, sqlcommon.NewConfig())
+	cfg := sqlcommon.NewConfig()
+	ds, err := New(uri, cfg)
 	require.NoError(t, err)
 	defer ds.Close()
 
diff --git a/pkg/testfixtures/storage/mysql.go b/pkg/testfixtures/storage/mysql.go
index 09e453e9..485c72e5 100644
--- a/pkg/testfixtures/storage/mysql.go
+++ b/pkg/testfixtures/storage/mysql.go
@@ -183,3 +183,11 @@ func (m *mySQLTestContainer) GetUsername() string {
 func (m *mySQLTestContainer) GetPassword() string {
 	return m.password
 }
+
+func (m *mySQLTestContainer) CreateSecondary(t testing.TB) error {
+	return nil
+}
+
+func (m *mySQLTestContainer) GetSecondaryConnectionURI(includeCredentials bool) string {
+	return ""
+}
diff --git a/pkg/testfixtures/storage/postgres.go b/pkg/testfixtures/storage/postgres.go
index d2d48ca8..441721a9 100644
--- a/pkg/testfixtures/storage/postgres.go
+++ b/pkg/testfixtures/storage/postgres.go
@@ -31,6 +31,13 @@ type postgresTestContainer struct {
 	version  int64
 	username string
 	password string
+	replica  *postgresReplicaContainer
+}
+
+type postgresReplicaContainer struct {
+	addr     string
+	username string
+	password string
 }
 
 // NewPostgresTestContainer returns an implementation of the DatastoreTestContainer interface
@@ -91,12 +98,20 @@ AllImages:
 			nat.Port("5432/tcp"): {},
 		},
 		Image: postgresImage,
+		Cmd: []string{
+			"postgres",
+			"-c", "wal_level=replica",
+			"-c", "max_wal_senders=3",
+			"-c", "max_replication_slots=3",
+			"-c", "wal_keep_size=64MB",
+			"-c", "hot_standby=on",
+		},
 	}
 
 	hostCfg := container.HostConfig{
 		AutoRemove:      true,
 		PublishAllPorts: true,
-		Tmpfs:           map[string]string{"/var/lib/postgresql/data": ""},
+		ExtraHosts:      []string{"host.docker.internal:host-gateway"},
 	}
 
 	name := "postgres-" + ulid.Make().String()
@@ -187,3 +202,243 @@ func (p *postgresTestContainer) GetUsername() string {
 func (p *postgresTestContainer) GetPassword() string {
 	return p.password
 }
+
+// CreateSecondary creates a secondary PostgreSQL container.
+func (p *postgresTestContainer) CreateSecondary(t testing.TB) error {
+	dockerClient, err := client.NewClientWithOpts(
+		client.FromEnv,
+		client.WithAPIVersionNegotiation(),
+	)
+	require.NoError(t, err)
+	t.Cleanup(func() {
+		dockerClient.Close()
+	})
+
+	// Configure the master for replication.
+	masterContainerID, err := p.getMasterContainerID(dockerClient)
+	require.NoError(t, err)
+
+	err = p.configureMasterForReplication(t, dockerClient, masterContainerID)
+	require.NoError(t, err)
+
+	// Wait for the master to be configured.
+	time.Sleep(3 * time.Second)
+
+	// Extract host and port from master for basebackup.
+	masterHost := "host.docker.internal"
+	masterPort := strings.Split(p.addr, ":")[1]
+
+	// Use standard PostgreSQL approach with docker-entrypoint-initdb.d.
+	containerCfg := container.Config{
+		Env: []string{
+			"POSTGRES_DB=defaultdb",
+			"POSTGRES_PASSWORD=secret",
+			"PGPASSWORD=secret",
+			"POSTGRES_INITDB_ARGS=--auth-host=trust",
+			"POSTGRES_MASTER_HOST=" + masterHost,
+			"POSTGRES_MASTER_PORT=" + masterPort,
+		},
+		ExposedPorts: nat.PortSet{
+			nat.Port("5432/tcp"): {},
+		},
+		Image:      postgresImage,
+		Entrypoint: []string{"/bin/bash", "-c"},
+		Cmd: []string{fmt.Sprintf(`
+set -e
+
+export PGPASSWORD=secret
+
+echo "Initializing PostgreSQL replica..."
+
+# Wait for master to be ready
+until pg_isready -h %s -p %s -U postgres; do
+    echo "Waiting for master..."
+    sleep 2
+done
+
+echo "Master ready, creating base backup..."
+
+# Remove default PGDATA content
+rm -rf $PGDATA/*
+
+# Create base backup
+pg_basebackup -h %s -p %s -U postgres -D $PGDATA -Fp -Xs -P -R
+
+# Configure as replica
+echo "hot_standby = on" >> $PGDATA/postgresql.conf
+touch $PGDATA/standby.signal
+
+echo "Starting PostgreSQL replica..."
+exec docker-entrypoint.sh postgres -c hot_standby=on -c wal_level=replica
+`, masterHost, masterPort, masterHost, masterPort)},
+	}
+
+	hostCfg := container.HostConfig{
+		AutoRemove:      true,
+		PublishAllPorts: true,
+		ExtraHosts:      []string{"host.docker.internal:host-gateway"},
+	}
+
+	name := "postgres-replica-" + ulid.Make().String()
+
+	cont, err := dockerClient.ContainerCreate(context.Background(), &containerCfg, &hostCfg, nil, nil, name)
+	require.NoError(t, err, "failed to create postgres replica docker container")
+
+	t.Cleanup(func() {
+		t.Logf("stopping replica container %s", name)
+		timeoutSec := 5
+
+		err := dockerClient.ContainerStop(context.Background(), cont.ID, container.StopOptions{Timeout: &timeoutSec})
+		if err != nil && !errdefs.IsNotFound(err) {
+			t.Logf("failed to stop postgres replica container: %v", err)
+		}
+
+		t.Logf("stopped replica container %s", name)
+	})
+
+	err = dockerClient.ContainerStart(context.Background(), cont.ID, container.StartOptions{})
+	require.NoError(t, err, "failed to start postgres replica container")
+
+	containerJSON, err := dockerClient.ContainerInspect(context.Background(), cont.ID)
+	require.NoError(t, err)
+
+	m, ok := containerJSON.NetworkSettings.Ports["5432/tcp"]
+	if !ok || len(m) == 0 {
+		require.Fail(t, "failed to get host port mapping from postgres replica container")
+	}
+
+	p.replica = &postgresReplicaContainer{
+		addr:     "localhost:" + m[0].HostPort,
+		username: "postgres",
+		password: "secret",
+	}
+
+	// Wait for replica to be ready and synchronized.
+	err = p.waitForReplicaSync(t)
+	require.NoError(t, err, "failed to sync replica")
+
+	return nil
+}
+
+// getMasterContainerID finds the master container ID.
+func (p *postgresTestContainer) getMasterContainerID(dockerClient *client.Client) (string, error) {
+	containers, err := dockerClient.ContainerList(context.Background(), container.ListOptions{})
+	if err != nil {
+		return "", err
+	}
+
+	for _, cont := range containers {
+		for _, name := range cont.Names {
+			if strings.Contains(name, "postgres-") && !strings.Contains(name, "replica") && !strings.Contains(name, "basebackup") {
+				return cont.ID, nil
+			}
+		}
+	}
+
+	return "", fmt.Errorf("master container not found")
+}
+
+// configureMasterForReplication configures the master to accept replication connections.
+func (p *postgresTestContainer) configureMasterForReplication(t testing.TB, dockerClient *client.Client, masterContainerID string) error {
+	// Configuration for streaming replication - only pg_hba.conf and reload.
+	commands := [][]string{
+		{"sh", "-c", "echo 'host replication postgres all trust' >> /var/lib/postgresql/data/pg_hba.conf"},
+		{"psql", "-U", "postgres", "-d", "defaultdb", "-c", "SELECT pg_reload_conf()"},
+	}
+
+	for _, cmd := range commands {
+		execConfig := container.ExecOptions{
+			Cmd: cmd,
+		}
+
+		exec, err := dockerClient.ContainerExecCreate(context.Background(), masterContainerID, execConfig)
+		if err != nil {
+			return fmt.Errorf("failed to create exec for command %v: %w", cmd, err)
+		}
+
+		err = dockerClient.ContainerExecStart(context.Background(), exec.ID, container.ExecStartOptions{})
+		if err != nil {
+			return fmt.Errorf("failed to execute command %v: %w", cmd, err)
+		}
+
+		// Wait for command to complete.
+		inspect, err := dockerClient.ContainerExecInspect(context.Background(), exec.ID)
+		if err != nil {
+			return fmt.Errorf("failed to inspect exec %v: %w", cmd, err)
+		}
+
+		if inspect.ExitCode != 0 {
+			t.Logf("Command %v completed with exit code %d", cmd, inspect.ExitCode)
+		}
+	}
+
+	return nil
+}
+
+// waitForReplicaSync waits for the replica to be synchronized with the master.
+func (p *postgresTestContainer) waitForReplicaSync(t testing.TB) error {
+	uri := fmt.Sprintf("postgres://%s:%s@%s/defaultdb?sslmode=disable", p.replica.username, p.replica.password, p.replica.addr)
+
+	backoffPolicy := backoff.NewExponentialBackOff()
+	backoffPolicy.MaxElapsedTime = 120 * time.Second // Increase to 2 minutes for initialization.
+	backoffPolicy.InitialInterval = 2 * time.Second  // Start with 2 seconds.
+	backoffPolicy.MaxInterval = 10 * time.Second     // Cap at 10 seconds.
+
+	return backoff.Retry(
+		func() error {
+			db, err := goose.OpenDBWithDriver("pgx", uri)
+			if err != nil {
+				t.Logf("Connection to replica failed (expected during initialization): %v", err)
+				return fmt.Errorf("failed to connect to replica: %w", err)
+			}
+			defer db.Close()
+
+			// Check that replica is in recovery mode (standby)
+			var inRecovery bool
+			err = db.QueryRow("SELECT pg_is_in_recovery()").Scan(&inRecovery)
+			if err != nil {
+				t.Logf("Failed to check recovery status (replica may still be initializing): %v", err)
+				return fmt.Errorf("failed to check recovery status: %w", err)
+			}
+
+			if !inRecovery {
+				return fmt.Errorf("replica is not in recovery mode")
+			}
+
+			// Check that replica is receiving WAL
+			var replicaLSN *string
+			err = db.QueryRow("SELECT pg_last_wal_receive_lsn()").Scan(&replicaLSN)
+			if err != nil {
+				t.Logf("Failed to get replica LSN: %v", err)
+				return fmt.Errorf("failed to get replica LSN: %w", err)
+			}
+
+			if replicaLSN == nil || *replicaLSN == "" {
+				return fmt.Errorf("replica has not received any WAL yet")
+			}
+
+			t.Logf("Replica is synchronized and receiving WAL at LSN: %s", *replicaLSN)
+			return nil
+		},
+		backoffPolicy,
+	)
+}
+
+// GetSecondaryConnectionURI returns the connection URI for the read replica.
+func (p *postgresTestContainer) GetSecondaryConnectionURI(includeCredentials bool) string {
+	if p.replica == nil {
+		return ""
+	}
+
+	creds := ""
+	if includeCredentials {
+		creds = fmt.Sprintf("%s:%s@", p.replica.username, p.replica.password)
+	}
+
+	return fmt.Sprintf(
+		"postgres://%s%s/%s?sslmode=disable",
+		creds,
+		p.replica.addr,
+		"defaultdb",
+	)
+}
diff --git a/pkg/testfixtures/storage/sqlite.go b/pkg/testfixtures/storage/sqlite.go
index cf892ecb..4b33769c 100644
--- a/pkg/testfixtures/storage/sqlite.go
+++ b/pkg/testfixtures/storage/sqlite.go
@@ -72,3 +72,11 @@ func (m *sqliteTestContainer) GetUsername() string {
 func (m *sqliteTestContainer) GetPassword() string {
 	return ""
 }
+
+func (m *sqliteTestContainer) CreateSecondary(t testing.TB) error {
+	return nil
+}
+
+func (m *sqliteTestContainer) GetSecondaryConnectionURI(includeCredentials bool) string {
+	return ""
+}
diff --git a/pkg/testfixtures/storage/storage.go b/pkg/testfixtures/storage/storage.go
index 4464011d..c379b17d 100644
--- a/pkg/testfixtures/storage/storage.go
+++ b/pkg/testfixtures/storage/storage.go
@@ -17,6 +17,14 @@ type DatastoreTestContainer interface {
 
 	GetUsername() string
 	GetPassword() string
+
+	// CreateSecondary creates a secondary datastore if supported.
+	// Returns an error if the operation fails or if the datastore doesn't support secondary datastores.
+	CreateSecondary(t testing.TB) error
+
+	// GetSecondaryConnectionURI returns the connection URI for the secondary datastore if one exists.
+	// Returns an empty string if no secondary datastore exists.
+	GetSecondaryConnectionURI(includeCredentials bool) string
 }
 
 type memoryTestContainer struct{}
@@ -37,6 +45,14 @@ func (m memoryTestContainer) GetDatabaseSchemaVersion() int64 {
 	return 1
 }
 
+func (m memoryTestContainer) CreateSecondary(t testing.TB) error {
+	return nil
+}
+
+func (m memoryTestContainer) GetSecondaryConnectionURI(includeCredentials bool) string {
+	return ""
+}
+
 // RunDatastoreTestContainer constructs and runs a specific DatastoreTestContainer for the provided
 // datastore engine. If applicable, it also runs all existing database migrations.
 // The resources used by the test engine will be cleaned up after the test has finished.