Skip to content

Commit dfa55dd

Browse files
Merge pull request #117 from toolsascode/feat-live-update
chore: Auto-migrate and denpendencies
2 parents 420bf29 + 07ef9b4 commit dfa55dd

39 files changed

Lines changed: 1939 additions & 455 deletions

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ repos:
100100
hooks:
101101
- id: eslint
102102
files: ^ffm/.*\.(js|jsx|ts|tsx)$
103-
exclude: ^ffm/(node_modules|dist|build)/
103+
exclude: ^ffm/(node_modules|dist|build)/|^ffm/public/runtime-config\.js$
104104
additional_dependencies:
105105
- eslint@^8.56.0
106106
- '@typescript-eslint/parser@^8.47.0'

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Backend For Migrations (BfM)
1313

1414
</p>
1515

16-
BfM is a comprehensive database migration system that supports multiple backends (PostgreSQL, GreptimeDB, Etcd) with HTTP and Protobuf APIs.
16+
BfM is a comprehensive database migration system that supports multiple backends (PostgreSQL, GreptimeDB, Etcd) with HTTP and Protobuf APIs. It centralizes migrations so that in scalable deployments many applications do not run the same migrations concurrently. It also supports dynamic schema creation, which fits workloads that use many schemas and need to create them on demand.
1717

1818
## Features
1919

api/cmd/server/auto_migrate.go

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"os"
6+
"sort"
7+
"strconv"
8+
"strings"
9+
"time"
10+
11+
"github.com/toolsascode/bfm/api/internal/backends"
12+
"github.com/toolsascode/bfm/api/internal/config"
13+
"github.com/toolsascode/bfm/api/internal/executor"
14+
"github.com/toolsascode/bfm/api/internal/logger"
15+
"github.com/toolsascode/bfm/api/internal/registry"
16+
)
17+
18+
// autoMigrateDefaultOn is true when BFM_AUTO_MIGRATE is unset. Set BFM_AUTO_MIGRATE=false to disable.
19+
func autoMigrateEnabled() bool {
20+
v := strings.TrimSpace(strings.ToLower(os.Getenv("BFM_AUTO_MIGRATE")))
21+
if v == "" {
22+
return true
23+
}
24+
return v != "false" && v != "0" && v != "off" && v != "no"
25+
}
26+
27+
// etcdEndpointsExtraNonEmpty returns true if Extra has a non-empty endpoints value (any key casing).
28+
func etcdEndpointsExtraNonEmpty(extra map[string]string) bool {
29+
if extra == nil {
30+
return false
31+
}
32+
for k, v := range extra {
33+
if strings.EqualFold(strings.TrimSpace(k), "endpoints") && strings.TrimSpace(v) != "" {
34+
return true
35+
}
36+
}
37+
return false
38+
}
39+
40+
// connectionConfigReadyForAutoMigrate reports whether conn has the minimum fields the corresponding
41+
// backend expects, so we do not dial empty hosts or etcd with no endpoints (avoids log spam).
42+
func connectionConfigReadyForAutoMigrate(conn *backends.ConnectionConfig) bool {
43+
if conn == nil {
44+
return false
45+
}
46+
switch strings.ToLower(strings.TrimSpace(conn.Backend)) {
47+
case "postgresql":
48+
return strings.TrimSpace(conn.Host) != ""
49+
case "greptimedb":
50+
return strings.TrimSpace(conn.Host) != ""
51+
case "etcd":
52+
if etcdEndpointsExtraNonEmpty(conn.Extra) {
53+
return true
54+
}
55+
return strings.TrimSpace(conn.Host) != "" && strings.TrimSpace(conn.Port) != ""
56+
default:
57+
return true
58+
}
59+
}
60+
61+
// autoMigrateRetryInterval returns the pause between full auto-migrate rounds. If the env value is
62+
// zero or negative, only one round is run (legacy single-pass behavior after the startup delay).
63+
func autoMigrateRetryInterval() time.Duration {
64+
v := strings.TrimSpace(os.Getenv("BFM_AUTO_MIGRATE_RETRY_INTERVAL"))
65+
if v == "" {
66+
return 5 * time.Second
67+
}
68+
d, err := time.ParseDuration(v)
69+
if err != nil {
70+
return 5 * time.Second
71+
}
72+
return d
73+
}
74+
75+
// autoMigrateRetryMaxRounds caps how many full passes run over all ready connections.
76+
// When retryInterval is <= 0, this is forced to 1.
77+
func autoMigrateRetryMaxRounds(retryInterval time.Duration) int {
78+
if retryInterval <= 0 {
79+
return 1
80+
}
81+
v := strings.TrimSpace(os.Getenv("BFM_AUTO_MIGRATE_RETRY_MAX_ROUNDS"))
82+
if v == "" {
83+
return 24
84+
}
85+
n, err := strconv.Atoi(v)
86+
if err != nil || n < 1 {
87+
return 24
88+
}
89+
return n
90+
}
91+
92+
type autoMigrateConn struct {
93+
name string
94+
cfg *backends.ConnectionConfig
95+
}
96+
97+
func sumPendingAutoMigratable(ctx context.Context, exec *executor.Executor, conns []autoMigrateConn) (int, error) {
98+
total := 0
99+
for _, c := range conns {
100+
select {
101+
case <-ctx.Done():
102+
return total, ctx.Err()
103+
default:
104+
}
105+
n, err := exec.CountPendingAutoMigratable(ctx, c.name, c.cfg.Backend)
106+
if err != nil {
107+
return total, err
108+
}
109+
total += n
110+
}
111+
return total, nil
112+
}
113+
114+
// startAutoMigrateBackground runs pending migrations per configured connection after startup,
115+
// retrying in bounded rounds until fixed-schema work is cleared, a stall is detected, or limits hit.
116+
// It uses the same ExecuteUp path as the HTTP API (synchronous execution, not the job queue).
117+
//
118+
// Limitations (documented for operators):
119+
// - Migrations with dynamic schema (empty migration.Schema) require an explicit schema in the request;
120+
// auto-migrate passes an empty schema, so those migrations are skipped with an info log until run manually with schemas.
121+
// - Optional BFM_AUTO_MIGRATE_CONNECTIONS (comma-separated) restricts which connection names are processed;
122+
// if unset, all connections from config are attempted.
123+
// - Connections with incomplete config for their backend (e.g. etcd without endpoints or host+port) are skipped.
124+
func startAutoMigrateBackground(ctx context.Context, exec *executor.Executor, cfg *config.Config) {
125+
if !autoMigrateEnabled() {
126+
logger.Info("BFM_AUTO_MIGRATE is disabled; skipping startup auto-migrate")
127+
return
128+
}
129+
130+
filterRaw := strings.TrimSpace(os.Getenv("BFM_AUTO_MIGRATE_CONNECTIONS"))
131+
var allow map[string]bool
132+
if filterRaw != "" {
133+
allow = make(map[string]bool)
134+
for _, p := range strings.Split(filterRaw, ",") {
135+
k := strings.TrimSpace(strings.ToLower(p))
136+
if k != "" {
137+
allow[k] = true
138+
}
139+
}
140+
}
141+
142+
go func() {
143+
select {
144+
case <-ctx.Done():
145+
return
146+
case <-time.After(2 * time.Second):
147+
}
148+
149+
retryInterval := autoMigrateRetryInterval()
150+
maxRounds := autoMigrateRetryMaxRounds(retryInterval)
151+
if retryInterval > 0 {
152+
logger.Infof("Auto-migrate: retry enabled (interval=%s, max_rounds=%d)", retryInterval, maxRounds)
153+
} else {
154+
logger.Info("Auto-migrate: single round only (BFM_AUTO_MIGRATE_RETRY_INTERVAL is 0 or invalid)")
155+
}
156+
157+
connNames := make([]string, 0, len(cfg.Connections))
158+
for name := range cfg.Connections {
159+
connNames = append(connNames, name)
160+
}
161+
sort.Strings(connNames)
162+
163+
var toRun []autoMigrateConn
164+
for _, connName := range connNames {
165+
if allow != nil && !allow[strings.ToLower(connName)] {
166+
continue
167+
}
168+
connCfg := cfg.Connections[connName]
169+
if connCfg == nil {
170+
continue
171+
}
172+
if !connectionConfigReadyForAutoMigrate(connCfg) {
173+
logger.Infof("Auto-migrate: skipping connection %q (backend=%s): incomplete connection config for auto-migrate", connName, connCfg.Backend)
174+
continue
175+
}
176+
toRun = append(toRun, autoMigrateConn{name: connName, cfg: connCfg})
177+
}
178+
179+
for round := 1; round <= maxRounds; round++ {
180+
select {
181+
case <-ctx.Done():
182+
logger.Info("Auto-migrate cancelled during shutdown")
183+
return
184+
default:
185+
}
186+
187+
pendingBefore, err := sumPendingAutoMigratable(ctx, exec, toRun)
188+
if err != nil {
189+
logger.Errorf("Auto-migrate: failed to count pending migrations: %v", err)
190+
break
191+
}
192+
if pendingBefore == 0 {
193+
logger.Info("Auto-migrate: no pending fixed-schema migrations for ready connections")
194+
logger.Info("Auto-migrate: startup pass completed")
195+
return
196+
}
197+
198+
logger.Infof("Auto-migrate: round %d/%d (%d pending fixed-schema migration(s) across ready connections)", round, maxRounds, pendingBefore)
199+
200+
anyApplied := false
201+
anyErr := false
202+
for _, cr := range toRun {
203+
select {
204+
case <-ctx.Done():
205+
logger.Info("Auto-migrate cancelled during shutdown")
206+
return
207+
default:
208+
}
209+
210+
target := &registry.MigrationTarget{
211+
Backend: cr.cfg.Backend,
212+
Connection: cr.name,
213+
}
214+
runCtx := executor.WithAutoMigrateContext(executor.SetExecutionContext(context.Background(), "bfm-server", "auto_migrate", map[string]interface{}{
215+
"connection": cr.name,
216+
"source": "BFM_AUTO_MIGRATE",
217+
"round": round,
218+
}))
219+
220+
logger.Infof("Auto-migrate: running pending migrations for connection %q (backend=%s)", cr.name, cr.cfg.Backend)
221+
result, err := exec.ExecuteUp(runCtx, target, cr.name, []string{""}, false, false)
222+
if err != nil {
223+
anyErr = true
224+
logger.Errorf("Auto-migrate: ExecuteUp failed for connection %q: %v", cr.name, err)
225+
continue
226+
}
227+
if len(result.Applied) > 0 {
228+
anyApplied = true
229+
logger.Infof("Auto-migrate: applied for %q: %v", cr.name, result.Applied)
230+
}
231+
if len(result.Skipped) > 0 {
232+
logger.Debug("Auto-migrate: skipped for %q (already applied): %v", cr.name, result.Skipped)
233+
}
234+
if len(result.Errors) > 0 {
235+
anyErr = true
236+
for _, e := range result.Errors {
237+
logger.Warnf("Auto-migrate: error for %q: %s", cr.name, e)
238+
}
239+
}
240+
}
241+
242+
pendingAfter, err := sumPendingAutoMigratable(ctx, exec, toRun)
243+
if err != nil {
244+
logger.Errorf("Auto-migrate: failed to count pending migrations after round: %v", err)
245+
break
246+
}
247+
if pendingAfter == 0 {
248+
logger.Info("Auto-migrate: all auto-migratable migrations applied")
249+
logger.Info("Auto-migrate: startup pass completed")
250+
return
251+
}
252+
if !anyApplied && !anyErr && pendingAfter == pendingBefore {
253+
logger.Warnf("Auto-migrate: no progress after round %d (%d pending fixed-schema migration(s) unchanged); check backend/connection alignment and logs. Stopping retries.", round, pendingAfter)
254+
logger.Info("Auto-migrate: startup pass completed")
255+
return
256+
}
257+
if round == maxRounds {
258+
logger.Warnf("Auto-migrate: reached max rounds (%d); %d pending auto-migratable migration(s) remain", maxRounds, pendingAfter)
259+
logger.Info("Auto-migrate: startup pass completed")
260+
return
261+
}
262+
if retryInterval <= 0 {
263+
logger.Info("Auto-migrate: startup pass completed")
264+
return
265+
}
266+
select {
267+
case <-ctx.Done():
268+
logger.Info("Auto-migrate cancelled during shutdown")
269+
return
270+
case <-time.After(retryInterval):
271+
}
272+
}
273+
274+
logger.Info("Auto-migrate: startup pass completed")
275+
}()
276+
}

0 commit comments

Comments
 (0)