Skip to content

Commit 34dfbd1

Browse files
committed
gate failover on replica freshness
1 parent f5c421e commit 34dfbd1

11 files changed

Lines changed: 423 additions & 91 deletions

‎README.md‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,12 +99,19 @@ metadata:
9999
simple-volume.shipstuff.io/failover-workload-kind: "Deployment"
100100
simple-volume.shipstuff.io/failover-workload-name: "my-writer"
101101
simple-volume.shipstuff.io/failover-grace-period: "1m"
102+
simple-volume.shipstuff.io/failover-max-staleness: "2m"
102103
```
103104

104105
When no healthy pod using the claim is running on a Ready, schedulable node for
105-
the grace period, the controller selects a ready replica node, patches the
106-
Deployment's `kubernetes.io/hostname` node selector, and deletes stale pods
107-
using the claim.
106+
the grace period, the controller selects a ready replica node whose last
107+
successful sync is within `failover-max-staleness`, patches the Deployment's
108+
`kubernetes.io/hostname` node selector, and deletes stale pods using the claim.
109+
110+
The old active node is not promoted back automatically when it returns. If the
111+
old active rejoins as a replica target, the next full sync asks that agent to
112+
move its existing local volume into `.simple-volume-backups/` before restoring
113+
from the current leader. That keeps a rollback copy of the pre-restore local
114+
state while avoiding split-brain.
108115

109116
## Development
110117

‎docs/e2e-deployment-tasks.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,9 @@ kubectl exec deploy/simple-volume-demo -- sh -c 'echo ok >> /data/e2e.txt && cat
8282
- Add an E2E test script that writes data, syncs it to a second node, simulates
8383
active-node loss, promotes a fresh replica, reschedules the demo pod, and
8484
verifies data on the promoted node.
85+
- Extend the failover drill so the old active node rejoins, backs up its stale
86+
local copy under `.simple-volume-backups/`, restores from the promoted leader,
87+
and remains a replica unless an explicit move-back is requested.
8588

8689
Recommended V0 replication policy shape:
8790

‎docs/v0-plan.md‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,14 @@
7878
- target replica within maxStaleness
7979
- target agent healthy
8080
- old writer marked demoted/stale in API state
81+
- returning old active is treated as stale local state, not as the leader
82+
83+
- Returning-node restore:
84+
- promoted node remains the source of truth
85+
- old active rejoins as a replica target only
86+
- before restore, the node agent moves its existing local volume into a timestamped `.simple-volume-backups/` path
87+
- restore then overwrites the live replica path from the current leader
88+
- automatic move-back is out of scope; planned move-back must be explicit
8189

8290
- Auto-promotion is opt-in per volume and controlled by per-volume grace/deadline fields.
8391
- Workload movement uses normal Kubernetes scheduling:
@@ -128,4 +136,3 @@
128136
- CSI is a thin Kubernetes volume boundary, not the replication engine.
129137
- Node-agent DaemonSet placement defines the default storage-capable node set.
130138
- Production workload adoption requires a later explicit rollout plan, restore drill, and failure-mode test.
131-

‎internal/agent/agent.go‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"os"
99
"path/filepath"
1010
"strings"
11+
"time"
1112
)
1213

1314
const PoolMarkerFile = ".simple-volume-pool"
@@ -88,6 +89,35 @@ func EnsureVolumePath(v VolumePath, perm os.FileMode) (string, error) {
8889
return path, nil
8990
}
9091

92+
func BackupVolumePath(v VolumePath, now time.Time) (string, error) {
93+
path, err := ResolveVolumePath(v)
94+
if err != nil {
95+
return "", err
96+
}
97+
if _, err := os.Stat(path); os.IsNotExist(err) {
98+
return "", nil
99+
} else if err != nil {
100+
return "", err
101+
}
102+
empty, err := IsEmptyDir(path)
103+
if err != nil {
104+
return "", err
105+
}
106+
if empty {
107+
return "", nil
108+
}
109+
pool := v.Pool.Clean()
110+
backupRoot := filepath.Join(pool.Path, ".simple-volume-backups", safeSegment(v.Namespace), safeSegment(v.Name))
111+
if err := os.MkdirAll(backupRoot, 0o755); err != nil {
112+
return "", err
113+
}
114+
backupPath := filepath.Join(backupRoot, now.UTC().Format("20060102T150405.000000000Z"))
115+
if err := os.Rename(path, backupPath); err != nil {
116+
return "", err
117+
}
118+
return backupPath, nil
119+
}
120+
91121
type SyncRequest struct {
92122
Method string
93123
SourceURL string

‎internal/agent/replication.go‎

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,12 @@ type SourceRef struct {
4141
}
4242

4343
type FullSyncRequest struct {
44-
Namespace string `json:"namespace,omitempty"`
45-
Volume string `json:"volume"`
46-
Source SourceRef `json:"source"`
47-
IncludePaths []string `json:"includePaths,omitempty"`
48-
ExcludePaths []string `json:"excludePaths,omitempty"`
44+
Namespace string `json:"namespace,omitempty"`
45+
Volume string `json:"volume"`
46+
Source SourceRef `json:"source"`
47+
IncludePaths []string `json:"includePaths,omitempty"`
48+
ExcludePaths []string `json:"excludePaths,omitempty"`
49+
BackupExisting bool `json:"backupExisting,omitempty"`
4950
}
5051

5152
type PathFilter struct {
@@ -223,11 +224,17 @@ func ApplyFullSync(ctx context.Context, runner Runner, pool Pool, req FullSyncRe
223224
if req.Source.WebDAVURL == "" {
224225
return fmt.Errorf("source.webdavUrl is required")
225226
}
226-
targetRoot, err := EnsureVolumePath(VolumePath{
227+
volumePath := VolumePath{
227228
Pool: pool,
228229
Namespace: req.Namespace,
229230
Name: req.Volume,
230-
}, 0o755)
231+
}
232+
if req.BackupExisting {
233+
if _, err := BackupVolumePath(volumePath, time.Now().UTC()); err != nil {
234+
return err
235+
}
236+
}
237+
targetRoot, err := EnsureVolumePath(volumePath, 0o755)
231238
if err != nil {
232239
return err
233240
}

‎internal/agent/replication_test.go‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,41 @@ func TestApplyFullSyncRunsScopedRcloneSync(t *testing.T) {
135135
}
136136
}
137137

138+
func TestApplyFullSyncCanBackupExistingTarget(t *testing.T) {
139+
dir := t.TempDir()
140+
pool := Pool{Name: "default", Path: dir}
141+
if err := EnsurePool(pool, false); err != nil {
142+
t.Fatal(err)
143+
}
144+
target := filepath.Join(dir, "default", "demo", "save.dat")
145+
if err := writeFile(target, "old state"); err != nil {
146+
t.Fatal(err)
147+
}
148+
runner := &recordingRunner{}
149+
err := ApplyFullSync(context.Background(), runner, pool, FullSyncRequest{
150+
Namespace: "default",
151+
Volume: "demo",
152+
Source: SourceRef{WebDAVURL: "http://source:8081"},
153+
BackupExisting: true,
154+
})
155+
if err != nil {
156+
t.Fatalf("ApplyFullSync returned error: %v", err)
157+
}
158+
backups, err := filepath.Glob(filepath.Join(dir, ".simple-volume-backups", "default", "demo", "*", "save.dat"))
159+
if err != nil {
160+
t.Fatal(err)
161+
}
162+
if len(backups) != 1 {
163+
t.Fatalf("backups = %#v, want one backed up save.dat", backups)
164+
}
165+
if _, err := os.Stat(filepath.Join(dir, "default", "demo")); err != nil {
166+
t.Fatalf("restored target directory missing: %v", err)
167+
}
168+
if len(runner.specs) != 1 {
169+
t.Fatalf("ran %d commands, want 1", len(runner.specs))
170+
}
171+
}
172+
138173
func writeFile(path, content string) error {
139174
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
140175
return err

‎internal/agent/watch_manager.go‎

Lines changed: 69 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -33,23 +33,34 @@ type WatchStopRequest struct {
3333
}
3434

3535
type WatchStatus struct {
36-
Namespace string `json:"namespace,omitempty"`
37-
Volume string `json:"volume"`
38-
Source SourceRef `json:"source"`
39-
Targets []TargetRef `json:"targets"`
40-
IncludePaths []string `json:"includePaths,omitempty"`
41-
ExcludePaths []string `json:"excludePaths,omitempty"`
42-
Running bool `json:"running"`
43-
StartedAt time.Time `json:"startedAt"`
44-
StoppedAt *time.Time `json:"stoppedAt,omitempty"`
45-
LastBatchAt *time.Time `json:"lastBatchAt,omitempty"`
46-
LastBatchEventCount int `json:"lastBatchEventCount,omitempty"`
47-
LastBatchGeneration string `json:"lastBatchGeneration,omitempty"`
48-
LastDeliveryError string `json:"lastDeliveryError,omitempty"`
49-
LastWatchError string `json:"lastWatchError,omitempty"`
50-
DeliveredBatchCount int64 `json:"deliveredBatchCount,omitempty"`
51-
DeliveredEventCount int64 `json:"deliveredEventCount,omitempty"`
52-
DeliveryFailureCount int64 `json:"deliveryFailureCount,omitempty"`
36+
Namespace string `json:"namespace,omitempty"`
37+
Volume string `json:"volume"`
38+
Source SourceRef `json:"source"`
39+
Targets []TargetRef `json:"targets"`
40+
TargetStatuses []TargetStatus `json:"targetStatuses,omitempty"`
41+
IncludePaths []string `json:"includePaths,omitempty"`
42+
ExcludePaths []string `json:"excludePaths,omitempty"`
43+
Running bool `json:"running"`
44+
StartedAt time.Time `json:"startedAt"`
45+
StoppedAt *time.Time `json:"stoppedAt,omitempty"`
46+
LastBatchAt *time.Time `json:"lastBatchAt,omitempty"`
47+
LastBatchEventCount int `json:"lastBatchEventCount,omitempty"`
48+
LastBatchGeneration string `json:"lastBatchGeneration,omitempty"`
49+
LastDeliveryError string `json:"lastDeliveryError,omitempty"`
50+
LastWatchError string `json:"lastWatchError,omitempty"`
51+
DeliveredBatchCount int64 `json:"deliveredBatchCount,omitempty"`
52+
DeliveredEventCount int64 `json:"deliveredEventCount,omitempty"`
53+
DeliveryFailureCount int64 `json:"deliveryFailureCount,omitempty"`
54+
}
55+
56+
type TargetStatus struct {
57+
URL string `json:"url"`
58+
LastSuccessfulSync *time.Time `json:"lastSuccessfulSync,omitempty"`
59+
LastObservedGeneration string `json:"lastObservedGeneration,omitempty"`
60+
LastError string `json:"lastError,omitempty"`
61+
DeliveredBatchCount int64 `json:"deliveredBatchCount,omitempty"`
62+
DeliveredEventCount int64 `json:"deliveredEventCount,omitempty"`
63+
DeliveryFailureCount int64 `json:"deliveryFailureCount,omitempty"`
5364
}
5465

5566
type BatchSender interface {
@@ -139,14 +150,15 @@ func (m *WatchManager) Start(ctx context.Context, req WatchStartRequest) (WatchS
139150
key := watchKey(req.Namespace, req.Volume)
140151
watchCtx, cancel := context.WithCancel(context.Background())
141152
status := WatchStatus{
142-
Namespace: req.Namespace,
143-
Volume: req.Volume,
144-
Source: req.Source,
145-
Targets: append([]TargetRef(nil), req.Targets...),
146-
IncludePaths: append([]string(nil), req.IncludePaths...),
147-
ExcludePaths: append([]string(nil), req.ExcludePaths...),
148-
Running: true,
149-
StartedAt: time.Now().UTC(),
153+
Namespace: req.Namespace,
154+
Volume: req.Volume,
155+
Source: req.Source,
156+
Targets: append([]TargetRef(nil), req.Targets...),
157+
TargetStatuses: initialTargetStatuses(req.Targets),
158+
IncludePaths: append([]string(nil), req.IncludePaths...),
159+
ExcludePaths: append([]string(nil), req.ExcludePaths...),
160+
Running: true,
161+
StartedAt: time.Now().UTC(),
150162
}
151163

152164
m.mu.Lock()
@@ -283,17 +295,17 @@ func (m *WatchManager) run(ctx context.Context, key string, watch *managedWatch,
283295
batch.Source = source
284296
for _, target := range targets {
285297
if err := m.sender.SendBatch(ctx, target, batch); err != nil {
286-
m.recordDeliveryError(key, watch, err)
298+
m.recordDeliveryError(key, watch, target, err)
287299
continue
288300
}
289-
m.recordDeliverySuccess(key, watch, batch)
301+
m.recordDeliverySuccess(key, watch, target, batch)
290302
}
291303
return nil
292304
})
293305
m.recordStopped(key, watch, err)
294306
}
295307

296-
func (m *WatchManager) recordDeliverySuccess(key string, watch *managedWatch, batch EventBatch) {
308+
func (m *WatchManager) recordDeliverySuccess(key string, watch *managedWatch, target TargetRef, batch EventBatch) {
297309
now := time.Now().UTC()
298310
m.mu.Lock()
299311
defer m.mu.Unlock()
@@ -306,16 +318,25 @@ func (m *WatchManager) recordDeliverySuccess(key string, watch *managedWatch, ba
306318
watch.status.DeliveredBatchCount++
307319
watch.status.DeliveredEventCount += int64(len(batch.Events))
308320
watch.status.LastDeliveryError = ""
321+
targetStatus := ensureTargetStatus(&watch.status, target.URL)
322+
targetStatus.LastSuccessfulSync = &now
323+
targetStatus.LastObservedGeneration = batch.Generation
324+
targetStatus.DeliveredBatchCount++
325+
targetStatus.DeliveredEventCount += int64(len(batch.Events))
326+
targetStatus.LastError = ""
309327
}
310328

311-
func (m *WatchManager) recordDeliveryError(key string, watch *managedWatch, err error) {
329+
func (m *WatchManager) recordDeliveryError(key string, watch *managedWatch, target TargetRef, err error) {
312330
m.mu.Lock()
313331
defer m.mu.Unlock()
314332
if m.watches[key] != watch {
315333
return
316334
}
317335
watch.status.DeliveryFailureCount++
318336
watch.status.LastDeliveryError = err.Error()
337+
targetStatus := ensureTargetStatus(&watch.status, target.URL)
338+
targetStatus.DeliveryFailureCount++
339+
targetStatus.LastError = err.Error()
319340
}
320341

321342
func (m *WatchManager) recordStopped(key string, watch *managedWatch, err error) {
@@ -350,7 +371,26 @@ func parseOptionalDuration(value string, fallback time.Duration) (time.Duration,
350371

351372
func cloneWatchStatus(status WatchStatus) WatchStatus {
352373
status.Targets = append([]TargetRef(nil), status.Targets...)
374+
status.TargetStatuses = append([]TargetStatus(nil), status.TargetStatuses...)
353375
status.IncludePaths = append([]string(nil), status.IncludePaths...)
354376
status.ExcludePaths = append([]string(nil), status.ExcludePaths...)
355377
return status
356378
}
379+
380+
func initialTargetStatuses(targets []TargetRef) []TargetStatus {
381+
statuses := make([]TargetStatus, 0, len(targets))
382+
for _, target := range targets {
383+
statuses = append(statuses, TargetStatus{URL: target.URL})
384+
}
385+
return statuses
386+
}
387+
388+
func ensureTargetStatus(status *WatchStatus, targetURL string) *TargetStatus {
389+
for i := range status.TargetStatuses {
390+
if status.TargetStatuses[i].URL == targetURL {
391+
return &status.TargetStatuses[i]
392+
}
393+
}
394+
status.TargetStatuses = append(status.TargetStatuses, TargetStatus{URL: targetURL})
395+
return &status.TargetStatuses[len(status.TargetStatuses)-1]
396+
}

0 commit comments

Comments
 (0)