v3: rewrite core driver with truthful optional-interface wrapping, lean dependencies, stdlib tests - #68
Conversation
Change the module path to github.com/infobloxopen/hotload/v3 and rewrite the core driver: - Wrapped conns and stmts now implement an optional driver interface if and only if the underlying driver supports it. Single-method piece types are composed into generated combination structs (internal/gen, invoked via go:generate), so database/sql's type assertions see the truth instead of ErrSkip round-trips. ConnPrepareContext, ConnBeginTx, SessionResetter and Validator are always implemented, replicating database/sql's fallback exactly. - Replace chanGroup with a group/generation model: one generation owns the cancel context, conn set and drained flag for one DSN value, so a config change is a pointer swap. No driver call, hook or strategy call happens under any lock, removing the deadlock class fixed in 573e701 and the 1ms-sleep workaround. forceKill waits a bounded killWindow (DSN parameter, default 100ms) for in-flight work before force-closing. - Replace teivah/onecontext with context.AfterFunc-based cancellation relay (no goroutine per merge, unregistered synchronously on release). - Implement driver.DriverContext; connectors are refcounted per DSN and DB.Close now tears down the strategy watch and run loop. - Wrap driver.Stmt, fixing the prepared-statement blind spot in transaction statement counters. - Replace hard-wired prometheus metrics with zero-dependency hooks (RegisterHooks); modtime and fsnotify report through them. Drop pkg/errors, google/uuid (crypto/rand), onecontext and prometheus from the module; the only remaining dependency is fsnotify. - New stdlib-only test suite: a capability-masked fake driver (internal/dbfake), interface truthfulness matrices, database/sql-level dispatch tests, graceful/forceKill lifecycle tests, change-storm concurrency tests, and a goroutine-leak checker (internal/testutil).
…detection fsnotify: - Replace pkg/errors with stdlib error wrapping; drop the metrics import (the core emits WatchEvent hooks instead). - Restructure watch closure: CloseWatch/Close now clean up synchronously under the strategy lock and close the per-watch queue, terminating the delivery goroutine, which previously leaked after every CloseWatch. - Make update delivery non-blocking: only the latest value matters, so a full queue drops the oldest entry instead of blocking the strategy behind a subscriber that stopped receiving (previously this could wedge every watcher sharing the strategy). - Resync all watched paths when an event arrives under an unknown name: kqueue reports events under the resolved symlink target, which broke Kubernetes ConfigMap-style symlink swaps on macOS. - Fix Watch panicking after Close (nil map); make the resync period a per-strategy field. - New stdlib test suite against real files: writes, truncation, atomic rename, remove/recreate recovery, the ConfigMap ..data symlink dance, multiple subscribers, close semantics, abandoned-subscriber wedging. modtime: report latency through hotload hooks instead of the metrics package; tests ported to stdlib testing with a mock fs.StatFS and hook-based assertions. internal: secret sink uses crypto/rand instead of google/uuid.
New nested module github.com/infobloxopen/hotload/observability keeping prometheus (and its transitive dependencies) out of the hotload core. Registration is explicit via EnablePrometheus, which preserves hotload v1's metric names and labels: transaction_sql_stmts, hotload_change_total, hotload_last_changed_timestamp_seconds, hotload_modtime_latency_histogram and hotload_path_chksum_timestamp_seconds (still gated by HOTLOAD_PATH_CHKSUM_METRICS_ENABLE). The path checksum metric is a hand-rolled scrape-time prometheus.Collector, replacing the colega/gaugefuncvec dependency. The CollectAndRegexpCompare test helper moves here from the core's internal package as observability/promtest. The replace directive in observability/go.mod serves in-repo development; consumers resolve the pinned require version (to be bumped to the real tag at release, see release ordering notes).
Replace the ginkgo-based integrationtests package with a nested module (never tagged or imported) holding plain-testing scenarios against a real PostgreSQL via lib/pq and the fsnotify strategy: database switchover, password rotation during long Exec/ExecContext/Query/ QueryContext (graceful completes, forceKill cancels), transactions spanning a config change in both modes, caller context cancellation, prepared statements re-preparing across a change, and sequential long execs each overlapping one rotation. Each test writes its own DSN file under t.TempDir, so every test gets an isolated hotload group. Tests skip when no postgres is reachable; HOTLOAD_INTEGRATION_TEST_POSTGRES_HOST/PORT are honored as before. The docker-compose setup and init SQL carry over unchanged.
Makefile: per-module fmt/vet/tidy/build/test loops, a generate target with a no-diff guard for the generated wrappers, and a dep-budget check asserting the root module's only direct dependency is fsnotify (internal/depbudget parses go mod edit -json with the stdlib). CI: replace the docker-build and kind/helm pipelines with native setup-go jobs — unit tests on a Go 1.23/1.24 matrix via make ci-test, and integration tests against docker-compose postgres. Remove the Dockerfiles, helm/kind targets and stale testdata. Docs: README rewritten for /v3 (corrects the long-stale Strategy.Watch signature, documents truthful capability mirroring, killWindow, connector lifecycle and opt-in metrics); MIGRATION.md covers the v1->v3 upgrade with the metrics call-out front and center; RELEASING.md documents the nested-module tag ordering and a consumer smoke check.
Closing the last sql.DB for a DSN released hdriver.mu before the group's strategy watch was torn down, so a concurrent sql.Open of the same DSN could create a new group that the strategy fed through the old group's about-to-be-closed update channel — the reopened handle then silently stopped receiving config changes. Strategy watch closure now happens inside the same hdriver.mu critical section that creates watches, serializing the two; the rest of the teardown (parent context cancel, hook emission) still runs outside all locks. TestReopenWhileClosing reproduces the race and fails on the previous ordering. Cleanups from review: drop the never-read baseConn.killed field, reuse the generation's cached redacted DSN when driver options don't modify the dial string (RedactUrl costs a mutex plus crypto/rand per call), and deduplicate the modtime test poll helper into internal/testutil.
The go.mod directives stay at 1.23 (minimum supported version); the CI matrix tracks the two most recent Go releases.
Port the k8ssecret strategy idea from PR #66 onto v3: a nested module (github.com/infobloxopen/hotload/k8ssecret, keeping client-go out of the core) implementing hotload.Strategy by watching a Kubernetes Secret through the API server, for deployments that need credentials from a Secret in another namespace, which Kubernetes cannot mount as a volume. DSN format matches the PR: k8ssecret://<driver>/<secret>?namespace=<ns>&dsn=<key>, with the namespace defaulting to the pod's own and the key to dsn.txt. Beyond the port, this fixes four defects in the PR #66 implementation: - The hotload core passes uri.Path, so the secret name arrives with a leading slash; the PR used it verbatim and would look up a secret named "/myapp-db" (its tests called Watch directly with bare names, masking this). Covered here by an end-to-end test through sql.Open with a minimal fake driver. - Updates could be lost in the gap between the initial Get and the watch start, and while a dropped watch waited to reconnect (only Modified events were handled, watching from "now"). Every (re)connect now re-reads the Secret, delivers missed changes, and watches from that read's resource version; Added events (recreated secrets, post-reconnect snapshots) are handled too. - Slow subscribers had the newest value dropped when their buffer was full, leaving them permanently stale; delivery now drops the oldest queued value so subscribers converge on the latest. - Watches were keyed by namespace/name only, so two DSNs reading different data keys of the same Secret shared one value; the data key is now part of the watch identity. Tests use client-go's fake clientset: initial fetch, missing secret/key, defaults, update propagation, delete/recreate, reconnect catch-up (via a watch reactor whose first watcher is killed), slow subscriber convergence, independent keys, CloseWatch/Close semantics, and the sql.Open end-to-end path. The module joins go.work, the Makefile module loop (and therefore CI), and the release ordering docs.
docker compose up --wait only waits for the container to be running unless a healthcheck is defined, and the postgres entrypoint restarts the server after running init scripts — so the tests could connect during initialization and fail with connection-reset errors. Add a pg_isready healthcheck (targeting TCP, since the init-phase temporary server only listens on the unix socket) and retry the setup ping for up to a minute as defense for environments without the healthcheck.
…k8ssecret tests client-go's fake clientset ignores the resource version in watch options and only delivers events to watchers already registered with its tracker, so a test mutating a Secret while the strategy's watch goroutine was still establishing its watch lost the event and timed out (flaked in CI under load; a real API server replays from the resource version, so the strategy itself has no such gap). The fake clientset helper now wraps the tracker watch in a reactor that signals after registration, and every mutating test waits for that signal first.
Branch protection on main requires a status check named build, the job name in the pre-v3 workflow; the rewritten workflow's unit/integration jobs never report it, leaving PRs stuck on "expected". Add an aggregate gate job with that name needing both real jobs. It runs even when they fail (a skipped required check counts as satisfied) and keeps the required-check name stable while the unit matrix's Go versions change.
| ) | ||
|
|
||
| // Strategy is the plugin interface for hotload. | ||
| type Strategy interface { |
There was a problem hiding this comment.
Given this PR is already rewriting the core, can we revisit the Strategy interface API for v3?
Watch(ctx, pth, pathQry)passes two opaque strings. The core has already parsed the hotload DSN, but each strategy has to re-parse / normalize path and query data, then repeat that same identity work inCloseWatch.- Collapse Strategy to a single Watch method scoped by
context.Context. RemovesCloseWatchandClose, eliminates duplicated identity logic in every strategy. Context cancellation can be use for managing lifetime.
I would simplify the strategy boundary to keep the channel, but make the resource parsed and the lifecycle context-scoped:
// Strategy is the plugin interface for hotload.
//
// Watch returns the current value and a channel of subsequent updates.
// The watch lives until ctx is canceled; on cancellation the strategy
// releases its resources and closes the updates channel. Values may
// carry secrets — strategies must not log them.
type Strategy interface {
Watch(ctx context.Context, loc Location) (value string, updates <-chan string, err error)
}
// Location identifies what to watch, parsed once by the core.
type Location struct {
Path string // URL path (file path, secret name, etcd key, …)
Params url.Values // query params, hotload-reserved keys removed
}Why this shape:
- One method. Teardown is ctx cancellation, the core already owns the per-DSN context.
- No re-parsing. Location is parsed once.
- No
Close(). Strategy lifetime = process lifetime; per-watch lifetime = ctx. Test isolation comes from constructing fresh strategy instances.
There was a problem hiding this comment.
Single-method interfaces are one of Go’s most powerful patterns. They are especially expressive when they represent exactly one capability through a single method.
For example, Watch: given a resource, watch it and stream configuration values.
There was a problem hiding this comment.
The parsing would add a small improvement but then you could not just pass the cli/env args directly into it. it pushes that into the caller. for example, net.Dial("tcp", "10.11.12.13:443"), you could pass a parsed version of the destination Addr, but its more convenient to deal with the unparsed representation throughout the code then the small efficiency you may gain. further since strings are immuntable pointers, nothing really gets passaround other than a pointers. once you have a struct here. you are passing a struct val of two pointers with the possiblity of url.Values being a ref, i think. Just doesn't seem worth the effort.
On the watch being the context, yeah I can get onboard.
There was a problem hiding this comment.
The CloseWatch is there to close specific watches but if the Watch returned a closeable, then that would be more ergonomic. Maybe we can have a WatchLocation(ctx, loc) value, Watchable, error
type Watchable interface {
Values() <-string
Close() error
}
wdyt?
There was a problem hiding this comment.
Yep, fair point on not making the Watch arguments a struct. The net.Dial analogy makes sense to me. keeping the lower-level API string-based is probably more convenient
The main part I still like is collapsing the lifecycle around Watch, and I think your Watchable shape solves the CloseWatch concern cleanly.
Something like this should work:
type Strategy interface {
Watch(ctx context.Context, path, pathQry string) (Watchable, error)
}
type Watchable interface {
Values() <-chan string
Close() error
}and the usage:
w, err := strategy.Watch(ctx, path, pathQry)
if err != nil {
return err
}
defer w.Close()
for value := range w.Values() {
if err := apply(value); err != nil {
return err
}
}There was a problem hiding this comment.
Implemented in efb28ef. Final shape:
type Strategy interface {
Watch(ctx context.Context, pth, pathQry string) (value string, watch Watchable, err error)
}
type Watchable interface {
Values() <-chan string
Close() error // idempotent
}Notes:
- Kept the synchronous initial value return (one deviation from your last snippet) so a bad config still fails fast at
sql.Openinstead of surfacing asynchronously. - Watch lifetime is
Watchable.Closeor ctx cancellation, whichever comes first — the core passes the group's parent context in, strategies wire cancellation withcontext.AfterFunc. CloseWatchand strategy-levelCloseare gone, and with them all the path/query re-parsing: the handle is the watch.- Each
Watchcall is now an independent subscription. That fixed a latent bug: subscribers used to be keyed by pathQry, so two DSNs differing only in the driver component shared one channel and split updates between them. - fsnotify now releases its OS watcher when the last watch closes (previously only the strategy-wide
Closedid).
MIGRATION.md has a new section for custom strategy authors. All modules green under -race, including the postgres integration suite.
|
|
||
| The hotload project ships with one hotload strategy: `fsnotify`. | ||
| ```go | ||
| import _ "github.com/infobloxopen/hotload/k8ssecret" |
There was a problem hiding this comment.
Would it be possible, or would it make sense, to make this registration explicit?
I understand this follows the database/sql driver pattern, but it feels less ergonomic in this case because strategies are stateful and often require configuration.
In particular, k8ssecret currently has to expose package-level mutable state, such as ClientsetFunc, so callers can influence the instance registered by init(). That makes configuration implicit and order-dependent: users need to import the package for side effects, mutate package globals before opening a DB, and trust that the registered singleton is the one they intended to configure.
I would prefer the blank import to remain available only as a backwards-compatible convenience, while the explicit/configured path is documented as the primary API.
Something like:
// Explicit — testable, no global, errors surface at the call site:
reg := hotload.NewRegistry()
reg.Register("fsnotify", fsnotify.NewStrategy())
reg.Register("k8ssecret", k8ssecret.NewStrategy(
k8ssecret.WithClientset(cs), // inject deps here, no package global
))
db := sql.OpenDB(hotload.NewConnector(reg, "k8ssecret://pgx/db?..."))Instead of:
import _ "…hotload/fsnotify" // magic, registers itselfThere was a problem hiding this comment.
isn't the clientset optional? it should be. we don't support double registration so we have to pick a path and stay on it.
There was a problem hiding this comment.
I guess my main concern here is less about Clientset specifically being required/optional, and more about how the strategy instance gets registered.
With the blank import path:
import _ "github.com/infobloxopen/hotload/k8ssecret"the package is effectively doing NewStrategy() inside init() and registering that instance globally.
That means any configuration for that strategy has to happen through package-level mutable state, and callers have to know to set that state before the package is used/opened.
I’m mainly suggesting that for v3 we pick the explicit path the only option. The important part to me is that the configured strategy instance is constructed and registered explicitly by the caller, not hidden behind init()
My previus example was not that good as I double register strategies:
reg := hotload.NewRegistry()
reg.Register("k8ssecret", k8ssecret.NewStrategy())
db := sql.OpenDB(hotload.NewConnector(reg, "k8ssecret://pgx/db?..."))There was a problem hiding this comment.
After looking more closely at how things are organized, I’m not sure this would be feasible or make sense. It changes a lot the way this operates.
Maybe this is a cleaner snippet showing what I envision for a more explicit driver declaration and usage:
db := sql.OpenDB(hotload.NewConnector(
fsnotify.Watch("fsnotify://postgres/tmp/myconfig.txt"),
hotload.Driver("postgres", &pq.Driver{}),
))Collapse Strategy to a single Watch method returning the current value plus a Watchable (Values/Close), per the PR #68 interface discussion. CloseWatch and Close are gone: per-watch lifetime is the returned handle or the Watch context, whichever ends first, so strategies no longer re-parse path/query to find the watch to close. Every Watch call now establishes an independent subscription with its own channel (previously two DSNs mapping to the same path+query silently shared one channel and split updates between them). The fsnotify strategy also releases its OS watcher when the last watch closes. The fake strategy moves out of internal/dbfake into the test packages: the interface now names hotload.Watchable, so a fake must import hotload, which dbfake cannot (internal tests import dbfake).
Two changes to soften the sharpest v1-to-v3 uptake hazards without reintroducing a metrics dependency in the core: EnablePrometheus with a nil (or default) registerer is now idempotent: the first call registers collectors and hooks, later calls return the same collectors. v1 enabled metrics as an import side effect, so ported code may reasonably enable defensively in both an application and a shared library; previously the second call panicked on duplicate registration. Explicit registerers keep per-call fresh collectors for tests. The core logs a one-time notice through the error logger when the first watch starts with no hooks registered. Without it, a service that ports the import paths and nothing else loses its hotload prometheus metrics with no signal anywhere; the notice points at the observability module and MIGRATION.md. Registering hooks or replacing the error logger silences it.
The path-chksum collector hashes local files at scrape time, but the generic watch event feeds it paths from every strategy — a k8ssecret watch would register its Secret name as a path, producing a bogus zero-valued series and a hashing error on every scrape. Filter to the fsnotify strategy, which is exactly what v1 tracked (only the fsnotify strategy called AddToDefaultPathChksum); custom file-backed strategies can call AddPath directly. Also document the branch layout near the top of the README: main is v3, release-1.x is the v1 maintenance line.
Collapse Strategy to a single Watch method returning the current value plus a Watchable (Values/Close), per the PR #68 interface discussion. CloseWatch and Close are gone: per-watch lifetime is the returned handle or the Watch context, whichever ends first, so strategies no longer re-parse path/query to find the watch to close. Every Watch call now establishes an independent subscription with its own channel (previously two DSNs mapping to the same path+query silently shared one channel and split updates between them). The fsnotify strategy also releases its OS watcher when the last watch closes. The fake strategy moves out of internal/dbfake into the test packages: the interface now names hotload.Watchable, so a fake must import hotload, which dbfake cannot (internal tests import dbfake).
Summary
Full rewrite of the hotload driver as
github.com/infobloxopen/hotload/v3. The driver name, DSN format (strategy://driver/path?forceKill=...), registration functions,Strategyinterface, and graceful/forceKill semantics are unchanged — for most applications the upgrade is the import-path change. SeeMIGRATION.md.Truthful driver capabilities
database/sqldiscovers capabilities by type-asserting optional interfaces on the driver conn. v1 implemented a fixed set regardless of the underlying driver (papering over gaps withdriver.ErrSkip) and never implementedConnPrepareContext/Pingeror wrappeddriver.Stmt. v3 wraps conns and stmts so an optional interface exists iff the underlying driver supports it:internal/gen, run viago generate, CI fails on diff): 16 conn combos over {ExecerContext, QueryerContext, Pinger, NamedValueChecker} and 16 stmt combos over {StmtExecContext, StmtQueryContext, ColumnConverter, NamedValueChecker}.Execer/Queryercollapse into their context flavors, withdatabase/sql's legacy fallback replicated internally; the wrapper never advertises the legacy interfaces.ConnPrepareContext,ConnBeginTx,SessionResetter,Validatorare always implemented, replicating the stdlib fallback exactly (hotload's drain/evict machinery needs the latter two).transaction_sql_stmts.Lifecycle rewrite
chanGroupis replaced by a group/generation model: one generation owns the cancel context, conn set, and drained flag for one DSN value, so a config change is a pointer swap. No driver call, hook, or strategy call happens under any lock — removing the nested-lock deadlock class (573e701) and its 1ms-sleep workaround.teivah/onecontextreplaced with acontext.AfterFunccancellation relay: no goroutine per merge, unregistered synchronously, fixing the leak class behind 06f03af.QueryContext/BeginTxstill pass the caller context through unmerged (402e736 semantics preserved).hotload.ErrHotSwapand waits a boundedkillWindow(new DSN param, default 100ms) before force-closing, so a driver that ignores cancellation can never wedge change processing. Dials happen outside locks, so a slow dial no longer blocks the run loop.driver.DriverContext; connectors are refcounted per DSN andDB.Closenow tears down the strategy watch and run loop (v1 leaked both). Config errors surface atsql.Openinstead of first use.Dependency diet
Root
go.modrequires exactlygithub.com/fsnotify/fsnotify(CI-enforced viamake dep-budget). prometheus,pkg/errors,google/uuid,onecontext,gaugefuncvec, ginkgo/gomega, sqlmock, and lib/pq leave the consumer module graph:hotload.RegisterHooks); the newobservability/nested module adapts them to prometheus with v1's exact metric names (registration is now explicit — the loudest item inMIGRATION.md).gaugefuncvecis replaced by a hand-rolled scrape-time collector.test/integrationnested module (lib/pq + docker-compose, opt-in via env).k8ssecret strategy (ports #66 onto v3)
A new
k8ssecret/nested module implements the strategy idea from #66: watching a Kubernetes Secret through the API server for deployments that need credentials from another namespace (which Kubernetes cannot mount as a volume). Same DSN shape (k8ssecret://<driver>/<secret>?namespace=<ns>&dsn=<key>), separate go.mod so client-go stays out of the core. Beyond the port, it fixes four defects in the #66 implementation:uri.Path, so the secret name arrives with a leading slash; Add k8ssecret strategy for cross-namespace secret watching #66 used it verbatim and would look up a secret named/myapp-db(its tests calledWatchdirectly with bare names, masking this). Pinned here by an end-to-end test throughsql.Open.Addedevents (recreated secrets) are handled too.fsnotify strategy fixes
CloseWatchin v1); update delivery is now non-blocking (latest value wins), so a dead subscriber can no longer wedge every watcher sharing the strategy...datasymlink swaps are now detected on kqueue platforms (events arrive under the resolved target path; unknown names conservatively resync all watched paths).WatchafterCloseno longer panics.Tests
All stdlib
testing(ginkgo/gomega removed). New suites:internal/dbfake, views generated alongside the combos) drives interface-truthfulness matrices with positive and negative assertions over every combo, plusdatabase/sql-level dispatch tests proving the pool takes the intended fallback paths.ErrHotSwapand exactly-one close), bounded killWindow with a cancellation-ignoring driver, a→b→a, teardown/refcounting, reopen-while-closing race (regression test fails on the unfixed code), change storms under-racewith goroutine-leak checks (internal/testutil).Release notes
RELEASING.mddocuments the nested-module tag ordering (rootv3.0.0first, then bumpobservability/go.mod's require and tagobservability/v1.0.0) and a consumer smoke check. Thereplaceinobservability/go.modis for in-repo development; consumers resolve the pinned require.Test plan
make ci-test(fmt, tidy, generate diff-check, vet,-racetests for all three modules, dep budget)make local-integration-tests)