Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 68 additions & 2 deletions .github/workflows/longhaul-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,19 @@
# (LONGHAUL_MIN_INSTANCES == LONGHAUL_MAX_INSTANCES) so the gate is a fast,
# deterministic data-durability check (writers + verifier) that fits a
# GitHub-hosted runner and finishes in a few minutes.
#
# Data-protection gate
# The backup verifier is exercised for real, not just compiled in. The kind
# cluster already has CSI VolumeSnapshot support (setup-test-environment runs
# deploy-csi-driver.sh: external-snapshotter + a default csi-hostpath
# VolumeSnapshotClass), so a single-instance cluster can complete snapshot
# backups — exactly as the e2e scheduled-backup test proves. The smoke run
# sets a per-minute backup schedule and a 30s verify interval (vs the 5m
# default, via LONGHAUL_BACKUP_VERIFY_INTERVAL) so the verifier's periodic
# loop fires many times within the window and reliably observes a
# scheduled+completed backup. It then asserts scheduled AND completed >= 1
# (with no retention leak or completion stall), so a broken backup path fails
# the PR rather than passing silently as a no-op.

name: Long-Haul Smoke Gate

Expand All @@ -61,7 +74,7 @@ on:
workflow_dispatch:
inputs:
max_duration:
description: "Bounded driver run length (Go duration, e.g. 6m)"
description: "Bounded driver run length (Go duration). Keep >= 6m so at least one per-minute backup is scheduled and completed within the window."
required: false
default: "6m"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Required] This window is too tight for a reliable end-to-end backup assertion. The driver only runs checkOnce on the first 5-minute ticker, while the first */1 schedule can occur nearly a minute after bootstrap and snapshot creation/completion can take additional time. If the backup is not completed by that single tick, the final report has Backups Completed = 0 and this otherwise healthy smoke run fails intermittently. Please either make the smoke run materially longer (for example, at least 10–15 minutes) or make the verifier perform an immediate/short-cadence initial check before relying on the 5-minute interval.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed by making the verify cadence configurable rather than draining at shutdown. The smoke run sets `LONGHAUL_BACKUP_VERIFY_INTERVAL: 30s` (vs the 5m default) so the verifier's real periodic loop ticks ~12x within the 6m window and reliably observes a scheduled+completed backup. We chose the short-cadence loop over a one-off immediate check so the gate genuinely proves the periodic verifier works, not just that a shutdown snapshot happened.


Expand Down Expand Up @@ -207,6 +220,12 @@ jobs:
# - RETAIN_PER_WRITER: low enough to force a real prune at 5m
# - MIN==MAX instances: disable disruptive scale ops (fast + stable)
# - short cadences so the verifier gets several cycles in the window
# - BACKUP_*: exercise the data-protection verifier for real — a
# per-minute schedule so at least one backup is scheduled and
# completed within the bounded window, plus a 30s verify interval
# (vs the 5m default) so the verifier's periodic loop ticks many
# times and reliably observes the completion. Retention is 1 day
# (>> the run) so no leak fires; we assert scheduled+completed.
PATCH=$(jq -nc \
--arg dur "${MAX_DURATION}" \
'{data: {
Expand All @@ -219,7 +238,11 @@ jobs:
LONGHAUL_RECOVERY_TIMEOUT: "2m",
LONGHAUL_REPORT_INTERVAL: "30s",
LONGHAUL_MIN_INSTANCES: "1",
LONGHAUL_MAX_INSTANCES: "1"
LONGHAUL_MAX_INSTANCES: "1",
LONGHAUL_BACKUP_ENABLED: "true",
LONGHAUL_BACKUP_SCHEDULE: "*/1 * * * *",
LONGHAUL_BACKUP_RETENTION_DAYS: "1",
LONGHAUL_BACKUP_VERIFY_INTERVAL: "30s"
}}')
kubectl patch configmap longhaul-test-config -n "${DB_NS}" --type merge -p "${PATCH}"

Expand Down Expand Up @@ -295,6 +318,44 @@ jobs:
fi
echo "✅ Long-haul smoke gate passed (exit 0, report PASS, retention pruned documents)."

- name: Assert data-protection verifier ran
run: |
set -euo pipefail
# The overall PASS only means "no leak/stall/data-loss". With zero
# backups that is trivially true, so a broken/no-op backup path would
# slip through. Assert the verifier actually scheduled AND completed a
# backup — proof the data-protection path functioned end-to-end.
report=$(kubectl get configmap longhaul-report -n "${DB_NS}" \
-o jsonpath='{.data.latest-report}' 2>/dev/null || echo "")
if [[ -z "${report}" ]]; then
echo "::error::longhaul-report has no latest-report body to inspect."
exit 1
fi

extract() { echo "${report}" | sed -n "s/^| $1 | \([0-9][0-9]*\) |.*/\1/p"; }
scheduled=$(extract "Backups Scheduled" || true)
completed=$(extract "Backups Completed" || true)
leaks=$(extract "Retention Leaks" || true)
stall=$(extract "Max Scheduled Without Completion" || true)

echo "===== Data Protection ====="
echo "${report}" | grep -E '^\| (Backups|Live Backup|Retention Leaks|Max Scheduled) ' || true
echo "Scheduled=${scheduled:-?} Completed=${completed:-?} Leaks=${leaks:-?} MaxNoCompletion=${stall:-?}"

if [[ -z "${scheduled}" || -z "${completed}" ]]; then
echo "::error::Could not parse backup metrics from the report."
exit 1
fi
if (( scheduled < 1 )); then
echo "::error::Backup verifier observed no scheduled backups (scheduled=${scheduled}); the data-protection path did not run."
exit 1
fi
if (( completed < 1 )); then
echo "::error::Backup verifier observed no completed backups (completed=${completed}); backups scheduled but never completed."
exit 1
fi
echo "✅ Data-protection verifier ran (scheduled=${scheduled}, completed=${completed})."

- name: Diagnostics on failure
if: failure()
run: |
Expand All @@ -306,6 +367,11 @@ jobs:
kubectl logs -n "${DB_NS}" -l app.kubernetes.io/name=longhaul-test --previous --tail=200 || true
echo "===== longhaul-report ConfigMap ====="
kubectl get configmap longhaul-report -n "${DB_NS}" -o yaml || true
echo "===== ScheduledBackup + child Backups ====="
kubectl get scheduledbackups.documentdb.io -n "${DB_NS}" -o wide || true
kubectl get backups.documentdb.io -n "${DB_NS}" -o wide || true
echo "===== VolumeSnapshots ====="
kubectl get volumesnapshots -n "${DB_NS}" -o wide || true
echo "===== DocumentDB describe ====="
kubectl describe documentdb "${DB_NAME}" -n "${DB_NS}" || true
echo "===== Operator logs ====="
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,9 @@ Backup*/
!test/e2e/manifests/backup/
!test/e2e/tests/backup/
!test/e2e/pkg/e2eutils/backup/
# The long-haul driver's data-protection component lives here and is
# likewise swallowed by the generic Backup*/ rule above.
!test/longhaul/backup/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
Expand Down
46 changes: 46 additions & 0 deletions test/longhaul/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,55 @@ All configuration is via environment variables.
| `LONGHAUL_MIN_INSTANCES` | No | `1` | Minimum `spec.instancesPerNode` for scale-down operations (CRD lower bound: 1). |
| `LONGHAUL_MAX_INSTANCES` | No | `3` | Maximum `spec.instancesPerNode` for scale-up operations (CRD upper bound: 3). |
| `LONGHAUL_REPORT_INTERVAL` | No | `1h` | How often to write checkpoint reports to ConfigMap. |
| `LONGHAUL_BACKUP_ENABLED` | No | `true` | Enable the ScheduledBackup + retention verifier. |
| `LONGHAUL_BACKUP_SCHEDULE` | No | `0 */6 * * *` | Cron schedule for the canary `ScheduledBackup`. |
| `LONGHAUL_BACKUP_RETENTION_DAYS` | No | `1` | Retention window applied to child backups; also used to derive the retention-leak deadline. |
| `LONGHAUL_BACKUP_VERIFY_INTERVAL` | No | `5m` | How often the backup verifier samples the `ScheduledBackup` and its children. Lower it for short bounded runs (e.g. the smoke gate uses `30s`) so the periodic loop fires several times within the window. |
| `LONGHAUL_RESET_DATA` | No | `false` | If `true`, drop the workload collection on startup. Off by default so a Deployment pod restart preserves durability history. |
| `LONGHAUL_RETAIN_PER_WRITER` | No | `2000000` | Retention window: most-recent verified documents kept per writer before the pruner deletes older ones, bounding disk usage. `0` disables pruning (unbounded growth). |

### Data Protection (ScheduledBackup + retention)

When `LONGHAUL_BACKUP_ENABLED` is true, the driver ensures a `ScheduledBackup`
named `<cluster>-longhaul` exists and matches the run's schedule/retention
(an existing CR is reconciled in place, never recreated, so backup history is
preserved across restarts and parameter changes) and runs a verifier
concurrently with the operation scheduler (backup is deliberately **not**
isolated from topology/chaos, per the design).

The verifier only checks the properties a **multi-day** run can establish —
things unit and e2e tests cannot:

- **Scheduling liveness** — `status.lastScheduledTime` keeps advancing; a stalled
scheduler (past `status.nextScheduledTime` + grace) raises a warning.
- **Completion** — child `Backup` CRs keep reaching `completed`; only terminal
`failed` backups are counted as failures. A `skipped` backup is an intentional
no-op (e.g. the operator declines to back up a non-primary/standby) and is
**not** counted as a failure. If backups keep being scheduled but stop
completing for 3 consecutive schedules (a dead completion path — every backup
failing or hanging), the run is a **FAIL**. A completed **or** skipped backup
resets this gap, so transient chaos-induced failures and normal standby /
failover intervals (where several consecutive schedules are skipped) are
tolerated.
- **Retention leak** — no completed backup outlives its retention window
(`stoppedAt + spec.retentionDays*24h` + grace). The window is taken from each
backup's **own** `spec.retentionDays` (stamped at creation), so the check
stays correct even if a later run uses a different retention. A lingering
backup is a **FAIL**: expired backups aren't garbage-collected and the
population (and its PVCs / VolumeSnapshots) grows unbounded.

It deliberately does **not** re-verify the operator's retention *arithmetic*
(`expiredAt == stoppedAt + retentionDays*24h`) — that is a pure function already
covered by the operator's unit tests and needs no accumulation. The oracle here
is black-box: expired backups disappear. Because the minimum meaningful
retention is 1 day, the leak check only fires on multi-day runs — exactly the
accumulation window long-haul exists to cover.

> **RBAC.** The driver ServiceAccount needs `create`/`get`/`list`/`update` on
> `scheduledbackups.documentdb.io` and `list` on `backups.documentdb.io`. These
> verbs are granted by the `longhaul-test` Role in `deploy/rbac.yaml`; without
> them the backup verifier logs an error and the rest of the run continues.

## CI Safety

The long haul test binary is deployed as a Kubernetes Deployment on a dedicated AKS
Expand Down
146 changes: 146 additions & 0 deletions test/longhaul/backup/metrics.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

// Package backup implements the data-protection component of the long haul
// driver. It provisions a ScheduledBackup against the canary cluster and
// continuously verifies the properties that only a multi-day run can
// establish: that backups keep being produced on schedule and completing,
// and that expired backups are actually garbage-collected so the backup
// population stays bounded over time (no PVC / VolumeSnapshot accumulation).
//
// It deliberately does NOT re-verify the operator's retention *arithmetic*
// (expiredAt == stoppedAt + retentionDays*24h) — that is a pure function
// already covered by the operator's unit tests and needs no accumulation.
// The oracle here is black-box: expired backups disappear.
//
// The component runs concurrently with the operation scheduler — per the
// long-haul design, backup is deliberately NOT isolated so that
// backup-vs-topology serialization bugs surface here rather than in
// production.
package backup

import (
"sync/atomic"
"time"
)

// completionStallThreshold is the number of consecutive backups that may be
// scheduled with no intervening completion before the run is failed. A single
// recovered (completed) backup resets the running gap, so transient chaos-
// induced failures stay well under this ceiling; only a wholly-broken
// completion path (every backup failing or hanging) drives the gap this high.
// At the default 6h schedule this is ~18h of zero successful backups.
const completionStallThreshold = 3

// Metrics tracks aggregate backup-verification counters using atomic
// operations so the reporter goroutine can snapshot them without locking.
//
// Two independent oracles flip the run verdict to FAIL:
// - RetentionLeaks > 0: a completed backup outlived its retention window
// (the operator failed to garbage-collect it).
// - MaxScheduledWithoutCompletion >= completionStallThreshold: backups keep
// being scheduled but stop completing (a dead completion path the leak
// oracle alone would miss).
//
// The remaining counters are observational and feed the report.
type Metrics struct {
// Scheduled counts backups observed to have been scheduled by the
// ScheduledBackup (advances of status.lastScheduledTime).
Scheduled atomic.Int64

// Completed is the number of child backups observed in the "completed"
// phase (deduplicated by name across verification cycles).
Completed atomic.Int64

// Failed is the number of child backups observed in a terminal failure
// phase (deduplicated by name).
Failed atomic.Int64

// Skipped is the number of child backups observed in the "skipped" phase
// (deduplicated by name). Skipped is an intentional no-op, not a failure,
// and resets the completion-stall gap like a completion. Observational.
Skipped atomic.Int64

// RetentionLeaks counts completed backups still present past their
// retention window (stoppedAt + retentionDays*24h). Non-zero => FAIL.
RetentionLeaks atomic.Int64

// MaxScheduledWithoutCompletion is the high-water mark of consecutive
// backups scheduled with no intervening completion. It is the completion-
// liveness oracle: a wholly-broken backup path drives it monotonically
// upward while Completed stays flat. Reaching completionStallThreshold
// flips the verdict to FAIL. See observeCompletionGap for how it advances.
MaxScheduledWithoutCompletion atomic.Int64

// LastChildCount is the number of child backups observed on the most
// recent verification cycle (the live backup population — expected to
// stabilize near retentionWindow/scheduleInterval at steady state).
LastChildCount atomic.Int64

// LastScheduledUnix is the Unix timestamp of the most recently observed
// status.lastScheduledTime; 0 until the first backup is scheduled.
LastScheduledUnix atomic.Int64
}

// NewMetrics creates an empty Metrics.
func NewMetrics() *Metrics {
return &Metrics{}
}

// observeCompletionGap records gap as a new high-water mark for consecutive
// scheduled-without-completion backups if it exceeds the current maximum.
func (m *Metrics) observeCompletionGap(gap int64) {
for {
cur := m.MaxScheduledWithoutCompletion.Load()
if gap <= cur {
return
}
if m.MaxScheduledWithoutCompletion.CompareAndSwap(cur, gap) {
return
}
}
}

// MetricsSnapshot is a point-in-time copy of Metrics.
type MetricsSnapshot struct {
Scheduled int64
Completed int64
Failed int64
Skipped int64
RetentionLeaks int64
MaxScheduledWithoutCompletion int64
LastChildCount int64
LastScheduled time.Time
}

// Snapshot captures the current metric values atomically.
func (m *Metrics) Snapshot() MetricsSnapshot {
var lastScheduled time.Time
if unix := m.LastScheduledUnix.Load(); unix > 0 {
lastScheduled = time.Unix(unix, 0)
}
return MetricsSnapshot{
Scheduled: m.Scheduled.Load(),
Completed: m.Completed.Load(),
Failed: m.Failed.Load(),
Skipped: m.Skipped.Load(),
RetentionLeaks: m.RetentionLeaks.Load(),
MaxScheduledWithoutCompletion: m.MaxScheduledWithoutCompletion.Load(),
LastChildCount: m.LastChildCount.Load(),
LastScheduled: lastScheduled,
}
}

// HasRetentionLeak returns true if any completed backup has outlived its
// retention window. A true result flips the overall run verdict to FAIL.
func (s MetricsSnapshot) HasRetentionLeak() bool {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Major / Question — a wholly-broken backup subsystem can still report PASS.

Only RetentionLeaks > 0 flips the run verdict (confirmed in cmd/longhaul/main.go buildSummary, where only backupSnap.HasRetentionLeak() calls appendReason). Failed and scheduling-stall are observational.

So if every child backup terminally fails — Completed=0, Failed climbing — while status.lastScheduledTime keeps advancing and nothing lingers to leak, the long-haul run still reports PASS. For a suite whose whole purpose is multi-day data-protection regression detection, a completely broken backup path would go unflagged.

Is the non-fatal Failed intentional (tolerating transient chaos-induced failures)? If so, consider a sustained-failure gate that keeps that tolerance but still catches the real regression — e.g. "no completed backup within N schedule intervals" or a failure-ratio ceiling — rather than only the leak oracle.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 7c4405a. Added a completion-liveness oracle: the verifier now tracks the high-water mark of consecutive backups scheduled with no intervening completion (MaxScheduledWithoutCompletion), and the run FAILs once it reaches completionStallThreshold (3). A single completed backup resets the running gap, so transient chaos-induced failures stay under the ceiling — only a sustained dead completion path (every backup failing/hanging, ~18h at the default 6h schedule) trips it. This keeps the tolerance you wanted while catching the "wholly-broken subsystem" regression. Surfaced in the report + README; unit tests cover the stall, keep-pace, and recovery cases.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, and agreed on the sustained-failure gate. `HasCompletionStall()` ("no completed backup within N schedule intervals", N=3) is now wired into the run verdict in `cmd/longhaul/main.go` `buildSummary` via `appendReason`, alongside the retention-leak oracle. This keeps tolerance of transient/chaos-induced `Failed` (still observational) but flips the run to FAIL when the backup path is genuinely broken (`Completed=0` while schedules keep advancing). Skipped children reset the gap so standby/failover doesn't false-trip it.

return s.RetentionLeaks > 0
}

// HasCompletionStall returns true if backups kept being scheduled but stopped
// completing for completionStallThreshold consecutive schedules. A true result
// flips the overall run verdict to FAIL, catching a wholly-broken backup path
// that the retention-leak oracle alone would miss.
func (s MetricsSnapshot) HasCompletionStall() bool {
return s.MaxScheduledWithoutCompletion >= completionStallThreshold
}
69 changes: 69 additions & 0 deletions test/longhaul/backup/metrics_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

package backup

import (
"time"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

var _ = Describe("Metrics", func() {
It("snapshots counters atomically", func() {
m := NewMetrics()
m.Scheduled.Add(3)
m.Completed.Add(2)
m.Failed.Add(1)
m.RetentionLeaks.Add(1)
m.LastChildCount.Store(7)
now := time.Now()
m.LastScheduledUnix.Store(now.Unix())

snap := m.Snapshot()
Expect(snap.Scheduled).To(Equal(int64(3)))
Expect(snap.Completed).To(Equal(int64(2)))
Expect(snap.Failed).To(Equal(int64(1)))
Expect(snap.RetentionLeaks).To(Equal(int64(1)))
Expect(snap.LastChildCount).To(Equal(int64(7)))
Expect(snap.LastScheduled.Unix()).To(Equal(now.Unix()))
})

It("reports zero LastScheduled when never scheduled", func() {
snap := NewMetrics().Snapshot()
Expect(snap.LastScheduled.IsZero()).To(BeTrue())
})

DescribeTable("HasRetentionLeak",
func(leaks int64, want bool) {
m := NewMetrics()
m.RetentionLeaks.Add(leaks)
Expect(m.Snapshot().HasRetentionLeak()).To(Equal(want))
},
Entry("clean", int64(0), false),
Entry("one leak", int64(1), true),
Entry("several leaks", int64(3), true),
)

DescribeTable("HasCompletionStall",
func(gap int64, want bool) {
m := NewMetrics()
m.observeCompletionGap(gap)
Expect(m.Snapshot().HasCompletionStall()).To(Equal(want))
},
Entry("no schedules", int64(0), false),
Entry("one in flight", int64(1), false),
Entry("two in flight", int64(2), false),
Entry("at threshold", int64(completionStallThreshold), true),
Entry("past threshold", int64(completionStallThreshold+2), true),
)

It("observeCompletionGap keeps only the high-water mark", func() {
m := NewMetrics()
m.observeCompletionGap(2)
m.observeCompletionGap(5)
m.observeCompletionGap(3) // lower value must not lower the mark
Expect(m.Snapshot().MaxScheduledWithoutCompletion).To(Equal(int64(5)))
})
})
Loading
Loading