rollbridge 0.1.55 → 0.1.56
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/changelog.d/20261008-stale-stopped-connection-count.md +14 -0
- package/package.json +1 -1
- package/src/daemon.js +30 -2
- package/src/release-group.js +21 -1
- package/test/owner-replacement.test.js +6 -3
- package/test/release-group.test.js +57 -0
- package/test/stale-connection-handoff.test.js +84 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
### Fixed
|
|
2
|
+
|
|
3
|
+
- Stopped releases no longer carry a stale connection count into owner
|
|
4
|
+
replacement. When a drain times out the release's local connection count was
|
|
5
|
+
frozen non-zero, persisted into the daemon state, and replayed into every
|
|
6
|
+
successor daemon's owner-replace handoff. The successor daemon does not
|
|
7
|
+
retain the stopped release, so the replay failed the candidate with
|
|
8
|
+
`Incumbent listener reported unknown release <id>` and deadlocked every
|
|
9
|
+
subsequent deployment. A release that reaches terminal `stopped` now
|
|
10
|
+
abandons its local connection count (it owns no live proxy listener),
|
|
11
|
+
`restore()` no longer re-adopts a persisted count for a stopped release,
|
|
12
|
+
the serialized owner handoff skips stopped releases, and a non-zero report
|
|
13
|
+
for a release the daemon does not retain is reconciled to zero with a log
|
|
14
|
+
line instead of throwing, so a stale count can never wedge the node again.
|
package/package.json
CHANGED
package/src/daemon.js
CHANGED
|
@@ -832,7 +832,22 @@ export default class RollbridgeDaemon {
|
|
|
832
832
|
setListenerConnectionSource(sourceId, releaseId, connections) {
|
|
833
833
|
const release = this.releases.get(releaseId)
|
|
834
834
|
|
|
835
|
-
if (!release
|
|
835
|
+
if (!release) {
|
|
836
|
+
if (connections.http + connections.websocket > 0) {
|
|
837
|
+
// A live proxy connection is always attributed to a release that still listens. A
|
|
838
|
+
// non-zero report for a release this daemon does not retain is a stale count frozen by
|
|
839
|
+
// a drain timeout in an earlier owner; accounting for it here would deadlock every
|
|
840
|
+
// future handoff, so reconcile it to zero instead of failing the owner replacement.
|
|
841
|
+
this.logger("dropping stale connection state for unretained release", {connections, releaseId, sourceId})
|
|
842
|
+
}
|
|
843
|
+
const sourceReleases = this.listenerConnectionSources.get(sourceId)
|
|
844
|
+
|
|
845
|
+
if (sourceReleases) {
|
|
846
|
+
sourceReleases.delete(releaseId)
|
|
847
|
+
if (sourceReleases.size === 0) this.listenerConnectionSources.delete(sourceId)
|
|
848
|
+
}
|
|
849
|
+
return
|
|
850
|
+
}
|
|
836
851
|
const sourceReleases = this.listenerConnectionSources.get(sourceId) || new Map()
|
|
837
852
|
|
|
838
853
|
if (connections.http + connections.websocket === 0) sourceReleases.delete(releaseId)
|
|
@@ -855,10 +870,23 @@ export default class RollbridgeDaemon {
|
|
|
855
870
|
|
|
856
871
|
/** @returns {Record<string, Record<string, {http: number, websocket: number}>>} Exact live listener sources. */
|
|
857
872
|
serializedListenerConnectionSources() {
|
|
858
|
-
const sources = new Map(
|
|
873
|
+
const sources = new Map()
|
|
874
|
+
|
|
875
|
+
for (const [sourceId, releases] of this.listenerConnectionSources) {
|
|
876
|
+
const retained = new Map()
|
|
877
|
+
|
|
878
|
+
for (const [releaseId, connections] of releases) {
|
|
879
|
+
// A stopped release owns no live listener; its count is a stale drain timeout. Carrying
|
|
880
|
+
// it into a successor daemon would report a connection the successor cannot attribute.
|
|
881
|
+
if (this.releases.get(releaseId)?.state === "stopped") continue
|
|
882
|
+
retained.set(releaseId, connections)
|
|
883
|
+
}
|
|
884
|
+
if (retained.size > 0) sources.set(sourceId, retained)
|
|
885
|
+
}
|
|
859
886
|
const local = new Map()
|
|
860
887
|
|
|
861
888
|
for (const release of this.releases.values()) {
|
|
889
|
+
if (release.state === "stopped") continue
|
|
862
890
|
const connections = release.localConnections()
|
|
863
891
|
|
|
864
892
|
if (connections.http + connections.websocket > 0) local.set(release.releaseId, connections)
|
package/src/release-group.js
CHANGED
|
@@ -196,7 +196,9 @@ export default class ReleaseGroup extends EventEmitter {
|
|
|
196
196
|
this.drainStartedAt = snapshot.drainStartedAt
|
|
197
197
|
this.retirementError = snapshot.retirementError
|
|
198
198
|
this.stoppedAt = snapshot.stoppedAt
|
|
199
|
-
|
|
199
|
+
// A stopped release no longer owns a proxy listener, so a persisted nonzero count is a
|
|
200
|
+
// stale drain timeout; re-adopting it would poison this daemon's handoff to the next one.
|
|
201
|
+
if (snapshot.state !== "stopped") this.setTransferredConnections(snapshot.connections)
|
|
200
202
|
|
|
201
203
|
for (const processStatus of snapshot.processes) {
|
|
202
204
|
const baseId = processStatus.id.replace(/#\d+$/, "")
|
|
@@ -625,6 +627,22 @@ export default class ReleaseGroup extends EventEmitter {
|
|
|
625
627
|
}
|
|
626
628
|
}
|
|
627
629
|
|
|
630
|
+
/**
|
|
631
|
+
* Abandons every connection this daemon still counts for the release. A release that
|
|
632
|
+
* reaches terminal `stopped` owns no live proxy listener, so any remaining local count
|
|
633
|
+
* is a stale drain timeout. Clearing it here (rather than relying on each in-flight
|
|
634
|
+
* connection to release) keeps the invariant "a stopped release holds no connections"
|
|
635
|
+
* intact for state persistence, owner-replacement handoff, and future daemons.
|
|
636
|
+
*/
|
|
637
|
+
abandonLocalConnections() {
|
|
638
|
+
if (this.state !== "stopped") return
|
|
639
|
+
if (this.connectionCount === 0 && this.transferredConnections.http + this.transferredConnections.websocket === 0) return
|
|
640
|
+
this.connections = {http: 0, websocket: 0}
|
|
641
|
+
this.transferredConnections = {http: 0, websocket: 0}
|
|
642
|
+
this.connectionCount = 0
|
|
643
|
+
this.emit("drained")
|
|
644
|
+
}
|
|
645
|
+
|
|
628
646
|
/**
|
|
629
647
|
* Reconciles connections still owned by a prior daemon listener.
|
|
630
648
|
* @param {ReleaseConnections} connections - Exact incumbent listener counts.
|
|
@@ -721,6 +739,7 @@ export default class ReleaseGroup extends EventEmitter {
|
|
|
721
739
|
this.state = "stopped"
|
|
722
740
|
this.stoppedAt = new Date().toISOString()
|
|
723
741
|
this.releasePortReservations()
|
|
742
|
+
this.abandonLocalConnections()
|
|
724
743
|
}
|
|
725
744
|
|
|
726
745
|
/**
|
|
@@ -763,6 +782,7 @@ export default class ReleaseGroup extends EventEmitter {
|
|
|
763
782
|
this.state = "stopped"
|
|
764
783
|
this.stoppedAt = new Date().toISOString()
|
|
765
784
|
this.releasePortReservations()
|
|
785
|
+
this.abandonLocalConnections()
|
|
766
786
|
}
|
|
767
787
|
|
|
768
788
|
/** @returns {Promise<void>} Quiesces every release process without waiting for its drain. */
|
|
@@ -1649,10 +1649,13 @@ test("pruned release connection completion closes the incumbent listener session
|
|
|
1649
1649
|
expect(closeCount).toBe(1)
|
|
1650
1650
|
expect(daemon.incumbentListenerControl).toBe(undefined)
|
|
1651
1651
|
|
|
1652
|
+
// A nonzero count for a release the daemon no longer retains is a stale drain timeout, not a
|
|
1653
|
+
// live connection: reconcile it to zero and close the session instead of throwing, so the
|
|
1654
|
+
// owner handoff cannot be deadlocked by a pruned generation's frozen count.
|
|
1652
1655
|
daemon.incumbentListenerControl = controlSession
|
|
1653
|
-
|
|
1654
|
-
expect(closeCount).toBe(
|
|
1655
|
-
expect(daemon.incumbentListenerControl).toBe(
|
|
1656
|
+
expect(() => daemon.handleIncumbentListenerEvent({connections: {http: 1, websocket: 0}, event: "owner-connection-state", releaseId: "pruned"}, session)).not.toThrow()
|
|
1657
|
+
expect(closeCount).toBe(2)
|
|
1658
|
+
expect(daemon.incumbentListenerControl).toBe(undefined)
|
|
1656
1659
|
})
|
|
1657
1660
|
|
|
1658
1661
|
test("same-authority owner replacement preserves completed activation compensation without replaying hooks", async () => {
|
|
@@ -93,4 +93,61 @@ test("committed generation restoration does not start after shutdown begins", as
|
|
|
93
93
|
await expect(release.restartCommittedGeneration()).rejects.toThrow(/shutting down/)
|
|
94
94
|
expect(starts).toBe(0)
|
|
95
95
|
})
|
|
96
|
+
|
|
97
|
+
test("stopping a release abandons its connection count instead of persisting a stale drain timeout", async () => {
|
|
98
|
+
const release = buildRelease({command: "run web", id: "web", policy: "proxied", port: {from: 0, to: 0}})
|
|
99
|
+
// In-flight connections whose response never completes (a drain timeout) hold the count.
|
|
100
|
+
const stuck = [0, 1, 2, 3].map(() => release.retainConnection("http"))
|
|
101
|
+
|
|
102
|
+
expect(release.connectionCount).toBe(4)
|
|
103
|
+
|
|
104
|
+
await release.stop()
|
|
105
|
+
|
|
106
|
+
expect(stuck).toHaveLength(4)
|
|
107
|
+
expect(release.state).toBe("stopped")
|
|
108
|
+
expect(release.status().connectionCount).toBe(0)
|
|
109
|
+
expect(release.localConnections()).toEqual({http: 0, websocket: 0})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test("restoring a stopped release does not re-adopt a stale connection count", async () => {
|
|
113
|
+
const release = buildRelease({command: "run web", id: "web", policy: "proxied", port: {from: 0, to: 0}})
|
|
114
|
+
|
|
115
|
+
const processStatus = /** @type {import("../src/managed-process.js").ManagedProcessStatus} */ ({
|
|
116
|
+
children: [],
|
|
117
|
+
command: "run web",
|
|
118
|
+
cwd: "/tmp/rel",
|
|
119
|
+
exitCode: 143,
|
|
120
|
+
exitSignal: "SIGTERM",
|
|
121
|
+
id: "web",
|
|
122
|
+
lastMemoryRestartAt: undefined,
|
|
123
|
+
lastStartReason: "deploy",
|
|
124
|
+
logs: [],
|
|
125
|
+
memoryRestarts: 0,
|
|
126
|
+
pid: undefined,
|
|
127
|
+
restarts: 0,
|
|
128
|
+
rssBytes: undefined,
|
|
129
|
+
startedAt: "2026-10-08T06:22:23.500Z",
|
|
130
|
+
state: "stopped",
|
|
131
|
+
uptimeMs: 900000
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
await release.restore({
|
|
135
|
+
activatedAt: "2026-10-08T06:22:23.583Z",
|
|
136
|
+
connectionCount: 14,
|
|
137
|
+
connections: {http: 14, websocket: 0},
|
|
138
|
+
drainStartedAt: "2026-10-08T07:58:00.464Z",
|
|
139
|
+
ports: {web: 4200},
|
|
140
|
+
processes: [processStatus],
|
|
141
|
+
releaseId: "v1",
|
|
142
|
+
releasePath: "/tmp/rel",
|
|
143
|
+
retirementError: undefined,
|
|
144
|
+
revision: "v1",
|
|
145
|
+
state: "stopped",
|
|
146
|
+
stoppedAt: "2026-10-08T08:28:02.005Z"
|
|
147
|
+
}, {synchronizeLifecycleRole: false})
|
|
148
|
+
|
|
149
|
+
expect(release.state).toBe("stopped")
|
|
150
|
+
expect(release.status().connectionCount).toBe(0)
|
|
151
|
+
expect(release.localConnections()).toEqual({http: 0, websocket: 0})
|
|
152
|
+
})
|
|
96
153
|
})
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import {describe, expect, test} from "@velocious/testing"
|
|
4
|
+
import RollbridgeDaemon from "../src/daemon.js"
|
|
5
|
+
import ReleaseGroup from "../src/release-group.js"
|
|
6
|
+
import {normalizeConfig} from "../src/config.js"
|
|
7
|
+
|
|
8
|
+
describe("stale connection handoff", () => {
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {import("../src/json.js").JsonValue} webProcess - The single proxied process definition.
|
|
12
|
+
* @returns {import("../src/config.js").RollbridgeConfig} A normalized daemon config.
|
|
13
|
+
*/
|
|
14
|
+
function daemonConfig(webProcess) {
|
|
15
|
+
return normalizeConfig({
|
|
16
|
+
application: "stale-connection-handoff-test",
|
|
17
|
+
control: {path: "/tmp/rollbridge-stale-connection-handoff.sock"},
|
|
18
|
+
processes: [webProcess],
|
|
19
|
+
proxy: {host: "127.0.0.1", port: 0}
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const webProcess = {
|
|
24
|
+
command: "run web",
|
|
25
|
+
id: "web",
|
|
26
|
+
policy: "proxied",
|
|
27
|
+
port: {from: 0, to: 0}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
test("the serialized owner handoff never carries a stopped release's stale connection count", () => {
|
|
31
|
+
const daemon = new RollbridgeDaemon({config: daemonConfig(webProcess), logger: () => {}})
|
|
32
|
+
const release = new ReleaseGroup({
|
|
33
|
+
config: daemon.config,
|
|
34
|
+
logger: () => {},
|
|
35
|
+
releaseId: "stopped-v1",
|
|
36
|
+
releasePath: "/tmp/stopped-v1",
|
|
37
|
+
revision: "stopped-v1"
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
release.state = "stopped"
|
|
41
|
+
// Frozen drain-timeout count that a prior owner persisted (the 062147 incident shape).
|
|
42
|
+
release.setTransferredConnections({http: 14, websocket: 0})
|
|
43
|
+
daemon.releases.set(release.releaseId, release)
|
|
44
|
+
daemon.setListenerConnectionSource("incumbent-listener", release.releaseId, {http: 14, websocket: 0})
|
|
45
|
+
|
|
46
|
+
expect(daemon.serializedListenerConnectionSources()).toEqual({})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test("a successor daemon reconciles a stale report for an unretained release instead of failing the handoff", () => {
|
|
50
|
+
const logs = /** @type {{message: string, data?: Record<string, import("../src/json.js").JsonValue>}[]} */ ([])
|
|
51
|
+
const daemon = new RollbridgeDaemon({
|
|
52
|
+
config: daemonConfig(webProcess),
|
|
53
|
+
logger: (message, data) => {
|
|
54
|
+
logs.push({message, data})
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
// The candidate daemon does not rehydrate the stopped release; the incumbent's handoff
|
|
59
|
+
// still reports a nonzero count for it.
|
|
60
|
+
expect(() => daemon.setListenerConnectionSource("incumbent-listener", "062147", {http: 14, websocket: 0})).not.toThrow()
|
|
61
|
+
|
|
62
|
+
expect(daemon.serializedListenerConnectionSources()).toEqual({})
|
|
63
|
+
expect(logs.some((entry) => entry.message === "dropping stale connection state for unretained release")).toBe(true)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test("live release connection counts still cross the owner handoff", () => {
|
|
67
|
+
const daemon = new RollbridgeDaemon({config: daemonConfig(webProcess), logger: () => {}})
|
|
68
|
+
const release = new ReleaseGroup({
|
|
69
|
+
config: daemon.config,
|
|
70
|
+
logger: () => {},
|
|
71
|
+
releaseId: "active-v2",
|
|
72
|
+
releasePath: "/tmp/active-v2",
|
|
73
|
+
revision: "active-v2"
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
release.state = "active"
|
|
77
|
+
daemon.releases.set(release.releaseId, release)
|
|
78
|
+
daemon.setListenerConnectionSource("incumbent-listener", release.releaseId, {http: 3, websocket: 1})
|
|
79
|
+
|
|
80
|
+
// The incumbent's live counts must still cross the handoff so the successor can adopt them.
|
|
81
|
+
expect(daemon.serializedListenerConnectionSources()).toEqual({"incumbent-listener": {"active-v2": {http: 3, websocket: 1}}})
|
|
82
|
+
expect(release.status().connections).toEqual({http: 3, websocket: 1})
|
|
83
|
+
})
|
|
84
|
+
})
|