Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
71 changes: 67 additions & 4 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
# therefore sets a fast backup schedule (every minute) and asserts the driver
# actually scheduled AND completed at least one backup (with no retention leak
# or completion stall). Because the backup verify loop ticks every 5m, the
# bounded window is >= 6m so that tick fires once against several
# already-completed backups; a broken backup path (no completion) fails the
# PR rather than passing silently as a no-op.

name: Long-Haul Smoke Gate

Expand All @@ -61,9 +74,9 @@ on:
workflow_dispatch:
inputs:
max_duration:
description: "Bounded driver run length (Go duration, e.g. 3m)"
description: "Bounded driver run length (Go duration). Keep >= 6m so the 5m backup verify tick fires."
required: false
default: "3m"
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.


permissions:
contents: read
Expand Down Expand Up @@ -115,7 +128,7 @@ jobs:
# Must match the cluster name the composite action derives:
# documentdb-<test-type>-<architecture>-<test-scenario-name>
KIND_CLUSTER: documentdb-longhaul-amd64-smoke
MAX_DURATION: ${{ github.event.inputs.max_duration || '3m' }}
MAX_DURATION: ${{ github.event.inputs.max_duration || '6m' }}
steps:
- name: Checkout
uses: actions/checkout@v4
Expand Down Expand Up @@ -205,6 +218,10 @@ jobs:
# - RESET_DATA: fresh collection each CI run
# - 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 backups are scheduled + completed well
# before the backup verifier's 5m tick observes them. Retention is
# 1 day (>> the run) so no leak fires; we assert scheduled+completed.
PATCH=$(jq -nc \
--arg dur "${MAX_DURATION}" \
'{data: {
Expand All @@ -216,7 +233,10 @@ 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"
}}')
kubectl patch configmap longhaul-test-config -n "${DB_NS}" --type merge -p "${PATCH}"

Expand Down Expand Up @@ -286,6 +306,44 @@ jobs:
fi
echo "✅ Long-haul smoke gate passed (exit 0, report PASS)."

- 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 @@ -297,6 +355,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
43 changes: 43 additions & 0 deletions test/longhaul/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,51 @@ 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_RESET_DATA` | No | `false` | If `true`, drop the workload collection on startup. Off by default so a Deployment pod restart preserves durability history. |

### 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 single completed backup resets
this gap, so transient chaos-induced failures 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
139 changes: 139 additions & 0 deletions test/longhaul/backup/metrics.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// 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

// 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
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(),
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.

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