Skip to content

Scale, observability & fast file-backed provisioning (stack A–I)#67

Open
dfsweden wants to merge 28 commits into
fix/fqdn-no-dataportalsfrom
pr/i-fast-create
Open

Scale, observability & fast file-backed provisioning (stack A–I)#67
dfsweden wants to merge 28 commits into
fix/fqdn-no-dataportalsfrom
pr/i-fast-create

Conversation

@dfsweden

Copy link
Copy Markdown
Contributor

Stacked on #66 (fix/fqdn-no-dataportals), so this diff is exactly the units below — no fqdn duplication. Built as a linear commit stack; can be split into per-unit PRs on request (branches available).

What's here (9 units)

Unit Change
A scale reliability acquireVolumeLock/acquireSnapshotLock return codes.Aborted on lock-timeout instead of os.Exit(1) (which crashed the whole controller under concurrent load).
E OTel tracing Tracing scaffold + spans across controller/node RPCs and the destroy path (OTEL_TRACES_EXPORTER).
F metrics hs_csi_operation_* + hs_csi_anvil_requests_total (Prometheus/OTLP). See docs/observability.md.
mount survival Survive a stale/dead backing-share NFS mount (timeout-bounded mount + force-unmount) instead of leaking the lock. docs/node-unmount-recovery.md.
B hardening xfs<300MiB / ext4<20MiB size gates; fsfreeze source volume before snapshot.
C share-type probe Decide file- vs share-backed from the volume ID, not a GetShare probe that 404s; also routes file-backed snapshot deletes correctly.
D task-poll cadence Fixed 2s/30s-then-4s task poll instead of exponential backoff. docs/tunable-retry-parameters.md.
G parallel create Narrow the per-backing-share lock so concurrent file-backed CreateVolumes run in parallel; mkfs tuning. docs/file-backed-performance.md.
I fast create objectiveTarget StorageClass param (share default | file | both): default skips the per-file objective-set + the Anvil visibility poll that gates it.

Validation (real Anvil + DSX + 3-node k8s in AWS)

  • 100 concurrent file-backed PVCs bound in 31s, 0 provisioner restarts (stock driver crashed at 2 concurrent).
  • PR I: GET /files 500-poll-storm dropped 240 → 10 for 10 concurrent volumes; per-file objective-set 10 → 0; 10-concurrent bind 26s → 8s.
  • Full create → stage → publish → unpublish → unstage → delete lifecycle exercised; metrics scraped into VictoriaMetrics + rendered in the Hammerspace CSI Grafana dashboard.

Notes for review

  • New param objectiveTarget is documented in deploy/kubernetes/example_storage_class_file_backed.yaml and CHANGELOG.md.
  • Adds OTel/Prometheus deps to go.mod (grpc 1.79→1.80, protobuf, x/net etc.) — flagged for a deliberate look.

🤖 Generated with Claude Code

https://claude.ai/code/session_013r1qXHBJyPeVXWMvVS7iwp

douglasfallstrom and others added 24 commits July 23, 2026 21:49
acquireVolumeLock/acquireSnapshotLock now return codes.Aborted instead of
killing the whole controller process on a lock-acquire timeout (which under
concurrent load cascaded into a crash loop). Cherry-picked ace0cc6 to skip
the per-file objective + file-visibility poll so CreateVolume returns after
local mkfs (~0.5s).
deleteFileBacked, CleanupLoopDevice

The 2000-pod scale test surfaced pods stuck in Terminating because
DeleteVolume was silently looping. The controller-side spans were
present (DeleteVolume, DeleteSnapshot, UnmountBackingShareIfUnused)
but the node-side destroy RPCs, the file-backed unpublish helper, the
Anvil-side backing-file delete, and the loop-device teardown all had
no spans. That means from a trace we could see "DeleteVolume was
called" but not why any given call took 30 seconds or hung — the
actual work happens in these five functions.

Add tracer.Start on each:

  * Node/NodeUnstageVolume     (node.go)
  * Node/NodePublishVolume     (already had span; kept)
  * Node/NodeUnpublishVolume   (node.go)      NEW
  * unpublishFileBackedVolume  (node_helper.go)  NEW
  * deleteFileBackedVolume     (controller.go)   NEW
  * CleanupLoopDevice          (utils.go)        NEW

CleanupLoopDevice previously took no context. Added ctx as first
parameter; three call sites in node_helper.go updated to pass ctx
(they all had it in scope already).

No behavior change beyond the spans; local `make compile` clean.
Adds pkg/common/metrics.go (MeasureOp helper emitting hs_csi_operation_duration_seconds,
hs_csi_operation_errors_total, hs_csi_operation_inflight keyed by operation) and the 13
call sites mirroring the tracing spans, plus env-driven trace+metric exporter wiring in
main.go (OTEL_TRACES_EXPORTER / OTEL_METRICS_EXPORTER / OTEL_METRICS_PROMETHEUS_LISTEN).
Matches the metric names the Grafana hs-csi-driver dashboard queries. On top of the
xfs-freeze2 hardening + DeleteSnapshot/DeleteVolume fixes.
MeasureOp now takes *error; CreateVolume/DeleteVolume/CreateSnapshot and
NodeStage/Unstage/Publish/Unpublish get a named err return + (&err) so
hs_csi_operation_errors_total increments on real RPC failures. Sub-step
call sites keep (nil) (duration+inflight only).
Add hs_csi_anvil_requests_total, recorded after each doRequest completes so
it carries the real HTTP status code (0 on transport error). The existing
doRequest latency histogram is armed in a deferred closure *before* the
response is known, so it can only label method+path; this counter captures
every verb (GET/POST/PUT/DELETE) plus the 404 type-probes that dominate
file-backed traffic.

Also collapse per-resource IDs in the path to a low-cardinality route
template (AnvilRoute: shares/files/tasks/file-snapshots ids -> {id}) and use
it for the histogram's http.path label too, which previously exploded into
one series per share (file--pvc-<uuid>).
MeasureOp/span coverage was file-backed-biased: MakeEmptyRawFile, FormatDevice
and the file-visibility poll each had their own op, but the share path's
distinctive steps were invisible on the dashboard, hidden inside the top-level
Controller/CreateVolume timing. Add hs_csi_operation coverage for:

  - HammerspaceClient.WaitForTaskCompletion  (+ span w/ attempt count) - the
    CreateShare task poll, the share-backed analog of the file-visibility poll
    and the likeliest share-create bottleneck
  - HammerspaceClient.CreateShare / CreateShareFromSnapshot
  - SetMetadataTags (now takes ctx) - the post-create share tag step
  - ensureShareBackedVolumeExists / ensureBackingShareExists (+ spans)

Now the per-operation panels localize share-based latency the same way they
already do for file-based. Pure observability; no behavior change.
Reference for the OTel metrics/tracing subsystem: wiring (env-driven, no-op when
off), the hs_csi_operation_* family + MeasureOp contract, the hs_csi_anvil_requests_total
counter + AnvilRoute normalization, the file/share instrumentation coverage map,
the Grafana dashboard rows/queries, cardinality rules, and how to extend it.
Behavioral fixes/optimizations stay documented inline + in commit messages.
The per-volume / per-backing-share / per-snapshot in-memory locks were invisible
to metrics: a lock acquired but never released (holder stuck in an uninterruptible
mount syscall -> deferred unlock never runs) could only be inferred indirectly
(CreateVolume inflight high while every sub-op gauge reads 0).

Instrument acquireVolumeLock/acquireSnapshotLock via common.LockProbe:
  hs_csi_locks_held{lock_type}              up/down gauge - LEAK = stuck > 0
  hs_csi_lock_wait_seconds{lock_type}       histogram     - acquire contention
  hs_csi_lock_hold_seconds{lock_type}       histogram     - hold duration
  hs_csi_lock_acquire_failures_total{lock_type} counter   - 30s timeouts (-> Aborted)

The held gauge is the reliable leak signal; wait/hold quantify contention and slow
holders. Pure observability; lock behavior unchanged.
Design reference and runbook for the CSI keyed locks and the LockProbe
instrumentation: the keyLock primitive and its 30s codes.Aborted timeout,
which RPCs take which keys, why held pins at the provisioner sidecar's
--worker-threads ceiling (not a driver limit), the four lock metrics and
their exact counter identity, the release-rate-gated leak verdict, and the
stale-mount root cause with manual remediation and the code fix.
…ng the lock

When the driver is re-pointed to a new Anvil (or a data portal/Anvil is
terminated), the backing-share NFS mount left at <ShareStagingDir><exportPath>
still points at the old, now-unreachable server. Two unbounded operations then
hang uninterruptibly while holding the volume + backing-share locks, so the
deferred unlocks never run: the locks leak and every serialized file-backed
CreateVolume behind them times out (Aborted) forever. Observed as
hs_csi_locks_held stuck non-zero with no release.

Fixes:
- IsShareMounted now uses the timeout-bounded SafeIsMountPoint, so stat()ing a
  hung mount returns 'not cleanly mounted' instead of blocking.
- MountShare bounds the mount syscall with a timeout (defaultMountCheckTimeout);
  a hard NFS mount to a dead server returns DeadlineExceeded so the lock is
  released and the op retried.
- EnsureBackingShareMounted force-unmounts (umount -f -l) a lingering stale mount
  before re-mounting, so it re-establishes against the current data portal - this
  is what lets file-backed provisioning survive an Anvil swap.

Pairs with the CSI lock-observability metrics that surfaced the leak. Groups with
the os.Exit lock-crash fix on the scale/reliability lane.
mkfs.xfs 6.4+ prints three warnings when asked to format a filesystem
smaller than 300 MiB ("Filesystem should be larger than 300MB",
"Log size should be at least 64MB", and "Support for filesystems like
this one is deprecated and they will not be supported in future
releases") but still returns exit 0. The driver's existing err check
never fires, so a deprecated-format XFS gets silently created and may
fail to mount on future kernels.

Reject the request early with codes.InvalidArgument and a message that
tells the user what threshold applies and what they requested. This
lands before any share mount, qemu-img create, or mkfs, so no state is
left behind on Anvil.
mkfs.ext4 accepts filesystems as small as ~8 MiB, but the journal +
reserved-block overhead means anything smaller than ~20 MiB has almost
no usable space and typically hits ENOSPC on the first meaningful write.
Reject in CreateVolume with codes.InvalidArgument and a message that
tells the user the floor and what they requested.

Restructured the fileBacked-only fsType switch so future fsType floors
(f2fs, btrfs, etc.) drop in cleanly.
Anvil's file-snapshot endpoint captures the on-disk bytes of the backing
file as they exist on the storage server at snapshot time. For file-backed
XFS volumes that includes XFS's in-flight log — any journal transaction
that Anvil catches mid-commit will be rolled back by XFS log recovery on
the next mount, deleting the associated user data. The galaxy demo
consistently reproduced this: snapshot succeeded, restored file was
loop-mountable as XFS, but the filesystem root was empty.

Fix: before calling Anvil's snapshot, locate every running pod that has
the source volume mounted and issue `fsfreeze --freeze` on the mount.
XFS quiesces (log flushed, no in-flight transactions) before the snapshot
fires. Unfreeze after — always, even if the snapshot itself failed, so
the app pod isn't left blocked on writes.

Design notes:
- Freeze is executed from the csi-node DaemonSet pod on the source
  volume's node, not from the user's app pod. The user's image may be
  nginx-alpine / distroless / scratch and lack fsfreeze; csi-node ships
  util-linux and has kubelet mount propagation, so it can freeze
  /var/lib/kubelet/pods/<uid>/volumes/kubernetes.io~csi/<pv>/mount
  directly.
- The Freezer is nil (no-op) when the driver isn't running in-cluster,
  keeping local `make sanity` runs functional.
- Failure to freeze is logged and best-effort — matching Velero pre-hook
  semantics; the snapshot still fires. Unfreeze failure is escalated to
  ERROR because a stuck freeze permanently blocks writes.

Requires new RBAC — the csi-provisioner ClusterRole needs 'get' on pods
and 'create' on pods/exec. Deployment side (plugin.yaml) is updated
separately.

Verified end-to-end with the galaxy demo on fsType=xfs, 1 GiB PVC.
Before: rollback pod showed empty filesystem. After: rollback pod
shows the galaxy SVG that was on disk at snapshot time.

Also updates go.mod / go.sum to pull in k8s.io/{api,apimachinery,client-go}
v0.33.6, matching the k8s.io/kubernetes v1.33.6 already in use.
…GetShare probe

The driver treated the volume ID as opaque and issued GET /shares/{name} to
learn a volume's type, using the 404 as the 'file-backed' signal. For
file-backed volumes (a file inside a shared backing share) that probe 404s on
every Delete/Expand/CreateSnapshot/DeleteSnapshot -- thousands of wasted Anvil
round-trips at scale, and it dominated REST traffic.

The volume ID already encodes the type structurally: CreateVolume writes
file-backed IDs as <prefix><backingShare>/<file> (an extra path segment) and
share-backed IDs as <prefix><share>. isFileBackedVolumeID() reads that off the
ID with zero REST calls. GetShare is retained only as a fallback on the
non-file-backed branch, so legacy/unexpected handles still resolve correctly.

Routing is unchanged; only the redundant probe is removed.
…ial backoff

Share-create tasks always take >2s and almost always finish under ~15s. The old
jpillora exponential backoff (Min 100ms, Factor 1.5, capped at 30s, sleep-first)
grew the inter-poll gap to 6-30s by the time a share was ready, so detection lag
could add up to a full 30s AFTER the task had already completed - which is what
inflated the observed WaitForTaskCompletion latency.

Replace it with a fixed cadence: poll every 2s for the first 30s (covers the
normal completion window with ~2s detection latency), then relax to every 4s for
the long tail, up to the unchanged 3600s deadline. Drops the jpillora/backoff
dependency from this path.
…ng-share lock

The per-backing-share lock was held across the entire file-backed CreateVolume
path - both ensureBackingShareExists (share create-if-not-exists, which genuinely
needs serialization) AND ensureDeviceFileExists (the per-file mkfs, which does
not). That serialized ALL file creation on a backing share to one-at-a-time
(~2-3/s) even though each create is ~0.3s of work and the Anvil (~5000/s capable)
and controller node CPU sit idle.

Narrow the lock to only the share create-if-not-exists, then release it, and keep
the backing share mounted for the duration of concurrent creates via a refcount
(acquire/releaseBackingMount) instead of mount/unmount under the lock per file.
Per-file mkfs now runs concurrently across the provisioner worker threads; the
share is unmounted only when the last in-flight create releases.

File-backed only; share-backed and the NFS-directory-in-a-base-share path are
untouched.
…e load

mkfs.ext4 without flags eagerly zeroes the inode table and journal (~tens of MB
per 1GB volume). For file-backed volumes those writes go over NFS to the backing
share, saturating the DSX disk (measured ~442 MB/s / 100% util, mkfs p95 ~9.6s,
capping CreateVolume at ~12/s). Passing -E lazy_itable_init=1,lazy_journal_init=1
defers that zeroing to lazy kernel background init, cutting create-time writes to
~KB. (XFS is not a workaround: mkfs.xfs is ~2x heavier over NFS, p95 ~19s.)
mkfs.xfs discards (TRIMs) the whole file range by default; over an NFS-backed
file that pass is slow overhead (measured mkfs.xfs p95 ~19s vs ext4 lazy ~9.6s).
-K skips it; the backing share manages its own space reclaim.
The bottleneck ladder (backing-share lock -> provisioner API throttle -> mkfs
write volume/DSX saturation -> kube-controller-manager binding), each fix and how
it was measured, the ext4/xfs/btrfs comparison, deployment tuning knobs, and the
remaining ~15/s open item.
…reateVolume

Adds objectiveTarget (share|file|both, default share). With the default,
file-backed CreateVolume skips the per-file objective-set and the Anvil
file-visibility poll that exists only to gate it, returning as soon as the
local mkfs completes. The backing share already carries the objectives, so
per-file objectives are redundant in the common single-site shape.

This eliminates the GET /files 500-storm that poll generates under
concurrency (measured: 240 500s for 10 concurrent volumes). Per-file
objectives (and the poll) are still available via objectiveTarget=file|both
for per-volume/multi-site policy. Metadata tags still apply on the fast
path - they are local mount operations needing no Anvil round-trip.

Config-gated version of the earlier unconditional experiment (8bde551).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add objectiveTarget to the example file-backed StorageClass, and an
Unreleased CHANGELOG section covering the observability, perf, hardening,
and fast-create work in this stack.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ifest

Tests (all green):
- Fix TestParseParams for the objectiveTarget default ("share").
- TestParseObjectiveTarget (share default / share|file|both / invalid->InvalidArgument).
- TestCheckFileBackedMinSize (xfs<300MiB, ext4<20MiB gates) — extracted the size
  gate from CreateVolume into checkFileBackedMinSize for testability.
- TestIsFileBackedVolumeID (file- vs share-backed volume-ID discriminator).
- TestAnvilRoute + TestMeasureOp (metric route normalization + MeasureOp contract).
- Assert lock-acquire timeout returns codes.Aborted (not os.Exit) in
  TestAcquireVolumeLockTimeout.

Monitoring (new deploy/monitoring/):
- grafana/hs-csi-driver-dashboard.json — the "Hammerspace CSI Driver" dashboard.
- victoriametrics/scrape.yml — example scrape config for the :9090/:9091 endpoints.
- README.md — enable-metrics -> scrape -> import walkthrough.

Kubernetes 1.36:
- deploy/kubernetes/kubernetes-1.36/plugin.yaml — validated on 1.36 (1.29 base +
  host-networked metrics port + OTel env vars); documented in deploy/kubernetes/README.md.

Docs: objectiveTarget already in the example SC + CHANGELOG; observability.md now
points at deploy/monitoring/. Also gofmt-clean the stack files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@ravi100k ravi100k left a comment

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.

Ran this branch end-to-end (built the driver image, redeployed csi-provisioner/csi-node in a live cluster against a real Anvil, and passed all 4 provisioning scenarios: file-backed, nfs, nfs-mount-backing, block). Functionally solid. While reviewing the diff for units B/C/F/G I found a few concurrency/observability issues worth a look before merge — details inline. None of these showed up in the happy-path test above; they're about edge cases (timeouts, concurrent create/delete, cancelled RPCs).

Comment thread pkg/driver/utils.go Outdated
// best-effort so the mount below re-establishes against the CURRENT
// data portal rather than stacking onto - or reusing - a dead mount.
// This is what makes file-backed provisioning survive an Anvil swap.
common.ForceUnmountStale(backingDir)

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.

Force-unmount can fire on a healthy-but-slow mount, not just a dead one.

IsShareMounted (via SafeIsMountPoint, pkg/common/host_utils.go:591-608,701-722) treats any error the same way, including its own 50s timeout: on context.DeadlineExceeded it returns false — identical to "definitely not mounted." That false is what triggers ForceUnmountStale (umount -f -l) here.

backingDir is the shared staging mount for the whole backing share, used by every concurrent file-backed volume on this node. A single slow stat() on a congested/busy NFS server (arguably more likely under the higher concurrency this PR enables via unit G) reads as "stale" and force-lazy-unmounts the mount out from under any other in-flight pod using it — the same class of outage this fix is meant to prevent, just reachable via a false positive instead of a real dead mount.

Would it make sense to gate the force-unmount on a more positive signal of staleness (e.g. a quick reachability probe of the export/portal, or requiring N consecutive timeouts) rather than a single SafeIsMountPoint timeout?

Comment thread pkg/driver/utils.go
@@ -263,7 +333,7 @@ func (d *CSIDriver) UnmountBackingShareIfUnused(ctx context.Context, backingShar
}

log.Infof("unmounting backing share %s", mountPath)

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.

UnmountBackingShareIfUnused's "in use" check doesn't know about mountRefs, and can race with unit G's concurrent mkfs.

This function is called directly (bypassing the new mountRefs refcount entirely) from controller.go:244, controller.go:930, and several sites in node_helper.go. Its only "still in use" signal is whether losetup -a shows a loop device backed by a file under mountPath.

But in ensureDeviceFileExists (controller.go:462-486), mkfs.<fstype> (FormatDevice) runs directly on the plain backing file over the NFS mount — no loop device exists for a file-backed volume until it's later staged on a node. So during the entire CreateVolume window (including the mkfs that unit G intentionally lets run without holding the per-backing-share lock), losetup -a shows nothing for that file, and this function will happily report "unused."

Concretely: volume A's CreateVolume calls acquireBackingMount (mountRefs[share]=1) and starts mkfs on its backing file over the shared mount, lock released per unit G. A concurrent DeleteVolume for volume B on the same backing share calls this function directly via controller.go:244's defer, sees no loop device anywhere under mountPath, and unmounts the share — out from under volume A's in-flight mkfs, even though mountRefs[share] is 1.

Suggest also checking d.mountRefs[mountPath] > 0 here before unmounting, so the two mechanisms agree on what "in use" means.

Comment thread pkg/driver/controller.go Outdated
// Always unfreeze, even if snapshot failed — otherwise the app pod
// stays blocked on writes indefinitely.
if d.freezer != nil && fileBackedSource {
d.freezer.Unfreeze(ctx, frozen)

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.

Freeze/unfreeze run on the cancelable request ctx; a cancelled/expired RPC between them can leave the pod's filesystem frozen.

FreezeForVolumeHandle (line 1449) and this Unfreeze call both use the plain gRPC ctx. execFsfreeze (freezer.go:266) does exe.StreamWithContext(ctx, ...) — if ctx is already cancelled/expired (client-side deadline, or the snapshotter sidecar giving up while SnapshotFile/SnapshotShare at lines 1454-1456 is still running), the unfreeze exec never actually reaches the pod; it just fails fast with a context error. freezer.go:122's log ("filesystem may remain frozen; investigate") acknowledges this can happen but there's no fallback — the app pod stays frozen (all writes blocked) until a manual fsfreeze -u or pod restart.

This PR already has the fix for this exact class of problem a few hundred lines away: context.WithoutCancel(ctx) at controller.go:494, used specifically so the objective/metadata step survives gRPC cancellation. Worth applying the same pattern to Unfreeze (or at minimum wrapping freeze/unfreeze in a defer against a detached context) so a cancelled snapshot RPC can't leave a workload's filesystem stuck frozen.

Comment thread pkg/common/metrics.go
segs := strings.Split(urlPath, "/")
for i := 1; i < len(segs); i++ {
switch segs[i-1] {
case "shares", "tasks", "files", "objectives":

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.

Missing share-snapshots case — the exact cardinality blowup this function exists to prevent.

hsclient.go hits /share-snapshots/snapshot-create/{shareName}, /share-snapshots/snapshot-list/{shareName}, and /share-snapshots/snapshot-delete/{shareName}/{snapshotName} (lines 1036, 1060, 1090). None of those match shares/tasks/files/objectives/file-snapshots here, so for those paths AnvilRoute returns the URL untouched — arbitrary share names (and for the delete path, also per-snapshot timestamps) flow straight into the http.route label on hs_csi_anvil_requests_total. That's an unbounded series per share (and per snapshot, for deletes), which is the cardinality problem this function is meant to guard against. TestAnvilRoute doesn't have a case for this family either, so it read as covered.

Suggest adding a "share-snapshots" case that collapses the share-name (and snapshot-name, for delete) segments to {id}, same as the existing cases.

Comment thread pkg/driver/controller.go
common.MinXfsSizeBytes, common.MinXfsSizeBytes/mib,
requestedSize, requestedSize/mib)
}
case "ext4":

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.

ext3 has no size floor, but is formatted identically to ext4.

This switch only guards xfs and ext4. FormatDevice (pkg/common/host_utils.go:270) explicitly treats ext3 the same as ext4 for the lazy-init mkfs tuning (fsType == "ext4" || fsType == "ext3"), which implies the same "almost no usable space below ~20MiB" concern applies — but a tiny ext3 file-backed volume sails through this check with no floor at all.

Suggest adding "ext3" alongside "ext4" here (or case "ext4", "ext3":).

dfsweden and others added 2 commits July 24, 2026 08:22
Add deploy/kubernetes/kubernetes-1.34/ and kubernetes-1.35/ (parity with
1.36: 1.29 base + host-networked metrics port + OTel env). 1.34/1.35/1.36
are now the supported+validated set. Default OTEL_TRACES_EXPORTER=none across
all three (console spans are verbose; metrics=prometheus stays on); fix the
now-stale inline comment. Update deploy/kubernetes/README.md compat range and
CHANGELOG.

Validated end-to-end on live AWS k8s 1.34.10 and 1.35.7 clusters (deployed in
parallel, pointed at a shared Anvil with unique per-cluster backing shares):
file-backed PVC create + pod mount + delete, 0 provisioner restarts, metrics
scraped into VictoriaMetrics labeled by k8s_version.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- VERSION -> v1.3.0 (ldflag-injected into pkg/common.Version at build).
- Supported manifests (kubernetes-1.34/1.35/1.36) image tag -> v1.3.0;
  historical 1.25-1.29 keep their pinned tags.
- CHANGELOG: [Unreleased] -> [1.3.0].

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
dfsweden and others added 2 commits July 24, 2026 10:08
…nt deadlock fix

Review fixes:
1. Don't force-unmount a backing share on a single mount-check timeout
   (classifyMount requires mountStaleProbes consecutive timeouts).
2. UnmountBackingShareIfUnused honors the mountRefs refcount.
3. Snapshot Unfreeze runs on a context detached from gRPC cancellation.
4. AnvilRoute collapses share-snapshots ids to {id} (metric cardinality).
5. Drop ext3 as a supported file-backed fsType (reject with InvalidArgument).

Plus, found during live ext3/ext4/xfs validation on a 2-node AWS cluster
against a lab Anvil: releaseBackingMount self-deadlocked when a file-backed
volume dropped the LAST backing-share reference. It held mountRefsMu while
calling UnmountBackingShareIfUnused, which re-locks the same non-reentrant
mutex via backingMountInUse -> the goroutine wedged forever, never releasing
mountRefsMu or the volume lock, so every later create failed to acquire the
volume lock. ext4 hid it (xfs still held a ref, so refcount != 0 skipped the
unmount); a lone xfs PVC (refcount 1 -> 0) reproduced it every time. Fixed by
dropping the refcount under the lock (dropBackingRef) and unmounting only
after releasing it. Added TestDropBackingRefNoDeadlock (watchdog-guarded).

Validated live: ext3 rejected, ext4 + xfs bound and pods running (fs=xfs
confirmed), ext4 snapshot readyToUse=true, all within the default 60s timeout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013r1qXHBJyPeVXWMvVS7iwp
Follow-up hardening from the deadlock audit. acquireBackingMount held the
global mountRefsMu across EnsureBackingShareMounted, which performs a real
NFS mount that can block up to the ~5 min command-exec timeout on a slow or
dead portal. While held, every other file-backed create/delete blocked on
mountRefsMu — including refcount reads (backingMountInUse) and releases on
unrelated backing shares. Not a lock cycle, but the same "raw mutex held too
long" liveness hazard as the self-deadlock just fixed, and it re-wedges the
serialized-provisioning path the PR set out to remove.

Fix: serialize the actual mount/unmount of a given backing share with a
per-directory lock (mountLockFor); mountRefsMu now guards only the map
updates and is held for microseconds. The reference is reserved before the
mount (bumpBackingRef returns the 0->1 transition), so a concurrent delete's
UnmountBackingShareIfUnused can't observe refcount 0 mid-mount and unmount
the share out from under an in-flight create — a window the old global-lock
design closed incidentally. releaseBackingMount takes the same per-dir lock
so an unmount can't race a concurrent remount.

Tests: TestBumpBackingRefFirst (0->1 transition), and
TestBackingMountLockDoesNotBlockRefcount — holds a per-dir mount lock
(simulating a hung mount) and asserts refcount ops on the same and other
dirs still complete under a watchdog. Full driver suite passes under -race.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013r1qXHBJyPeVXWMvVS7iwp
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants