From 655db8dcbff1da6a93a23a1fd8351463cbbd6796 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sat, 27 Jun 2026 21:47:14 +0700 Subject: [PATCH] feat(config): standardize keepalive env names --- .env.example | 33 +++++++++++++++++--------------- README.md | 42 +++++++++++++++++++++-------------------- adapter/adapter.go | 10 +--------- adapter/couchbase.go | 14 +++++++------- adapter/env.go | 13 ++++++++++--- adapter/env_test.go | 44 +++++++++++++++++++++++++++++++++++++++++++ adapter/mongodb.go | 8 ++++---- adapter/mysql.go | 12 ++++++++---- adapter/postgresql.go | 9 ++++++--- adapter/redis.go | 4 ++-- adapter/valkey.go | 4 ++-- main.go | 11 ++++++++--- 12 files changed, 132 insertions(+), 72 deletions(-) create mode 100644 adapter/env_test.go diff --git a/.env.example b/.env.example index 4e32556..cebf773 100644 --- a/.env.example +++ b/.env.example @@ -1,28 +1,31 @@ # Pick one of: redis, valkey, postgresql, mysql, mongodb, couchbase -DB_TYPE=redis +KEEPALIVE_ADAPTER=redis # Optional: tick interval (e.g. 30s, 1m, 5m). Defaults to 1m. -# INTERVAL=1m +# KEEPALIVE_INTERVAL=1m + +# Optional: key/doc ID to increment. Defaults to counter. +# KEEPALIVE_COUNTER_KEY=counter # --- redis / valkey --- -REDIS_URL=redis://default@127.0.0.1:6379 -# VALKEY_URL=valkey://default@127.0.0.1:6379 +KEEPALIVE_REDIS_URL=redis://default@127.0.0.1:6379 +# KEEPALIVE_VALKEY_URL=valkey://default@127.0.0.1:6379 # --- postgresql --- -# SERVICE_URI=postgres://user:pass@host:5432/dbname?sslmode=require +# KEEPALIVE_POSTGRESQL_URL=postgres://user:pass@host:5432/dbname?sslmode=require # --- mysql --- -# DATA_SOURCE_NAME=user:pass@tcp(host:3306)/dbname +# KEEPALIVE_MYSQL_DSN=user:pass@tcp(host:3306)/dbname # --- mongodb --- -# MONGODB_URI=mongodb+srv://user:pass@cluster.mongodb.net -# MONGODB_DATABASE=keepalive -# MONGODB_COLLECTION=counter +# KEEPALIVE_MONGODB_URI=mongodb+srv://user:pass@cluster.mongodb.net +# KEEPALIVE_MONGODB_DATABASE=keepalive +# KEEPALIVE_MONGODB_COLLECTION=counter # --- couchbase --- -# COUCHBASE_CONNECTION_STRING=couchbases://cb.host -# COUCHBASE_USERNAME= -# COUCHBASE_PASSWORD= -# COUCHBASE_BUCKET_NAME= -# COUCHBASE_SCOPE_NAME=_default -# COUCHBASE_COLLECTION_NAME=_default +# KEEPALIVE_COUCHBASE_CONNECTION_STRING=couchbases://cb.host +# KEEPALIVE_COUCHBASE_USERNAME= +# KEEPALIVE_COUCHBASE_PASSWORD= +# KEEPALIVE_COUCHBASE_BUCKET_NAME= +# KEEPALIVE_COUCHBASE_SCOPE_NAME=_default +# KEEPALIVE_COUCHBASE_COLLECTION_NAME=_default diff --git a/README.md b/README.md index f530a6d..d5ce3e0 100644 --- a/README.md +++ b/README.md @@ -8,23 +8,25 @@ Successor to the `*-keepalive` family: one binary, one image, six datastore adap ## Supported adapters -| `DB_TYPE` | Driver | Env vars | -| ------------ | ----------------------------------- | -------- | -| `redis` | `github.com/redis/go-redis/v9` | `REDIS_URL` | -| `valkey` | `github.com/valkey-io/valkey-go` | `VALKEY_URL` | -| `postgresql` | `github.com/lib/pq` | `SERVICE_URI` | -| `mysql` | `github.com/go-sql-driver/mysql` | `DATA_SOURCE_NAME` | -| `mongodb` | `go.mongodb.org/mongo-driver/v2` | `MONGODB_URI`, `MONGODB_DATABASE`, `MONGODB_COLLECTION` | -| `couchbase` | `github.com/couchbase/gocb/v2` | `COUCHBASE_CONNECTION_STRING`, `COUCHBASE_USERNAME`, `COUCHBASE_PASSWORD`, `COUCHBASE_BUCKET_NAME`, `COUCHBASE_SCOPE_NAME`, `COUCHBASE_COLLECTION_NAME` | +Set `KEEPALIVE_ADAPTER` to one of the values below. -Optional: `INTERVAL` (e.g. `30s`, `5m`; default `1m`), `COUNTER_KEY` (default `counter`). +| `KEEPALIVE_ADAPTER` | Driver | Env vars | +| ------------------- | ----------------------------------- | ------------------ | +| `redis` | `github.com/redis/go-redis/v9` | `KEEPALIVE_REDIS_URL` | +| `valkey` | `github.com/valkey-io/valkey-go` | `KEEPALIVE_VALKEY_URL` | +| `postgresql` | `github.com/lib/pq` | `KEEPALIVE_POSTGRESQL_URL` | +| `mysql` | `github.com/go-sql-driver/mysql` | `KEEPALIVE_MYSQL_DSN` | +| `mongodb` | `go.mongodb.org/mongo-driver/v2` | `KEEPALIVE_MONGODB_URI`, `KEEPALIVE_MONGODB_DATABASE`, `KEEPALIVE_MONGODB_COLLECTION` | +| `couchbase` | `github.com/couchbase/gocb/v2` | `KEEPALIVE_COUCHBASE_CONNECTION_STRING`, `KEEPALIVE_COUCHBASE_USERNAME`, `KEEPALIVE_COUCHBASE_PASSWORD`, `KEEPALIVE_COUCHBASE_BUCKET_NAME`, `KEEPALIVE_COUCHBASE_SCOPE_NAME`, `KEEPALIVE_COUCHBASE_COLLECTION_NAME` | + +Optional: `KEEPALIVE_INTERVAL` (e.g. `30s`, `5m`; default `1m`), `KEEPALIVE_COUNTER_KEY` (default `counter`). ## Quick start (Docker) ```bash docker run -d --name keepalive --restart unless-stopped \ - -e DB_TYPE=redis \ - -e REDIS_URL='redis://default@host:6379' \ + -e KEEPALIVE_ADAPTER=redis \ + -e KEEPALIVE_REDIS_URL='redis://default@host:6379' \ ghcr.io/tiennm99/keepalive:latest ``` @@ -33,19 +35,19 @@ docker run -d --name keepalive --restart unless-stopped \ ```bash git clone https://github.com/tiennm99/keepalive cd keepalive -cp .env.example .env # then edit DB_TYPE + the driver's env vars +cp .env.example .env # then edit KEEPALIVE_ADAPTER + the driver's env vars go run . ``` ## How it works -On every tick the chosen adapter performs the cheapest write that proves the cluster is alive: +On every tick the chosen adapter performs the cheapest write that proves the cluster is alive. `KEEPALIVE_COUNTER_KEY` selects the key/doc ID and defaults to `counter`. -- **Redis/Valkey** — `INCR counter` -- **PostgreSQL** — `UPDATE keepalive SET value = value + 1 WHERE key = 'counter' RETURNING value` -- **MySQL** — `UPDATE` + `SELECT` inside a transaction -- **MongoDB** — `FindOneAndUpdate({_id: "counter"}, {$inc: {count: 1}}, upsert)` -- **Couchbase** — `GET counter` → `++` → `UPSERT counter` +- **Redis/Valkey** — `INCR key` +- **PostgreSQL** — `UPDATE keepalive SET value = value + 1 WHERE key = $1 RETURNING value` +- **MySQL** — `UPDATE` + `SELECT` by key inside a transaction +- **MongoDB** — `FindOneAndUpdate({_id: key}, {$inc: {count: 1}}, upsert)` +- **Couchbase** — `GET key` -> `++` -> `UPSERT key` The PostgreSQL and MySQL adapters expect a table: @@ -54,7 +56,7 @@ CREATE TABLE keepalive (key TEXT PRIMARY KEY, value BIGINT NOT NULL DEFAULT 0); INSERT INTO keepalive (key, value) VALUES ('counter', 0); ``` -(MySQL uses backticked identifiers — see `adapter/mysql.go`.) +Seed the value with your configured `KEEPALIVE_COUNTER_KEY` when it is not `counter`. MySQL uses backticked identifiers — see `adapter/mysql.go`. ## Adding a new adapter @@ -64,7 +66,7 @@ INSERT INTO keepalive (key, value) VALUES ('counter', 0); ```go func init() { Registry[""] = func() (Adapter, error) { return &myAdapter{}, nil } } ``` -4. Add an `import _ "your driver"` if needed, and the env vars to `.env.example` and the table above. +4. Add an `import _ "your driver"` if needed, and the `KEEPALIVE_*` env vars to `.env.example` and the table above. ## Migrated from diff --git a/adapter/adapter.go b/adapter/adapter.go index e11d21a..12b3ec0 100644 --- a/adapter/adapter.go +++ b/adapter/adapter.go @@ -24,7 +24,7 @@ var Registry = map[string]Factory{} func New(dbType string) (Adapter, error) { f, ok := Registry[dbType] if !ok { - return nil, fmt.Errorf("unknown DB_TYPE %q (known: %v)", dbType, Known()) + return nil, fmt.Errorf("unknown adapter %q (known: %v)", dbType, Known()) } return f() } @@ -36,11 +36,3 @@ func Known() []string { } return out } - -func envOrFail(name string) (string, error) { - v, ok := lookupEnv(name) - if !ok || v == "" { - return "", fmt.Errorf("env %s is required", name) - } - return v, nil -} diff --git a/adapter/couchbase.go b/adapter/couchbase.go index f766d9e..75fbd02 100644 --- a/adapter/couchbase.go +++ b/adapter/couchbase.go @@ -18,27 +18,27 @@ type couchbaseAdapter struct { } func (a *couchbaseAdapter) Connect(_ context.Context) error { - conn, err := envOrFail("COUCHBASE_CONNECTION_STRING") + conn, err := envOrFail("KEEPALIVE_COUCHBASE_CONNECTION_STRING") if err != nil { return err } - user, err := envOrFail("COUCHBASE_USERNAME") + user, err := envOrFail("KEEPALIVE_COUCHBASE_USERNAME") if err != nil { return err } - pass, err := envOrFail("COUCHBASE_PASSWORD") + pass, err := envOrFail("KEEPALIVE_COUCHBASE_PASSWORD") if err != nil { return err } - bucket, err := envOrFail("COUCHBASE_BUCKET_NAME") + bucket, err := envOrFail("KEEPALIVE_COUCHBASE_BUCKET_NAME") if err != nil { return err } - scope, err := envOrFail("COUCHBASE_SCOPE_NAME") + scope, err := envOrFail("KEEPALIVE_COUCHBASE_SCOPE_NAME") if err != nil { return err } - collName, err := envOrFail("COUCHBASE_COLLECTION_NAME") + collName, err := envOrFail("KEEPALIVE_COUCHBASE_COLLECTION_NAME") if err != nil { return err } @@ -59,7 +59,7 @@ func (a *couchbaseAdapter) Connect(_ context.Context) error { } a.cluster = cluster a.coll = b.Scope(scope).Collection(collName) - a.docID = envOr("COUNTER_KEY", "counter") + a.docID = envOr("KEEPALIVE_COUNTER_KEY", "counter") return nil } diff --git a/adapter/env.go b/adapter/env.go index 1311cf9..95bad61 100644 --- a/adapter/env.go +++ b/adapter/env.go @@ -1,9 +1,16 @@ package adapter -import "os" +import ( + "fmt" + "os" +) -func lookupEnv(name string) (string, bool) { - return os.LookupEnv(name) +func envOrFail(name string) (string, error) { + v, ok := os.LookupEnv(name) + if !ok || v == "" { + return "", fmt.Errorf("env %s is required", name) + } + return v, nil } func envOr(name, def string) string { diff --git a/adapter/env_test.go b/adapter/env_test.go new file mode 100644 index 0000000..6dd7494 --- /dev/null +++ b/adapter/env_test.go @@ -0,0 +1,44 @@ +package adapter + +import ( + "strings" + "testing" +) + +func TestEnvOrFailReturnsConfiguredValue(t *testing.T) { + t.Setenv("KEEPALIVE_TEST_REQUIRED", "configured") + + got, err := envOrFail("KEEPALIVE_TEST_REQUIRED") + if err != nil { + t.Fatalf("envOrFail returned error: %v", err) + } + if got != "configured" { + t.Fatalf("envOrFail() = %q, want configured", got) + } +} + +func TestEnvOrFailReportsMissingName(t *testing.T) { + _, err := envOrFail("KEEPALIVE_TEST_MISSING_REQUIRED") + if err == nil { + t.Fatal("envOrFail returned nil error") + } + if !strings.Contains(err.Error(), "KEEPALIVE_TEST_MISSING_REQUIRED") { + t.Fatalf("error %q does not include env name", err) + } +} + +func TestEnvOrReturnsConfiguredValue(t *testing.T) { + t.Setenv("KEEPALIVE_TEST_OPTIONAL", "configured") + + got := envOr("KEEPALIVE_TEST_OPTIONAL", "default") + if got != "configured" { + t.Fatalf("envOr() = %q, want configured", got) + } +} + +func TestEnvOrUsesDefaultWhenNoNamesAreSet(t *testing.T) { + got := envOr("KEEPALIVE_TEST_MISSING_DEFAULT", "default") + if got != "default" { + t.Fatalf("envOr() = %q, want default", got) + } +} diff --git a/adapter/mongodb.go b/adapter/mongodb.go index f2d99b6..e1a6d0c 100644 --- a/adapter/mongodb.go +++ b/adapter/mongodb.go @@ -20,15 +20,15 @@ type mongoAdapter struct { } func (a *mongoAdapter) Connect(ctx context.Context) error { - uri, err := envOrFail("MONGODB_URI") + uri, err := envOrFail("KEEPALIVE_MONGODB_URI") if err != nil { return err } - dbName, err := envOrFail("MONGODB_DATABASE") + dbName, err := envOrFail("KEEPALIVE_MONGODB_DATABASE") if err != nil { return err } - collName, err := envOrFail("MONGODB_COLLECTION") + collName, err := envOrFail("KEEPALIVE_MONGODB_COLLECTION") if err != nil { return err } @@ -38,7 +38,7 @@ func (a *mongoAdapter) Connect(ctx context.Context) error { } a.client = client a.coll = client.Database(dbName).Collection(collName) - a.docID = envOr("COUNTER_KEY", "counter") + a.docID = envOr("KEEPALIVE_COUNTER_KEY", "counter") return client.Ping(ctx, nil) } diff --git a/adapter/mysql.go b/adapter/mysql.go index cc268fb..d695b00 100644 --- a/adapter/mysql.go +++ b/adapter/mysql.go @@ -13,11 +13,12 @@ func init() { } type mysqlAdapter struct { - db *sql.DB + db *sql.DB + key string } func (a *mysqlAdapter) Connect(ctx context.Context) error { - dsn, err := envOrFail("DATA_SOURCE_NAME") + dsn, err := envOrFail("KEEPALIVE_MYSQL_DSN") if err != nil { return err } @@ -29,6 +30,7 @@ func (a *mysqlAdapter) Connect(ctx context.Context) error { db.SetMaxOpenConns(10) db.SetMaxIdleConns(10) a.db = db + a.key = envOr("KEEPALIVE_COUNTER_KEY", "counter") return a.db.PingContext(ctx) } @@ -38,14 +40,16 @@ func (a *mysqlAdapter) Increment(ctx context.Context) (int64, error) { return 0, err } if _, err := tx.ExecContext(ctx, - "UPDATE `keepalive` SET `value` = `value` + 1 WHERE `key` = 'counter'", + "UPDATE `keepalive` SET `value` = `value` + 1 WHERE `key` = ?", + a.key, ); err != nil { tx.Rollback() return 0, err } var value int64 if err := tx.QueryRowContext(ctx, - "SELECT `value` FROM `keepalive` WHERE `key` = 'counter'", + "SELECT `value` FROM `keepalive` WHERE `key` = ?", + a.key, ).Scan(&value); err != nil { tx.Rollback() return 0, err diff --git a/adapter/postgresql.go b/adapter/postgresql.go index 2233efc..2b473ce 100644 --- a/adapter/postgresql.go +++ b/adapter/postgresql.go @@ -13,11 +13,12 @@ func init() { } type postgresAdapter struct { - db *sql.DB + db *sql.DB + key string } func (a *postgresAdapter) Connect(ctx context.Context) error { - uri, err := envOrFail("SERVICE_URI") + uri, err := envOrFail("KEEPALIVE_POSTGRESQL_URL") if err != nil { return err } @@ -26,6 +27,7 @@ func (a *postgresAdapter) Connect(ctx context.Context) error { return err } a.db = db + a.key = envOr("KEEPALIVE_COUNTER_KEY", "counter") return a.db.PingContext(ctx) } @@ -36,7 +38,8 @@ func (a *postgresAdapter) Increment(ctx context.Context) (int64, error) { } var value int64 if err := tx.QueryRowContext(ctx, - `UPDATE keepalive SET value = value + 1 WHERE key = 'counter' RETURNING value`, + `UPDATE keepalive SET value = value + 1 WHERE key = $1 RETURNING value`, + a.key, ).Scan(&value); err != nil { tx.Rollback() return 0, err diff --git a/adapter/redis.go b/adapter/redis.go index d4cb5d6..21f8d37 100644 --- a/adapter/redis.go +++ b/adapter/redis.go @@ -16,7 +16,7 @@ type redisAdapter struct { } func (a *redisAdapter) Connect(ctx context.Context) error { - url, err := envOrFail("REDIS_URL") + url, err := envOrFail("KEEPALIVE_REDIS_URL") if err != nil { return err } @@ -25,7 +25,7 @@ func (a *redisAdapter) Connect(ctx context.Context) error { return err } a.client = redis.NewClient(opt) - a.key = envOr("COUNTER_KEY", "counter") + a.key = envOr("KEEPALIVE_COUNTER_KEY", "counter") return a.client.Ping(ctx).Err() } diff --git a/adapter/valkey.go b/adapter/valkey.go index 8e5e0b6..4b317e2 100644 --- a/adapter/valkey.go +++ b/adapter/valkey.go @@ -16,7 +16,7 @@ type valkeyAdapter struct { } func (a *valkeyAdapter) Connect(_ context.Context) error { - url, err := envOrFail("VALKEY_URL") + url, err := envOrFail("KEEPALIVE_VALKEY_URL") if err != nil { return err } @@ -29,7 +29,7 @@ func (a *valkeyAdapter) Connect(_ context.Context) error { return err } a.client = client - a.key = envOr("COUNTER_KEY", "counter") + a.key = envOr("KEEPALIVE_COUNTER_KEY", "counter") return nil } diff --git a/main.go b/main.go index f7e065c..87a38cc 100644 --- a/main.go +++ b/main.go @@ -13,17 +13,22 @@ import ( "github.com/tiennm99/keepalive/adapter" ) +const ( + envAdapter = "KEEPALIVE_ADAPTER" + envInterval = "KEEPALIVE_INTERVAL" +) + func main() { if err := godotenv.Load(); err != nil { log.Println("note: .env not loaded, relying on process env") } - dbType := os.Getenv("DB_TYPE") + dbType := os.Getenv(envAdapter) if dbType == "" { - log.Fatalf("DB_TYPE is required (known: %v)", adapter.Known()) + log.Fatalf("%s is required (known: %v)", envAdapter, adapter.Known()) } - interval := parseInterval(os.Getenv("INTERVAL"), time.Minute) + interval := parseInterval(os.Getenv(envInterval), time.Minute) a, err := adapter.New(dbType) if err != nil {