Skip to content

Commit 677d935

Browse files
committed
feat: add rollback-safe cutover keeping the monolith view consistent after rollback
1 parent 7e1a200 commit 677d935

5 files changed

Lines changed: 164 additions & 3 deletions

File tree

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,24 @@ more than a 30 percent tolerance. The ratio is measured in the same run, so the
120120
gate catches a routing change that adds latency without being sensitive to how
121121
fast the runner is.
122122

123+
## Migration safety
124+
125+
Two mechanisms make the cutover safe, both in the gateway's
126+
`io.cloudshift.gateway.dualwrite` package:
127+
128+
- Dual-write consistency. During the migration window a reservation write is
129+
applied to both the monolith and the extracted service, and a consistency
130+
check compares the two stored records on their business fields. Divergence is
131+
counted and surfaced rather than silently accepted. The generated id is left
132+
out of the comparison because the backends assign ids independently. Tests
133+
prove that an injected divergence is detected and that a consistent dual-write
134+
passes.
135+
- Rollback safety. The monolith receives every write in every phase, including
136+
while the route is cut over to the service. Rolling back to the monolith
137+
therefore finds a complete view with each record present exactly once. A test
138+
cuts over, writes, rolls back, and asserts the monolith holds every write with
139+
nothing lost and nothing duplicated.
140+
123141
## Cloud target
124142

125143
The intended deployment target is Azure (Azure Container Apps or AKS), with the

docs/migration-runbook.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,13 @@ Verification: a flipped route reaches the extracted service.
6666
### Phase 4: rollback if needed
6767

6868
If a problem appears after cutover, the target is flipped back to `MONOLITH`.
69-
Because reads and writes during the dual-write phase kept the monolith's view
70-
current, rolling back leaves a consistent monolith with no lost or duplicated
71-
records.
69+
Because the monolith receives every write in every phase, including while the
70+
route is cut over to the service, its view stays complete. Rolling back finds a
71+
monolith that holds each record exactly once, with nothing lost and nothing
72+
duplicated. The cutover coordinator
73+
(`io.cloudshift.gateway.dualwrite.CutoverCoordinator`) encodes this: it writes
74+
the monolith unconditionally and only adds the service once the migration window
75+
opens.
7276

7377
```bash
7478
RESERVATIONS_TARGET=MONOLITH docker compose up -d gateway
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package io.cloudshift.gateway.dualwrite;
2+
3+
/**
4+
* Drives a rollback-safe cutover for a single capability. The monolith receives every write in
5+
* every phase, so its view stays complete and a rollback to the monolith never finds lost or
6+
* duplicated records. The extracted service receives writes once the migration window opens, and
7+
* serves reads only after the route is flipped.
8+
*/
9+
public class CutoverCoordinator {
10+
11+
private final ReservationWriter monolith;
12+
private final ReservationWriter service;
13+
private MigrationPhase phase = MigrationPhase.MONOLITH;
14+
15+
public CutoverCoordinator(ReservationWriter monolith, ReservationWriter service) {
16+
this.monolith = monolith;
17+
this.service = service;
18+
}
19+
20+
public MigrationPhase phase() {
21+
return phase;
22+
}
23+
24+
public void beginDualWrite() {
25+
phase = MigrationPhase.DUAL_WRITE;
26+
}
27+
28+
public void cutOver() {
29+
phase = MigrationPhase.SERVICE;
30+
}
31+
32+
public void rollBack() {
33+
phase = MigrationPhase.MONOLITH;
34+
}
35+
36+
/**
37+
* Applies a write according to the current phase. The monolith is written in every phase; the
38+
* service is written once the migration window has opened. Writing the monolith unconditionally
39+
* is what makes the rollback safe.
40+
*/
41+
public void write(ReservationRecord request) {
42+
monolith.write(request);
43+
if (phase != MigrationPhase.MONOLITH) {
44+
service.write(request);
45+
}
46+
}
47+
48+
/** The backend that currently serves reads for the capability. */
49+
public ReservationWriter readBackend() {
50+
return phase == MigrationPhase.SERVICE ? service : monolith;
51+
}
52+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package io.cloudshift.gateway.dualwrite;
2+
3+
/**
4+
* Phases a capability moves through during a rollback-safe cutover.
5+
*
6+
* <ul>
7+
* <li>{@code MONOLITH}: writes go only to the monolith.
8+
* <li>{@code DUAL_WRITE}: writes go to both backends so the monolith stays current and can be
9+
* rolled back to.
10+
* <li>{@code SERVICE}: reads are served by the extracted service while writes continue to mirror
11+
* to the monolith, keeping rollback safe.
12+
* </ul>
13+
*/
14+
public enum MigrationPhase {
15+
MONOLITH,
16+
DUAL_WRITE,
17+
SERVICE
18+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package io.cloudshift.gateway.dualwrite;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import java.time.LocalDate;
6+
import java.util.ArrayList;
7+
import java.util.List;
8+
import org.junit.jupiter.api.Test;
9+
10+
class CutoverRollbackTest {
11+
12+
/** In-memory backend that records every write so we can check for loss or duplication. */
13+
private static final class RecordingStore implements ReservationWriter {
14+
private final List<ReservationRecord> stored = new ArrayList<>();
15+
16+
@Override
17+
public ReservationRecord write(ReservationRecord request) {
18+
stored.add(request);
19+
return request;
20+
}
21+
22+
List<ReservationRecord> contents() {
23+
return List.copyOf(stored);
24+
}
25+
}
26+
27+
private static ReservationRecord reservation(long roomId, String guest) {
28+
return new ReservationRecord(
29+
roomId, guest, LocalDate.of(2026, 1, 1), LocalDate.of(2026, 1, 2));
30+
}
31+
32+
@Test
33+
void cutoverThenRollbackLeavesTheMonolithConsistent() {
34+
RecordingStore monolith = new RecordingStore();
35+
RecordingStore service = new RecordingStore();
36+
CutoverCoordinator coordinator = new CutoverCoordinator(monolith, service);
37+
38+
// Write before any migration.
39+
coordinator.write(reservation(1, "before"));
40+
41+
// Open the migration window and write while dual-writing.
42+
coordinator.beginDualWrite();
43+
coordinator.write(reservation(2, "dual"));
44+
45+
// Flip reads to the service and write while cut over.
46+
coordinator.cutOver();
47+
coordinator.write(reservation(3, "cutover"));
48+
assertThat(coordinator.readBackend()).isSameAs(service);
49+
50+
// Roll back to the monolith.
51+
coordinator.rollBack();
52+
coordinator.write(reservation(4, "after"));
53+
assertThat(coordinator.readBackend()).isSameAs(monolith);
54+
55+
// The monolith saw every write exactly once: nothing lost, nothing duplicated.
56+
List<ReservationRecord> monolithView = monolith.contents();
57+
assertThat(monolithView)
58+
.containsExactly(
59+
reservation(1, "before"),
60+
reservation(2, "dual"),
61+
reservation(3, "cutover"),
62+
reservation(4, "after"));
63+
assertThat(monolithView).doesNotHaveDuplicates();
64+
65+
// The service only holds the writes made from the migration window onward.
66+
assertThat(service.contents())
67+
.containsExactly(reservation(2, "dual"), reservation(3, "cutover"));
68+
}
69+
}

0 commit comments

Comments
 (0)