# The push cooldown: what it still buys, and what removing it would cost
> **design**: open. Option C (§7), the failure backoff, is built in
> [`retry.go`](../../internal/git/retry.go) (#412), one schedule with parent recovery's probe since
> #413. The success cooldown is unchanged. Concurrent commit windows (#467, #476) changed where it
> engages (§3). Option D needs the measurements in §9; option E stays rejected without them.
> Index: [`../INDEX.md`](../INDEX.md). Reviewed at `11b8b4e0` on 2026-11-10.
> Related: [`../api-first-publication.md`](../api-first-publication.md),
> [`concurrent-author-windows.md`](concurrent-author-windows.md),
> [`../spec/commit-window-refactor.md`](../spec/commit-window-refactor.md)
**The question.** `PushCooldown` predates the commit window. The window groups an editing burst into
one commit, which is most of what the cooldown was invented to do. Is the cooldown still earning its
place, and would removing it simplify the worker?
**The short answer.** One window closing on its own idle timer is already spaced past the cooldown
(§2). The cooldown batches the commits that land close together anyway: windows closed early (a
write of another `GitTarget`, an overlapping or unproven footprint, the buffer limit,
`idleTimeout: 0s`), and windows open side by side whose idle timers run out within seconds of each
other (§3). Removing the success wait would remove `lastPushAt`, `pushTimer` and their scheduling
branch; the retry timer and the retained-write machinery stay. Whether that is worth the extra
publications is the measured decision in §9.
## Outage handling is not the cooldown's job
Failure backoff paces attempts at Git (`retry.go`); capacity admission pauses intake for the
affected worker and resumes producers once the accepted backlog settles
([recovery contract](gittarget-branch-worker-pending-writes.md#recovery-contract)). Both are built.
Neither needed the success cooldown, and removing it would close none of their gaps. Neither one
supplies storage durability: that is the HA plan's.
## 1. What it is today
`PushCooldown` is a fixed `5s` per worker. `maybeSchedulePush`
([`branch_worker.go`](../../internal/git/branch_worker.go)) runs after every local commit:
```go
if len(l.pendingWrites) == 0 || l.awaitingRetry() { return } // a failed attempt has its own deadline
if l.lastPushAt.IsZero() || (l.flushing && l.pendingUpTo(l.flushMark)) {
l.pushPending(); return // never pushed, or an apply waits: go now
}
if !l.cooldownHoldsSaves && l.pendingCarriesCommitRequest() {
l.pushPending(); return // a save waits: go now
}
if elapsed := now - l.lastPushAt; elapsed >= PushCooldown { l.pushPending(); return }
if l.pushTimer == nil { l.pushTimer = time.NewTimer(PushCooldown - elapsed) } // wait out the rest
```
`lastPushAt` advances **only on a successful push**, so the cooldown paces successful publication
and never retries; the retry schedule does that. Two pushes skip it:
- **An apply flush.** The writes up to the flush mark go at once, because the apply waits for them
([the apply trigger](apply-trigger.md#flush-then-apply)).
- **A save.** A person waits for a `CommitRequest`, and an author has one save in progress at a time,
so saves cannot make the burst the cooldown exists to batch. `cooldownHoldsSaves` is a test seam
that keeps a save behind the cooldown.
Plain writes keep it.
## 2. One window closing on its own timer is already spaced
A window is per `(author, GitTarget)` and closes after `spec.commit.window.idleTimeout` of silence
(default `5s`, `DefaultCommitWindow`) or at `maxDuration` (default `1m`). With no early closure, a
writer's next window opens only after the previous commit, so its idle deadline already falls
outside the cooldown:
```mermaid
sequenceDiagram
participant E as Events
participant W as Window (5s idle)
participant P as Push
Note over E,P: One author, one target, both timers at 5s
E->>W: edit at t=0
Note over W: idle deadline t=5
W->>P: commit at t=5
P->>P: push at t=5, cooldown runs to t=10
E->>W: next edit at t=5+e
Note over W: a NEW window opens
its idle deadline is t=10+e
W->>P: commit at t=10+e
Note over P: cooldown expired at t=10
nothing was delayed, nothing was batched
```
When `idleTimeout >= PushCooldown`, the cooldown has always expired for that writer before there is
anything to hold. A shorter `idleTimeout`, an early closure, or other writers on the branch can make
it engage.
## 3. Where it still batches
Since #467 a worker holds a window per author side by side, as long as every footprint is proven and
none overlaps ([`window_admission.go`](../../internal/git/window_admission.go),
[concurrent-author-windows.md](concurrent-author-windows.md)). Two authors on one `GitTarget`
editing different files no longer close each other's windows. Before #467 they did, on every
alternation, and the cooldown was then the only batching left. That case is gone; these remain.
**Early closes.** A write still closes other windows first, so their commits land before its own,
when it cannot sit beside them:
| Source | Close reason |
| --- | --- |
| A write of another `GitTarget` on the same branch (per-target encryption and bootstrap stay serial) | `author-or-target-change` |
| A write, or an open window, whose footprint cannot be proven | `author-or-target-change` |
| A worker that fell back to one window at a time | `author-or-target-change`, after `footprint-fallback` |
| Overlapping footprints | `path-conflict` |
| The retained-byte budget | `buffer-limit` |
| `commit.window.idleTimeout: 0s` | every event commits on arrival |
| Resync snapshots and request records | they commit outside the windows |
Several `GitTarget`s sharing a branch is the multi-tenant shape, and there every switch between
targets is a close:
```mermaid
sequenceDiagram
participant E as Events
participant W as Windows
participant P as Push
Note over E,P: Targets A and B on one branch
idle 5s, cooldown 5s, previous push finished at t=0
E->>W: t=1 edit for A
Note over W: window opens for A
E->>W: t=2 edit for B
Note over W: another GitTarget: A's window
closes NOW, not at t=6
W->>P: commit 1 at t=2
Note over P: cooldown active, timer armed for t=5
E->>W: t=3 edit for A
W->>P: commit 2 at t=3
E->>W: t=4 edit for B
W->>P: commit 3 at t=4
P->>P: t=5: one push carries all three
Note over E,P: 2 requests. Without the cooldown: 6
```
**Side-by-side windows closing together.** Concurrent windows add a case of their own. Three
authors who stop editing at about the same time have three idle deadlines a second or two apart, so
three commits. Commit order is close order. The first push goes at once, and the cooldown carries
the other two in one push.
Ledger row 4 prices the batching: three commits published together cost **2** HTTP requests; three
separate publications cost **6**.
## 4. What removing the success cooldown would simplify
The hope ("it would clean up a lot of state") is half right.
**Goes away:**
| Item | Size |
| --- | --- |
| `lastPushAt` | 1 field |
| The elapsed-time branch in `maybeSchedulePush` | a few lines |
| `pushTimer`, `pushTimerAt` and their select arm | success-cooldown scheduling only |
| `PushCooldown` and its documentation | 1 constant, several doc paragraphs |
| `cooldownHoldsSaves` | a test seam |
**Stays exactly as it is:**
| Item | Why it is not the cooldown's |
| --- | --- |
| The retry schedule (`retry.go`) | A failed publication still needs a scheduled attempt |
| `pendingWrites` retention | A push can fail or be rejected; the writes must survive to be replayed |
| `baseTrusted` | The head-of-cycle fetch decision, unrelated to push cadence |
| `checkoutApplied` | A write that failed part-way, or a reset that discarded local commits |
| `materialize`, `refreshRemoteAndRebuildPendingWrites`, the replay path | Contention recovery |
```mermaid
flowchart TD
subgraph COOLDOWN["Owned by the cooldown - would be deleted"]
LPA["lastPushAt"]
MSP["the wait branch in maybeSchedulePush"]
end
subgraph FAILURE["Owned by push failure - would remain"]
PT["retry schedule"]
PW["pendingWrites retention"]
BT["baseTrusted"]
CA["checkoutApplied"]
RE["materialize: reset and replay"]
end
style COOLDOWN fill:#e8f5e9,stroke:#43a047
style FAILURE fill:#ffebee,stroke:#e53935
```
**The complexity that makes this worker hard to reason about is in the red box, and removing the
cooldown does not touch it.** Every defect found in PR #382 lived there.
## 5. The one thing it does change
Today "committed locally but not yet pushed" is a **normal** state: any burst of closes passes
through it. Without the cooldown it becomes an **exceptional** state, reached only when a push fails
or is rejected. That cuts both ways:
- **For removal.** A narrower window in which the retained-write paths can bite in production.
- **Against removal.** Those paths do not disappear; they run less, so their bugs are found later.
The five defects PR #382 fixed were all in paths the cooldown makes routine.
Tests set `loop.lastPushAt` to hold a push back and inspect retained state. Removing the cooldown
needs another way to reach that state without changing the failure path each test exercises.
## 6. Serialization limits concurrency, but does not batch queued commits
The branch worker is one goroutine, and Git operations run on it synchronously. While a push is in
flight, arriving events wait in the queue. After it returns, each dequeued item runs its handler,
which can close a window and call `maybeSchedulePush` before the next item is read. Without the
success wait, a backlog of early closes drained after a slow push would each cause a push.
Serialization keeps Git work to one operation at a time; it does not combine queued commits.
## 7. Options
| Option | What it is | Complexity | Verdict |
| --- | --- | --- | --- |
| **A. Keep the success cooldown** | Preserve successful-push spacing | Unchanged | Current default alongside C |
| **B. Delete the success cooldown** | Push after every commit unless the retry schedule applies | Removes `lastPushAt` and `pushTimer` | §3 becomes one push per early close |
| **C. Add a failure backoff, keep the cooldown** | Schedule failed publications independently of new writes | One retry timer shared with parent recovery | Built in #412; unified with parent recovery in #413 |
| **D. C, then shorten or drop the success cooldown** | Reduce the `5s` wait once §9 has priced it | Depends on the outcome | The measured follow-up to C |
| **E. Make it conditional or configurable** | Engage only with several windows or targets, or expose it on `GitProvider` | **+complexity, +API surface** | Rejected unless D measurably fails |
C came first because the retry gap was a defect. Before #412, `pushPending` stopped its timer on
failure and armed no replacement outside parent recovery, so a quiet branch kept its work
indefinitely, while continued arrivals each provoked a failed push. Dropping the success wait was
never the fix for that, and it is not an obvious simplification either: §3 is where it is
load-bearing.
```mermaid
stateDiagram-v2
direction LR
[*] --> Idle
Idle --> Publishing: a commit is retained
Publishing --> Idle: push succeeded, nothing retained
Publishing --> Backoff: push or recovery failed
Backoff --> Publishing: backoff elapsed, work still retained
Backoff --> Idle: nothing retained any more
note right of Idle
No timer armed.
A healthy idle branch is silent.
end note
```
## 8. Risks of removing it
Ordered by how much they should worry you.
1. **Request amplification on shared branches.** Several `GitTarget`s per branch is supported, and
that is where the cooldown is load-bearing. Option B turns row 4's `2` into `6`.
2. **Hosted Git rate limits.** With serialization the bound is one push per push duration, several
per second on a fast remote. Measure throttling on the supported hosts first.
3. **`commit.window.idleTimeout: 0s` becomes a push per event.** Someone who set `0s` for prompt
commits did not necessarily ask for prompt *pushes*.
4. **Losing a well-exercised path.** §5.
5. **Test migration.** Tests use `lastPushAt` to reach retained state; a careless replacement (for
example, stubbing `pushAtomicFn` to fail) changes what is tested.
6. **A latency expectation.** Plain writes would publish up to five seconds sooner. Saves and apply
flushes already skip the cooldown, so the visible change is smaller than it was.
## 9. What to measure before deciding
Extend the event-loop ledger from PR #382, which counts connections per scheduled workload:
| # | Workload | What it settles |
| --- | --- | --- |
| 1 | One author, one target, a burst at the default window, no early close | Confirms §2 |
| 2 | Three authors on one target, disjoint files, stopping together | Prices side-by-side windows closing together |
| 3 | Three `GitTarget`s on one branch, edited together | The multi-tenant bill for option B |
| 4 | Two authors on overlapping files (`path-conflict`) | Prices the early closes that remain on one target |
| 5 | `commit.window.idleTimeout: 0s`, ten events | Risk 3, as a number |
| 6 | A failing remote with continued arrivals, across a parent-recovery handoff | Confirms retry pacing |
## 10. Recommendation
1. **Keep the success cooldown** until the §9 rows exist. Changing publication cadence is its own
change with its own validation.
2. **Then consider option D.** If the numbers show early closes and clustered closes are rare or
cheap, shortening or dropping the wait is defensible. If they show what §3 predicts, keep it, and
the question is settled with evidence.