Skip to content

feat: add optional read-write separation for database#1516

Merged
looplj merged 3 commits into
looplj:unstablefrom
lazhenyi:unstable
Apr 27, 2026
Merged

feat: add optional read-write separation for database#1516
looplj merged 3 commits into
looplj:unstablefrom
lazhenyi:unstable

Conversation

@lazhenyi

Copy link
Copy Markdown
Contributor

Summary

Add optional read-write separation for the database. When read_replica.read_dsn is configured, read-only queries (SELECT/WITH, etc.) are automatically routed to the replica, while writes and
transactions always go to the master. When unconfigured, everything falls back to single-master mode with zero overhead.

Changes

  • db/config.go: Add ReadReplicaConfig (read_dsn, read_max_open_conns, read_max_idle_conns)
  • db/ent.go: Refactor NewEntClient, extract openDB() / driverName() / entDialect(); enable router driver only when read_dsn is non-empty
  • db/router.go: New routerDriver that inspects the first SQL token for routing + masterTx to keep transactions on master
  • conf/conf.go: Add three read_replica defaults (empty/zero)
  • config.example.yml / deploy/helm/: Add config examples and env var comments
  • docs/: Add "Read-Write Separation" section to both zh and en configuration docs

Notes

  • Not applicable to SQLite (single file, no replication)
  • Transactions (client.Tx()) always go to master to avoid replication lag
  • Only requires read_dsn to enable — zero business code changes

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements read-write separation by introducing a routerDriver that directs read-only queries to a configured replica while routing writes and transactions to the master. The changes include updated configuration schemas, documentation, and database initialization logic. Review feedback identifies a potential connection leak in the driver's Close method, suggests more accurate SQL token matching, recommends a more flexible dialect fallback, and points out a performance optimization in the SQL parser.

Comment thread internal/server/db/router.go Outdated
Comment on lines +46 to +53
func (d *routerDriver) Close() error {
// Only close the drivers we created; let the underlying *sql.DB leak
// only if both drivers reference the same *sql.DB (shouldn't happen in practice).
if d.replica != nil {
d.replica.Close()
}
return nil // master driver is closed via the sql.DB pool lifecycle
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The Close method currently only closes the replica driver. Since the master driver is also created and managed within the NewEntClient lifecycle (wrapping a *sql.DB), it must be explicitly closed here to prevent connection pool leaks when the Ent client is closed. The comment suggesting it is closed elsewhere is incorrect in this context.

Suggested change
func (d *routerDriver) Close() error {
// Only close the drivers we created; let the underlying *sql.DB leak
// only if both drivers reference the same *sql.DB (shouldn't happen in practice).
if d.replica != nil {
d.replica.Close()
}
return nil // master driver is closed via the sql.DB pool lifecycle
}
func (d *routerDriver) Close() error {
var err error
if d.replica != nil {
err = d.replica.Close()
}
if masterErr := d.master.Close(); masterErr != nil {
err = masterErr
}
return err
}

Comment thread internal/server/db/ent.go Outdated
Comment on lines +120 to +131
func entDialect(dialectName string) string {
switch dialectName {
case "postgres", "pgx", "postgresdb", "pg", "postgresql":
return dialect.Postgres
case "sqlite3", "sqlite":
return dialect.SQLite
case "mysql", "tidb":
return dialect.MySQL
default:
return dialect.Postgres
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The entDialect function defaults to dialect.Postgres for any unknown dialect. This could lead to incorrect SQL generation if a custom or unsupported dialect is used. It should return the input dialectName as a fallback, allowing Ent to attempt to handle it or provide a more descriptive error.

Suggested change
func entDialect(dialectName string) string {
switch dialectName {
case "postgres", "pgx", "postgresdb", "pg", "postgresql":
return dialect.Postgres
case "sqlite3", "sqlite":
return dialect.SQLite
case "mysql", "tidb":
return dialect.MySQL
default:
return dialect.Postgres
}
}
func entDialect(dialectName string) string {
switch dialectName {
case "postgres", "pgx", "postgresdb", "pg", "postgresql":
return dialect.Postgres
case "sqlite3", "sqlite":
return dialect.SQLite
case "mysql", "tidb":
return dialect.MySQL
default:
return dialectName
}
}

Comment thread internal/server/db/router.go Outdated
Comment on lines +89 to +93
for _, prefix := range readOnlyPrefixes {
if strings.HasPrefix(upper, prefix) {
return true
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using strings.HasPrefix to identify read-only keywords can lead to false positives if a non-keyword token starts with a reserved word (e.g., a table or function named SELECTING). Since nextSQLToken already extracts the full token, an exact equality check is safer and more accurate.

Suggested change
for _, prefix := range readOnlyPrefixes {
if strings.HasPrefix(upper, prefix) {
return true
}
}
for _, prefix := range readOnlyPrefixes {
if upper == prefix {
return true
}
}

Comment thread internal/server/db/router.go Outdated
// block comments (/* */), line comments (--), and whitespace.
func nextSQLToken(sqlStr string) string {
var result strings.Builder
runes := []rune(sqlStr)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Converting the entire SQL string to a []rune slice causes an O(N) memory allocation and an extra pass over the string. Since only the first token is needed, it is more efficient to iterate over the string directly using range or utf8.DecodeRuneInString.

@looplj

looplj commented Apr 27, 2026

Copy link
Copy Markdown
Owner

Thanks for your pr,
Please follow the ent guideline to implement read/write separate or another better solution.

https://entgo.io/docs/faq/#how-to-configure-two-or-more-db-to-separate-read-and-write

@lazhenyi

Copy link
Copy Markdown
Contributor Author

understood. the above is based on my own engineering experience, I forgot to refer to best practices. i will modify the code later

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.

@lazhenyi

Copy link
Copy Markdown
Contributor Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements database read-write separation by introducing support for a read replica. The changes include new configuration options in conf/conf.go and config.example.yml, updated Helm charts, and comprehensive documentation for routing logic. A new routerDriver is introduced to handle the routing of SELECT queries to the replica while ensuring writes and transactions remain on the master. Review feedback focuses on improving the robustness of the database initialization logic, specifically addressing a regression where unsupported dialects silently default to Postgres, and ensuring that both master and replica connections are closed during shutdown to prevent resource leaks.

Comment thread internal/server/db/ent.go
Comment on lines +83 to +103
func openDB(dialectName, dsn string, maxOpen, maxIdle int, maxLifetime, maxIdleTime time.Duration) (string, *sql.DB, error) {
sqlDB, err := sql.Open(driverName(dialectName), dsn)
if err != nil {
return "", nil, err
}

if maxOpen > 0 {
sqlDB.SetMaxOpenConns(maxOpen)
}
if maxIdle > 0 {
sqlDB.SetMaxIdleConns(maxIdle)
}
if maxLifetime > 0 {
sqlDB.SetConnMaxLifetime(maxLifetime)
}
if maxIdleTime > 0 {
sqlDB.SetConnMaxIdleTime(maxIdleTime)
}

return entDialect(dialectName), sqlDB, nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The openDB function should handle potential errors from entDialect and ensure that the sql.DB handle is closed if a dialect error occurs after the connection is opened. Currently, entDialect has a silent fallback to Postgres which is problematic.

func openDB(dialectName, dsn string, maxOpen, maxIdle int, maxLifetime, maxIdleTime time.Duration) (string, *sql.DB, error) {
	dialect, err := entDialect(dialectName)
	if err != nil {
		return "", nil, err
	}

	sqlDB, err := sql.Open(driverName(dialectName), dsn)
	if err != nil {
		return "", nil, err
	}

	if maxOpen > 0 {
		sqlDB.SetMaxOpenConns(maxOpen)
	}
	if maxIdle > 0 {
		sqlDB.SetMaxIdleConns(maxIdle)
	}
	if maxLifetime > 0 {
		sqlDB.SetConnMaxLifetime(maxLifetime)
	}
	if maxIdleTime > 0 {
		sqlDB.SetConnMaxIdleTime(maxIdleTime)
	}

	return dialect, sqlDB, nil
}

Comment thread internal/server/db/ent.go Outdated
Comment on lines +120 to +131
func entDialect(dialectName string) string {
switch dialectName {
case "postgres", "pgx", "postgresdb", "pg", "postgresql":
return dialect.Postgres
case "sqlite3", "sqlite":
return dialect.SQLite
case "mysql", "tidb":
return dialect.MySQL
default:
return dialect.Postgres
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The entDialect function silently defaults to dialect.Postgres for any unknown dialect. This is a regression from the previous implementation which panicked on invalid dialects. It should return an error for unsupported dialects to ensure the application fails fast with a clear message.

Suggested change
func entDialect(dialectName string) string {
switch dialectName {
case "postgres", "pgx", "postgresdb", "pg", "postgresql":
return dialect.Postgres
case "sqlite3", "sqlite":
return dialect.SQLite
case "mysql", "tidb":
return dialect.MySQL
default:
return dialect.Postgres
}
}
func entDialect(dialectName string) (string, error) {
switch dialectName {
case "postgres", "pgx", "postgresdb", "pg", "postgresql":
return dialect.Postgres, nil
case "sqlite3", "sqlite":
return dialect.SQLite, nil
case "mysql", "tidb":
return dialect.MySQL, nil
default:
return "", fmt.Errorf("invalid dialect: %s", dialectName)
}
}

Comment on lines +39 to +47
func (d *routerDriver) Close() error {
if err := d.master.Close(); err != nil {
return err
}
if d.replica != nil {
return d.replica.Close()
}
return nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In the Close method, if d.master.Close() returns an error, d.replica.Close() is never called. Both database handles should be attempted to be closed to avoid resource leaks.

Suggested change
func (d *routerDriver) Close() error {
if err := d.master.Close(); err != nil {
return err
}
if d.replica != nil {
return d.replica.Close()
}
return nil
}
func (d *routerDriver) Close() error {
err1 := d.master.Close()
var err2 error
if d.replica != nil {
err2 = d.replica.Close()
}
if err1 != nil {
return err1
}
return err2
}

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.

@lazhenyi

lazhenyi commented Apr 27, 2026 via email

Copy link
Copy Markdown
Contributor Author

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements read-write separation for the database by introducing a read replica configuration. It adds a routerDriver to the Ent client that automatically routes SELECT queries to the configured replica while directing all writes and transactions to the master database. The changes include updated configuration defaults, example YAML files, Helm charts, and documentation in both English and Chinese. One piece of feedback was provided to ensure that the read replica's connection pool settings inherit the master's defaults when not explicitly configured, preventing the replica from defaulting to unlimited connections.

Comment thread internal/server/db/ent.go
Comment on lines +42 to +44
readDialect, replicaDB, err := openDB(cfg.Dialect, cfg.ReadReplica.DSN,
cfg.ReadReplica.MaxOpenConns, cfg.ReadReplica.MaxIdleConns,
cfg.ConnMaxLifetime, cfg.ConnMaxIdleTime)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The read replica connection pool settings (read_max_open_conns and read_max_idle_conns) default to 0 in the configuration. In Go's sql.DB, a value of 0 for MaxOpenConns means unlimited connections. This is inconsistent with the master database, which defaults to 20 open and 10 idle connections. If a user configures a read replica but doesn't explicitly set its pool limits, the replica could potentially overwhelm the database server with unlimited connections. It is safer to inherit the master's pool settings if the replica-specific ones are not provided.

Suggested change
readDialect, replicaDB, err := openDB(cfg.Dialect, cfg.ReadReplica.DSN,
cfg.ReadReplica.MaxOpenConns, cfg.ReadReplica.MaxIdleConns,
cfg.ConnMaxLifetime, cfg.ConnMaxIdleTime)
maxOpen := cfg.ReadReplica.MaxOpenConns
if maxOpen == 0 {
maxOpen = cfg.MaxOpenConns
}
maxIdle := cfg.ReadReplica.MaxIdleConns
if maxIdle == 0 {
maxIdle = cfg.MaxIdleConns
}
readDialect, replicaDB, err := openDB(cfg.Dialect, cfg.ReadReplica.DSN,
maxOpen, maxIdle,
cfg.ConnMaxLifetime, cfg.ConnMaxIdleTime)

@lazhenyi

lazhenyi commented Apr 27, 2026 via email

Copy link
Copy Markdown
Contributor Author

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

Gemini encountered an error creating the issue-comment-reply. You can try again by commenting /gemini issue-comment-reply.

@looplj

looplj commented Apr 27, 2026

Copy link
Copy Markdown
Owner

Please confirm if it is tested in local, I will merge it if it is ready.

@lazhenyi

lazhenyi commented Apr 27, 2026 via email

Copy link
Copy Markdown
Contributor Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants