gitmote

gitmote  ·  history  ·  branches

feat: promote on demand at the request gate to drop the graceful-deploy 503

5cc35ce5158594c6dddd47d343ec9e455b6cb5be
Atanas Minev <atmin@protonmail.com> · 2026-07-19 09:54

diff --git a/cmd/gitmote/main.go b/cmd/gitmote/main.go
index 42817ad..82f5e2c 100644
--- a/cmd/gitmote/main.go
+++ b/cmd/gitmote/main.go
@@ -37,6 +37,11 @@ var version = "dev"
const shutdownTimeout = 10 * time.Second
+// promoteTimeout bounds an on-demand lease acquisition on the request path (see
+// the gate in buildGitHandler): a follower blocks at most this long for the
+// restore before falling back to 503, so a slow S3 can't hang a request.
+const promoteTimeout = 10 * time.Second
+
// defaultListenAddr is the bind address in the container, where ports are mapped
// externally. GITMOTE_LISTEN_ADDR overrides it — for running a second native
// instance beside `make dev` (e.g. the break-glass self-deploy on :8081).
@@ -195,7 +200,7 @@ func run(ctx context.Context, logger *slog.Logger, addr string) error {
ctx, stop := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
defer stop()
- gitHandler, ui, reportAPI, isLeader, closeMeta, err := buildGitHandler(ctx, logger)
+ gitHandler, ui, reportAPI, gate, closeMeta, err := buildGitHandler(ctx, logger)
if err != nil {
return err
}
@@ -207,7 +212,7 @@ func run(ctx context.Context, logger *slog.Logger, addr string) error {
srv := &http.Server{
Addr: addr,
- Handler: newHandler(gitHandler, ui, reportAPI, isLeader),
+ Handler: newHandler(gitHandler, ui, reportAPI, gate),
}
if reportAPI != nil {
@@ -261,10 +266,11 @@ func reconcileLoop(ctx context.Context, reportAPI *ci.ReportAPI, logger *slog.Lo
// git catch-all). gitHandler, when non-nil, serves the git smart-HTTP endpoints
// at "/"; the exact health/version routes stay more specific.
//
-// isLeader gates every metadata-derived response on the writer lease (see
+// gate decides whether this instance may answer a metadata-derived request (see
// leaderGate): a follower — the brief post-deploy window before it promotes —
-// serves a frozen, stale snapshot, so it must not answer with stale refs.
-func newHandler(gitHandler http.Handler, ui *webui.Handler, reportAPI *ci.ReportAPI, isLeader func() bool) http.Handler {
+// serves a frozen, stale snapshot, so it must not answer with stale refs. The
+// gate promotes on demand when it can, else refuses.
+func newHandler(gitHandler http.Handler, ui *webui.Handler, reportAPI *ci.ReportAPI, gate func(context.Context) bool) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprintln(w, "ok")
@@ -281,7 +287,7 @@ func newHandler(gitHandler http.Handler, ui *webui.Handler, reportAPI *ci.Report
if gitHandler != nil {
mux.Handle("/", gitHandler)
}
- return leaderGate(mux, isLeader)
+ return leaderGate(mux, gate)
}
// leaderGate serves stale-free: only the writer (leader) answers requests that
@@ -293,10 +299,16 @@ func newHandler(gitHandler http.Handler, ui *webui.Handler, reportAPI *ci.Report
// Exceptions that must stay up on a follower: the liveness probes (Scaleway's
// health check — gating them would deadlock a rolling deploy, since the new
// instance can't promote until the old one drains) and static assets (no
-// metadata). A nil isLeader (unreplicated dev, sole writer) means always-leader.
-func leaderGate(next http.Handler, isLeader func() bool) http.Handler {
+// metadata). A nil gate (unreplicated dev, sole writer) means always-leader.
+//
+// The gate promotes on demand: during a graceful deploy the old writer has
+// released its lease by the time the first request reaches the new instance, so
+// the gate acquires it here — blocking for the restore — and serves, instead of
+// refusing until the background poll. It still returns 503 when the lease is
+// genuinely held elsewhere (e.g. a hard kill, until the old lease expires).
+func leaderGate(next http.Handler, gate func(context.Context) bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if alwaysServable(r.URL.Path) || isLeader == nil || isLeader() {
+ if alwaysServable(r.URL.Path) || gate == nil || gate(r.Context()) {
next.ServeHTTP(w, r)
return
}
@@ -323,7 +335,7 @@ func alwaysServable(p string) bool {
// The db, cache, and socket derive from GITMOTE_DATA (see dataDir); metadata
// replicates to a root-derived replica (see replicaTarget). The push path
// installs the pre-receive hook binary (gitmote-hook, beside this binary).
-func buildGitHandler(ctx context.Context, logger *slog.Logger) (http.Handler, *webui.Handler, *ci.ReportAPI, func() bool, func() error, error) {
+func buildGitHandler(ctx context.Context, logger *slog.Logger) (http.Handler, *webui.Handler, *ci.ReportAPI, func(context.Context) bool, func() error, error) {
noop := func() error { return nil }
if os.Getenv("GITMOTE_S3_BUCKET") == "" {
@@ -347,6 +359,26 @@ func buildGitHandler(ctx context.Context, logger *slog.Logger) (http.Handler, *w
logger.Warn("lost the writer lease; now read-only", "error", err)
})
+ // gate answers "may this instance serve a metadata request?" — and promotes on
+ // demand when it can. During a graceful deploy the old writer has released its
+ // lease by the time the first request lands here, so TryPromote acquires it
+ // (blocking for the restore) instead of the request waiting out the background
+ // poll; OnPromote above logs the transition. TryPromote's IsLeader fast path
+ // makes the steady-state call free, and it returns false (not an error) while
+ // the lease is still held elsewhere, so a follower under a live writer stays
+ // quiet and 503s. Per-request promotion is safe only because max-scale=1 keeps
+ // this the sole instance outside the deploy window (docs/ops.md). Bounded so a
+ // slow restore falls back to 503 rather than hanging the request.
+ gate := func(reqCtx context.Context) bool {
+ pctx, cancel := context.WithTimeout(reqCtx, promoteTimeout)
+ defer cancel()
+ ok, err := md.TryPromote(pctx)
+ if err != nil {
+ logger.Warn("on-demand promotion failed; refusing request", "error", err)
+ }
+ return ok
+ }
+
// Objects, CI logs, and the metadata replica all live under the storage root
// (GITMOTE_S3_BUCKET, "bucket" or "bucket/base"), so they share fate: point at
// the wrong root and nothing restores (a clean, empty instance), never the
@@ -497,7 +529,7 @@ func buildGitHandler(ctx context.Context, logger *slog.Logger) (http.Handler, *w
_ = cleanup()
return nil, nil, nil, nil, noop, err
}
- return handler, ui, reportAPI, md.IsLeader, cleanup, nil
+ return handler, ui, reportAPI, gate, cleanup, nil
}
// buildUI constructs the management UI (always on). Its session cookie key is
diff --git a/cmd/gitmote/main_test.go b/cmd/gitmote/main_test.go
index b595958..c151554 100644
--- a/cmd/gitmote/main_test.go
+++ b/cmd/gitmote/main_test.go
@@ -156,7 +156,7 @@ func TestLeaderGate(t *testing.T) {
w.WriteHeader(http.StatusTeapot)
})
leader := false
- srv := httptest.NewServer(newHandler(gitHandler, nil, nil, func() bool { return leader }))
+ srv := httptest.NewServer(newHandler(gitHandler, nil, nil, func(context.Context) bool { return leader }))
defer srv.Close()
get := func(path string) int {
diff --git a/docs/architecture/safety.md b/docs/architecture/safety.md
index dc7a5ac..6ffe899 100644
--- a/docs/architecture/safety.md
+++ b/docs/architecture/safety.md
@@ -15,7 +15,8 @@
conditional writes** (compare-and-swap: `If-None-Match`/`If-Match`) a correctness
requirement — the lease can't be enforced without it, so a provider lacking the
primitive cannot safely back gitmote. gitmote gates **all metadata-derived requests** on
- `IsLeader()` — a follower refuses them with a retryable `503`; only the liveness
+ the writer lease — a follower **promotes on demand** (`s3lite.TryPromote`, the same
+ CAS) and, until it can, refuses them with a retryable `503`; only the liveness
probes and static assets stay up (gating those would deadlock a rolling deploy,
since the new instance can't promote until the old drains). Writes are gated for
*safety*, reads for *freshness*: a follower serves only the snapshot it restored
@@ -24,7 +25,8 @@
A rolling deploy's
brief old+new overlap is therefore one writer + one follower: the new instance
boots as a follower, the old releases the lease on its graceful SIGTERM `Close`,
- and the new promotes on its next lease poll. **Overlap is safe by construction**,
+ and the new promotes on demand as the first request arrives (or its next lease
+ poll). **Overlap is safe by construction**,
so there is no stop-first drain. This is the writer half of
[reader-writer-split](../evolution/reader-writer-split.md), shipped; the
remaining half — *fresh* followers that scale reads out (a follower serves only
diff --git a/docs/ops.md b/docs/ops.md
index f557c7c..d445426 100644
--- a/docs/ops.md
+++ b/docs/ops.md
@@ -39,19 +39,25 @@ runs as a read-only follower otherwise. This makes **atomic conditional writes a
provider**: the lease — and therefore the single-writer correctness invariant —
holds only if the backend supports the compare-and-swap primitive (`If-None-Match`
to acquire, `If-Match` to renew/release). Most do (AWS S3, Scaleway, …); one that
-doesn't cannot safely back gitmote. gitmote gates receive-pack on `IsLeader()`, so
-a follower refuses pushes with a retryable `503` while still serving clones/fetches.
+doesn't cannot safely back gitmote. gitmote gates every metadata-derived response
+on the writer lease (`leaderGate`): a follower **promotes on demand** — the request
+triggers an immediate acquisition (`s3lite.TryPromote`) and, once it restores the
+latest state, serves — and refuses with a retryable `503` only when the lease is
+still held elsewhere (liveness probes and static assets are always served).
- **Rolling deploys are safe by construction.** A rolling deploy briefly runs a
second container alongside the old one — two instances live at once — which does
**not** violate the single-writer invariant. The new instance boots as a
**follower** (the old still holds the lease); the old releases on its graceful
SIGTERM (`Close` flushes, then releases the lease last); the new then
- **promotes** on its next lease poll. Never two writers — no drain step, no
- `POST /admin/quit`. Brief handoff window: the new instance is up
- read-only for ≤ ~lease-TTL/3 after the old exits, so a push in that gap gets the
- retryable `503`; reads are unaffected. (After a *hard* kill the successor waits
- out the ≤30 s lease TTL before acquiring — a graceful exit releases at once.)
+ **promotes** on demand — the first metadata request to reach it after the old
+ releases acquires the freed lease (`TryPromote` blocks ≤ `promoteTimeout` for the
+ restore, then serves) — or, absent traffic, on its next background lease poll.
+ Never two writers — no drain step, no `POST /admin/quit`. **No handoff `503` on a
+ graceful deploy:** the triggering request blocks briefly for the restore instead
+ of being refused. A `503` remains only when the lease is genuinely held — after a
+ *hard* kill the successor can't acquire until the ≤30 s TTL expires, so requests
+ in that window get the retryable `503`; a graceful exit releases at once.
- **`max-scale = 1`** still holds, re-asserted on every deploy. Not for the
writer invariant anymore (the lease covers that) but because a follower serves
a *restored snapshot* and polls — it is not a continuously-fresh read replica —
diff --git a/go.mod b/go.mod
index d47997d..1047421 100644
--- a/go.mod
+++ b/go.mod
@@ -4,7 +4,7 @@ go 1.26.1
require (
github.com/alecthomas/chroma/v2 v2.27.0
- github.com/atmin/s3lite v0.2.0
+ github.com/atmin/s3lite v0.4.0
github.com/aws/aws-sdk-go-v2 v1.42.1
github.com/aws/aws-sdk-go-v2/config v1.32.27
github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.2.13
diff --git a/go.sum b/go.sum
index 3f80fe2..96ee8d8 100644
--- a/go.sum
+++ b/go.sum
@@ -32,8 +32,8 @@ github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.3.0 h1:wQlqotpyjYPjJz+Noh5bRu7Snmydk8SKC5Z6u1CR20Y=
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.3.0/go.mod h1:FTzydeQVmR24FI0D6XWUOMKckjXehM/jgMn1xC+DA9M=
-github.com/atmin/s3lite v0.2.0 h1:p8vgtZ15SjGEC725LQy0JhawILyRx7XHTschXLVt80A=
-github.com/atmin/s3lite v0.2.0/go.mod h1:2dzs56UfxacqgIFhOnH51vNhBuOYWrXZKd/PW1AIVmQ=
+github.com/atmin/s3lite v0.4.0 h1:oeHA/Hgrj9KB/Ony78+pKkqpbl65Ptug6muEEGsrX/Y=
+github.com/atmin/s3lite v0.4.0/go.mod h1:/J/01OC1ECMtOqgpOfPdzhpgz0bfZCSYoVioIJ/GsM0=
github.com/aws/aws-sdk-go-v2 v1.42.1 h1:9eOTgu1z/dVtYpNZ3/8/XbbaX0x/BqE3HUzAzs6K0ek=
github.com/aws/aws-sdk-go-v2 v1.42.1/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 h1:3IZY0XAJquT3aHzbkHfPzy4ACPcEjVG0x87KOwtpqGY=
diff --git a/internal/meta/meta.go b/internal/meta/meta.go
index e74368f..fe53c71 100644
--- a/internal/meta/meta.go
+++ b/internal/meta/meta.go
@@ -155,6 +155,13 @@ func (m *Metadata) IsLeader() bool { return m.db.IsLeader() }
// takeover; 0 when no lease is held.
func (m *Metadata) Generation() int64 { return m.db.Generation() }
+// TryPromote attempts to acquire the writer lease immediately instead of waiting
+// for the background lease poll, so a follower that receives a request during a
+// deploy handoff can promote on the spot. Returns true if this instance is (or
+// has just become) the writer, false if the lease is still held elsewhere. It
+// blocks for the restore while promoting, so bound ctx.
+func (m *Metadata) TryPromote(ctx context.Context) (bool, error) { return m.db.TryPromote(ctx) }
+
// OnPromote registers a callback fired after this instance becomes the writer
// (acquired the lease and started replicating).
func (m *Metadata) OnPromote(fn func()) { m.db.OnPromote(fn) }