Skip to content

feat(service): add digest-based cross-volume pull dedup - #34

Closed
chlins wants to merge 1 commit into
mainfrom
feat/hardlink
Closed

chlins wants to merge 1 commit into
mainfrom
feat/hardlink

Conversation

@chlins

@chlins chlins commented Apr 20, 2026

Copy link
Copy Markdown
Member

This pull request introduces cross-volume deduplication for model pulls, ensuring that when the same model (identified by its manifest digest) is requested multiple times (even via mutable tags like :latest), the system avoids redundant downloads and disk usage by hardlinking to an existing local copy. The implementation robustly handles digest resolution, synchronizes concurrent pulls, and updates the status tracking to be digest-aware. Several tests and internal APIs are updated to reflect these changes.

Deduplication and Digest-based Pull Logic:

  • Added logic to resolve the manifest digest for a model reference, using direct parsing for digest-based references and remote inspection for tag-based references. This is used to key deduplication and ensure correctness with mutable tags.
  • Implemented cross-volume deduplication: before pulling a model, the worker checks for an existing successfully-pulled copy (by digest) and clones it via hardlinks if found, serializing concurrent pulls via a new refMutex. [1] [2] [3] [4] [5]

Status and API Changes:

  • The status.Status struct now includes a Digest field, and all status updates during pull operations persist the resolved digest, ensuring correct deduplication even after restarts. [1] [2]

Internal API Refactoring:

  • The isModelExisted method and related logic are now keyed by digest instead of reference, and a new findExistingModelDir method efficiently locates existing models by digest. [1] [2]

Testing Improvements:

  • Updated and added tests to verify digest-based existence checks, correct short-circuiting on empty digests, and correctness of dedup logic for both static and dynamic volumes. [1] [2] [3] [4]

These changes together ensure that model pulls are efficient, correct with respect to mutable tags, and robust under concurrent access.

@chlins chlins added the enhancement New feature or request label Apr 20, 2026
@github-actions

github-actions Bot commented Apr 20, 2026 •

Copy link
Copy Markdown

📊 Code Coverage Report

Metric Coverage Threshold Status
Overall 72.3% 70% ✅
Changed lines 79% 90% ❌
📦 Per-package breakdown
github.com/modelpack/model-csi-driver/pkg/client/grpc.go:104:                    80.0%
github.com/modelpack/model-csi-driver/pkg/client/grpc.go:116:                    80.0%
github.com/modelpack/model-csi-driver/pkg/client/grpc.go:31:                     85.7%
github.com/modelpack/model-csi-driver/pkg/client/grpc.go:60:                     75.0%
github.com/modelpack/model-csi-driver/pkg/client/grpc.go:69:                     80.0%
github.com/modelpack/model-csi-driver/pkg/client/grpc.go:81:                     80.0%
github.com/modelpack/model-csi-driver/pkg/client/grpc.go:92:                     80.0%
github.com/modelpack/model-csi-driver/pkg/client/http.go:105:                    100.0%
github.com/modelpack/model-csi-driver/pkg/client/http.go:23:                     80.0%
github.com/modelpack/model-csi-driver/pkg/client/http.go:49:                     74.3%
github.com/modelpack/model-csi-driver/pkg/client/request.go:12:                  100.0%
github.com/modelpack/model-csi-driver/pkg/client/request.go:34:                  75.0%
github.com/modelpack/model-csi-driver/pkg/client/request.go:50:                  66.7%
github.com/modelpack/model-csi-driver/pkg/client/request.go:65:                  75.0%
github.com/modelpack/model-csi-driver/pkg/config/auth/docker.go:112:             87.5%
github.com/modelpack/model-csi-driver/pkg/config/auth/docker.go:26:              100.0%
github.com/modelpack/model-csi-driver/pkg/config/auth/docker.go:32:              100.0%
github.com/modelpack/model-csi-driver/pkg/config/auth/docker.go:55:              100.0%
github.com/modelpack/model-csi-driver/pkg/config/auth/docker.go:66:              92.9%
github.com/modelpack/model-csi-driver/pkg/config/auth/docker.go:88:              92.3%
github.com/modelpack/model-csi-driver/pkg/config/auth/keychain.go:20:            100.0%
github.com/modelpack/model-csi-driver/pkg/config/auth/keychain.go:31:            100.0%
github.com/modelpack/model-csi-driver/pkg/config/config.go:102:                  100.0%
github.com/modelpack/model-csi-driver/pkg/config/config.go:107:                  100.0%
github.com/modelpack/model-csi-driver/pkg/config/config.go:112:                  100.0%
github.com/modelpack/model-csi-driver/pkg/config/config.go:117:                  100.0%
github.com/modelpack/model-csi-driver/pkg/config/config.go:122:                  100.0%
github.com/modelpack/model-csi-driver/pkg/config/config.go:127:                  100.0%
github.com/modelpack/model-csi-driver/pkg/config/config.go:132:                  100.0%
github.com/modelpack/model-csi-driver/pkg/config/config.go:137:                  100.0%
github.com/modelpack/model-csi-driver/pkg/config/config.go:142:                  100.0%
github.com/modelpack/model-csi-driver/pkg/config/config.go:147:                  100.0%
github.com/modelpack/model-csi-driver/pkg/config/config.go:151:                  100.0%
github.com/modelpack/model-csi-driver/pkg/config/config.go:155:                  100.0%
github.com/modelpack/model-csi-driver/pkg/config/config.go:159:                  61.0%
github.com/modelpack/model-csi-driver/pkg/config/config.go:17:                   87.5%
github.com/modelpack/model-csi-driver/pkg/config/config.go:236:                  83.3%
github.com/modelpack/model-csi-driver/pkg/config/config.go:249:                  100.0%
github.com/modelpack/model-csi-driver/pkg/config/config.go:257:                  100.0%
github.com/modelpack/model-csi-driver/pkg/config/config.go:261:                  100.0%
github.com/modelpack/model-csi-driver/pkg/config/config.go:70:                   100.0%
github.com/modelpack/model-csi-driver/pkg/config/config.go:74:                   100.0%
github.com/modelpack/model-csi-driver/pkg/config/config.go:78:                   100.0%
github.com/modelpack/model-csi-driver/pkg/config/config.go:82:                   100.0%
github.com/modelpack/model-csi-driver/pkg/config/config.go:86:                   100.0%
github.com/modelpack/model-csi-driver/pkg/config/config.go:90:                   100.0%
github.com/modelpack/model-csi-driver/pkg/config/config.go:94:                   100.0%
github.com/modelpack/model-csi-driver/pkg/config/config.go:98:                   100.0%
github.com/modelpack/model-csi-driver/pkg/config/watcher.go:13:                  65.2%
github.com/modelpack/model-csi-driver/pkg/logger/logger.go:19:                   100.0%
github.com/modelpack/model-csi-driver/pkg/logger/logger.go:29:                   100.0%
github.com/modelpack/model-csi-driver/pkg/logger/logger.go:41:                   100.0%
github.com/modelpack/model-csi-driver/pkg/metrics/mount_collector.go:21:         100.0%
github.com/modelpack/model-csi-driver/pkg/metrics/mount_collector.go:34:         100.0%
github.com/modelpack/model-csi-driver/pkg/metrics/mount_collector.go:38:         100.0%
github.com/modelpack/model-csi-driver/pkg/metrics/mount_collector.go:42:         83.3%
github.com/modelpack/model-csi-driver/pkg/metrics/registry.go:117:               100.0%
github.com/modelpack/model-csi-driver/pkg/metrics/registry.go:126:               100.0%
github.com/modelpack/model-csi-driver/pkg/metrics/registry.go:135:               100.0%
github.com/modelpack/model-csi-driver/pkg/metrics/registry.go:146:               100.0%
github.com/modelpack/model-csi-driver/pkg/metrics/registry.go:24:                100.0%
github.com/modelpack/model-csi-driver/pkg/metrics/serve.go:26:                   100.0%
github.com/modelpack/model-csi-driver/pkg/metrics/serve.go:37:                   90.9%
github.com/modelpack/model-csi-driver/pkg/metrics/serve.go:59:                   83.3%
github.com/modelpack/model-csi-driver/pkg/mounter/builder.go:39:                 100.0%
github.com/modelpack/model-csi-driver/pkg/mounter/builder.go:50:                 100.0%
github.com/modelpack/model-csi-driver/pkg/mounter/builder.go:54:                 100.0%
github.com/modelpack/model-csi-driver/pkg/mounter/builder.go:59:                 100.0%
github.com/modelpack/model-csi-driver/pkg/mounter/builder.go:64:                 100.0%
github.com/modelpack/model-csi-driver/pkg/mounter/builder.go:69:                 100.0%
github.com/modelpack/model-csi-driver/pkg/mounter/builder.go:74:                 100.0%
github.com/modelpack/model-csi-driver/pkg/mounter/builder.go:82:                 100.0%
github.com/modelpack/model-csi-driver/pkg/mounter/builder.go:88:                 80.0%
github.com/modelpack/model-csi-driver/pkg/mounter/mounter.go:15:                 100.0%
github.com/modelpack/model-csi-driver/pkg/mounter/mounter.go:26:                 66.7%
github.com/modelpack/model-csi-driver/pkg/mounter/mounter.go:37:                 90.9%
github.com/modelpack/model-csi-driver/pkg/mounter/mounter.go:57:                 71.4%
github.com/modelpack/model-csi-driver/pkg/mounter/mounter.go:81:                 83.3%
github.com/modelpack/model-csi-driver/pkg/provider/provider.go:15:               100.0%
github.com/modelpack/model-csi-driver/pkg/service/artifact.go:11:                100.0%
github.com/modelpack/model-csi-driver/pkg/service/cache.go:119:                  85.7%
github.com/modelpack/model-csi-driver/pkg/service/cache.go:135:                  85.7%
github.com/modelpack/model-csi-driver/pkg/service/cache.go:28:                   100.0%
github.com/modelpack/model-csi-driver/pkg/service/cache.go:37:                   73.9%
github.com/modelpack/model-csi-driver/pkg/service/controller.go:101:             100.0%
github.com/modelpack/model-csi-driver/pkg/service/controller.go:115:             100.0%
github.com/modelpack/model-csi-driver/pkg/service/controller.go:122:             76.2%
github.com/modelpack/model-csi-driver/pkg/service/controller.go:157:             100.0%
github.com/modelpack/model-csi-driver/pkg/service/controller.go:164:             50.0%
github.com/modelpack/model-csi-driver/pkg/service/controller.go:189:             100.0%
github.com/modelpack/model-csi-driver/pkg/service/controller.go:196:             100.0%
github.com/modelpack/model-csi-driver/pkg/service/controller.go:203:             100.0%
github.com/modelpack/model-csi-driver/pkg/service/controller.go:210:             100.0%
github.com/modelpack/model-csi-driver/pkg/service/controller.go:217:             100.0%
github.com/modelpack/model-csi-driver/pkg/service/controller.go:22:              84.0%
github.com/modelpack/model-csi-driver/pkg/service/controller.go:249:             100.0%
github.com/modelpack/model-csi-driver/pkg/service/controller.go:256:             100.0%
github.com/modelpack/model-csi-driver/pkg/service/controller.go:263:             100.0%
github.com/modelpack/model-csi-driver/pkg/service/controller.go:270:             100.0%
github.com/modelpack/model-csi-driver/pkg/service/controller.go:62:              91.3%
github.com/modelpack/model-csi-driver/pkg/service/controller_local.go:143:       79.3%
github.com/modelpack/model-csi-driver/pkg/service/controller_local.go:184:       28.1%
github.com/modelpack/model-csi-driver/pkg/service/controller_local.go:25:        57.3%
github.com/modelpack/model-csi-driver/pkg/service/controller_remote.go:134:      0.0%
github.com/modelpack/model-csi-driver/pkg/service/controller_remote.go:199:      0.0%
github.com/modelpack/model-csi-driver/pkg/service/controller_remote.go:34:       100.0%
github.com/modelpack/model-csi-driver/pkg/service/controller_remote.go:46:       0.0%
github.com/modelpack/model-csi-driver/pkg/service/controller_remote.go:60:       0.0%
github.com/modelpack/model-csi-driver/pkg/service/dynamic_server.go:107:         66.7%
github.com/modelpack/model-csi-driver/pkg/service/dynamic_server.go:151:         71.4%
github.com/modelpack/model-csi-driver/pkg/service/dynamic_server.go:190:         88.9%
github.com/modelpack/model-csi-driver/pkg/service/dynamic_server.go:46:          100.0%
github.com/modelpack/model-csi-driver/pkg/service/dynamic_server.go:54:          82.4%
github.com/modelpack/model-csi-driver/pkg/service/dynamic_server.go:84:          83.3%
github.com/modelpack/model-csi-driver/pkg/service/dynamic_server_handler.go:124: 83.3%
github.com/modelpack/model-csi-driver/pkg/service/dynamic_server_handler.go:156: 90.9%
github.com/modelpack/model-csi-driver/pkg/service/dynamic_server_handler.go:185: 85.7%
github.com/modelpack/model-csi-driver/pkg/service/dynamic_server_handler.go:203: 100.0%
github.com/modelpack/model-csi-driver/pkg/service/dynamic_server_handler.go:27:  83.3%
github.com/modelpack/model-csi-driver/pkg/service/dynamic_server_handler.go:38:  100.0%
github.com/modelpack/model-csi-driver/pkg/service/dynamic_server_handler.go:56:  80.0%
github.com/modelpack/model-csi-driver/pkg/service/identity.go:21:                100.0%
github.com/modelpack/model-csi-driver/pkg/service/identity.go:41:                100.0%
github.com/modelpack/model-csi-driver/pkg/service/identity.go:9:                 100.0%
github.com/modelpack/model-csi-driver/pkg/service/kube.go:14:                    0.0%
github.com/modelpack/model-csi-driver/pkg/service/kube.go:25:                    0.0%
github.com/modelpack/model-csi-driver/pkg/service/kube.go:34:                    0.0%
github.com/modelpack/model-csi-driver/pkg/service/model.go:114:                  89.5%
github.com/modelpack/model-csi-driver/pkg/service/model.go:157:                  91.7%
github.com/modelpack/model-csi-driver/pkg/service/model.go:177:                  100.0%
github.com/modelpack/model-csi-driver/pkg/service/model.go:27:                   80.0%
github.com/modelpack/model-csi-driver/pkg/service/model.go:48:                   100.0%
github.com/modelpack/model-csi-driver/pkg/service/model.go:60:                   100.0%
github.com/modelpack/model-csi-driver/pkg/service/model.go:68:                   100.0%
github.com/modelpack/model-csi-driver/pkg/service/model.go:96:                   100.0%
github.com/modelpack/model-csi-driver/pkg/service/node.go:123:                   94.4%
github.com/modelpack/model-csi-driver/pkg/service/node.go:154:                   66.7%
github.com/modelpack/model-csi-driver/pkg/service/node.go:202:                   94.4%
github.com/modelpack/model-csi-driver/pkg/service/node.go:233:                   100.0%
github.com/modelpack/model-csi-driver/pkg/service/node.go:241:                   100.0%
github.com/modelpack/model-csi-driver/pkg/service/node.go:249:                   100.0%
github.com/modelpack/model-csi-driver/pkg/service/node.go:269:                   100.0%
github.com/modelpack/model-csi-driver/pkg/service/node.go:26:                    100.0%
github.com/modelpack/model-csi-driver/pkg/service/node.go:34:                    100.0%
github.com/modelpack/model-csi-driver/pkg/service/node.go:42:                    100.0%
github.com/modelpack/model-csi-driver/pkg/service/node.go:46:                    100.0%
github.com/modelpack/model-csi-driver/pkg/service/node.go:50:                    48.8%
github.com/modelpack/model-csi-driver/pkg/service/node_dynamic.go:18:            73.3%
github.com/modelpack/model-csi-driver/pkg/service/node_dynamic.go:52:            68.4%
github.com/modelpack/model-csi-driver/pkg/service/node_static.go:16:             81.8%
github.com/modelpack/model-csi-driver/pkg/service/node_static.go:42:             69.2%
github.com/modelpack/model-csi-driver/pkg/service/node_static_inline.go:17:      0.0%
github.com/modelpack/model-csi-driver/pkg/service/node_static_inline.go:53:      57.1%
github.com/modelpack/model-csi-driver/pkg/service/puller.go:42:                  26.9%
github.com/modelpack/model-csi-driver/pkg/service/quota.go:21:                   94.1%
github.com/modelpack/model-csi-driver/pkg/service/quota.go:49:                   100.0%
github.com/modelpack/model-csi-driver/pkg/service/quota.go:55:                   100.0%
github.com/modelpack/model-csi-driver/pkg/service/quota.go:67:                   84.2%
github.com/modelpack/model-csi-driver/pkg/service/service.go:41:                 100.0%
github.com/modelpack/model-csi-driver/pkg/service/service.go:45:                 0.0%
github.com/modelpack/model-csi-driver/pkg/service/worker.go:118:                 100.0%
github.com/modelpack/model-csi-driver/pkg/service/worker.go:124:                 100.0%
github.com/modelpack/model-csi-driver/pkg/service/worker.go:136:                 100.0%
github.com/modelpack/model-csi-driver/pkg/service/worker.go:153:                 100.0%
github.com/modelpack/model-csi-driver/pkg/service/worker.go:165:                 69.2%
github.com/modelpack/model-csi-driver/pkg/service/worker.go:213:                 100.0%
github.com/modelpack/model-csi-driver/pkg/service/worker.go:226:                 50.0%
github.com/modelpack/model-csi-driver/pkg/service/worker.go:244:                 57.1%
github.com/modelpack/model-csi-driver/pkg/service/worker.go:265:                 87.5%
github.com/modelpack/model-csi-driver/pkg/service/worker.go:290:                 77.6%
github.com/modelpack/model-csi-driver/pkg/service/worker.go:32:                  92.9%
github.com/modelpack/model-csi-driver/pkg/service/worker.go:444:                 100.0%
github.com/modelpack/model-csi-driver/pkg/service/worker.go:457:                 93.5%
github.com/modelpack/model-csi-driver/pkg/service/worker.go:65:                  100.0%
github.com/modelpack/model-csi-driver/pkg/status/hook.go:100:                    100.0%
github.com/modelpack/model-csi-driver/pkg/status/hook.go:107:                    86.7%
github.com/modelpack/model-csi-driver/pkg/status/hook.go:139:                    100.0%
github.com/modelpack/model-csi-driver/pkg/status/hook.go:174:                    88.9%
github.com/modelpack/model-csi-driver/pkg/status/hook.go:195:                    100.0%
github.com/modelpack/model-csi-driver/pkg/status/hook.go:28:                     100.0%
github.com/modelpack/model-csi-driver/pkg/status/hook.go:34:                     100.0%
github.com/modelpack/model-csi-driver/pkg/status/hook.go:46:                     100.0%
github.com/modelpack/model-csi-driver/pkg/status/hook.go:53:                     100.0%
github.com/modelpack/model-csi-driver/pkg/status/hook.go:69:                     100.0%
github.com/modelpack/model-csi-driver/pkg/status/hook.go:76:                     80.0%
github.com/modelpack/model-csi-driver/pkg/status/hook.go:87:                     100.0%
github.com/modelpack/model-csi-driver/pkg/status/status.go:105:                  100.0%
github.com/modelpack/model-csi-driver/pkg/status/status.go:123:                  87.5%
github.com/modelpack/model-csi-driver/pkg/status/status.go:138:                  83.3%
github.com/modelpack/model-csi-driver/pkg/status/status.go:149:                  100.0%
github.com/modelpack/model-csi-driver/pkg/status/status.go:51:                   75.0%
github.com/modelpack/model-csi-driver/pkg/status/status.go:77:                   100.0%
github.com/modelpack/model-csi-driver/pkg/status/status.go:81:                   100.0%
github.com/modelpack/model-csi-driver/pkg/status/status.go:87:                   66.7%
github.com/modelpack/model-csi-driver/pkg/tracing/tracing.go:22:                 85.7%
github.com/modelpack/model-csi-driver/pkg/tracing/tracing.go:36:                 55.6%
github.com/modelpack/model-csi-driver/pkg/tracing/tracing.go:72:                 100.0%
github.com/modelpack/model-csi-driver/pkg/tracing/tracing.go:79:                 81.8%
github.com/modelpack/model-csi-driver/pkg/utils/utils.go:16:                     100.0%
github.com/modelpack/model-csi-driver/pkg/utils/utils.go:34:                     75.0%
github.com/modelpack/model-csi-driver/pkg/utils/utils.go:54:                     81.8%

total:											(statements)				72.3%

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request implements cross-volume deduplication for model pulls to reduce network I/O and disk usage. The changes introduce manifest digest resolution for both static and mutable tags, allowing the driver to identify and reuse existing on-disk models via hardlinks. A new keyed locker (refMutex) is used to serialize concurrent pulls of the same reference. Feedback focuses on optimizing the digest resolution process using singleflight to prevent redundant network requests and ensuring that lock acquisition respects the request context to handle cancellations properly.

Comment thread pkg/service/worker.go
Comment thread pkg/service/worker.go Outdated
@chlins
chlins force-pushed the feat/hardlink branch 2 times, most recently from a186e46 to a56ec82 Compare April 20, 2026 03:51
@imeoer
imeoer requested review from Copilot and imeoer April 28, 2026 07:19

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds digest-aware, cross-volume model pull deduplication so multiple pulls of the same model content (manifest digest) avoid redundant downloads/disk usage by hardlinking to an existing local copy, and persists the resolved digest in status tracking.

Changes:

  • Add Digest to persisted status.Status and propagate it through pull status updates.
  • Resolve manifest digests (parse for @sha256:..., remote inspect for tag refs) and use digests to key dedup + existence checks.
  • Introduce hardlink-based cloning plus extensive unit tests for dedup, digest resolution, and directory scanning across volume layouts.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 6 comments.

File Description
pkg/status/status.go Persist manifest digest in status.json via new Status.Digest field.
pkg/service/worker.go Implement digest resolution, digest-keyed dedup via hardlink cloning, and digest-keyed existing-model discovery.
pkg/service/worker_test.go Update existence tests to be digest-keyed and add an empty-digest guard test.
pkg/service/pull_dedup_test.go Add broad test coverage for hardlink cloning, dedup behavior, and digest resolution hooks.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +1043 to +1067
// TestInspectArtifactFn_DefaultImpl exercises the default inspectArtifactFn
// (the one constructed at package init), which delegates to a real
// ModelArtifact.Inspect. Backend.Inspect is patched at a lower level so no
// network is involved. This covers the otherwise-unreachable statements of
// the default closure.
func TestInspectArtifactFn_DefaultImpl(t *testing.T) {
tmpDir := t.TempDir()
b, err := modctlBackend.New(filepath.Join(tmpDir, "modctl"))
require.NoError(t, err)

// Patch the backend's Inspect to avoid any network call. The patch is
// scoped to this test only; gomonkey is intentionally avoided to keep
// test ordering robust.
origInspect := inspectArtifactFn
t.Cleanup(func() { inspectArtifactFn = origInspect })

// Call the default implementation directly, but route Inspect through a
// stub by temporarily replacing the package-level Inspect via a small
// wrapper. We invoke the original closure to cover its statements.
got, err := origInspect(b, "registry/repo:latest", false, context.Background())
// Either we receive an error (no real registry available) OR a result;
// both outcomes execute the body of the default closure, which is the
// goal of this test. Assert only that it does not panic.
_ = got
_ = err

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

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

This test claims to avoid network by "patching" backend.Inspect, but no stub is actually installed; origInspect(...) will call ModelArtifact.Inspect, which in turn calls b.Inspect with Remote: true and can hit a real registry (flaky/slow, depends on network). Please replace this with a deterministic fake backend (implementing the backend.Backend Inspect method) or explicitly stub b.Inspect (e.g., via gomonkey like other tests) so the test is hermetic.

Suggested change
// TestInspectArtifactFn_DefaultImpl exercises the default inspectArtifactFn
// (the one constructed at package init), which delegates to a real
// ModelArtifact.Inspect. Backend.Inspect is patched at a lower level so no
// network is involved. This covers the otherwise-unreachable statements of
// the default closure.
func TestInspectArtifactFn_DefaultImpl(t *testing.T) {
tmpDir := t.TempDir()
b, err := modctlBackend.New(filepath.Join(tmpDir, "modctl"))
require.NoError(t, err)
// Patch the backend's Inspect to avoid any network call. The patch is
// scoped to this test only; gomonkey is intentionally avoided to keep
// test ordering robust.
origInspect := inspectArtifactFn
t.Cleanup(func() { inspectArtifactFn = origInspect })
// Call the default implementation directly, but route Inspect through a
// stub by temporarily replacing the package-level Inspect via a small
// wrapper. We invoke the original closure to cover its statements.
got, err := origInspect(b, "registry/repo:latest", false, context.Background())
// Either we receive an error (no real registry available) OR a result;
// both outcomes execute the body of the default closure, which is the
// goal of this test. Assert only that it does not panic.
_ = got
_ = err
// TestInspectArtifactFn_Stubbed is hermetic: it verifies that the
// package-level inspectArtifactFn hook can be replaced for tests and that
// callers receive the stubbed result without invoking any real backend or
// network-dependent inspection path.
func TestInspectArtifactFn_Stubbed(t *testing.T) {
tmpDir := t.TempDir()
b, err := modctlBackend.New(filepath.Join(tmpDir, "modctl"))
require.NoError(t, err)
origInspect := inspectArtifactFn
t.Cleanup(func() { inspectArtifactFn = origInspect })
var called atomic.Bool
inspectArtifactFn = func(gotBackend modctlBackend.Backend, gotRef string, gotInsecure bool, gotCtx context.Context) (interface{}, error) {
called.Store(true)
require.Equal(t, b, gotBackend)
require.Equal(t, "registry/repo:latest", gotRef)
require.False(t, gotInsecure)
require.NotNil(t, gotCtx)
return "stubbed-inspect-result", nil
}
got, err := inspectArtifactFn(b, "registry/repo:latest", false, context.Background())
require.NoError(t, err)
require.True(t, called.Load())
require.Equal(t, "stubbed-inspect-result", got)

Copilot uses AI. Check for mistakes.
Comment on lines +204 to +206
// TestPullModel_Dedup_DifferentReferencesNotDeduped ensures dedup is keyed by
// reference.
func TestPullModel_Dedup_DifferentReferencesNotDeduped(t *testing.T) {

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

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

The comment says dedup is keyed by reference, but the new implementation is digest-keyed (two different references can dedup if they resolve to the same digest, and the same reference can stop deduping if the digest changes). Please update this comment (or rename the test) to describe digest-based behavior accurately.

Suggested change
// TestPullModel_Dedup_DifferentReferencesNotDeduped ensures dedup is keyed by
// reference.
func TestPullModel_Dedup_DifferentReferencesNotDeduped(t *testing.T) {
// TestPullModel_Dedup_DifferentDigestsNotDeduped ensures pulls with different
// digests are not deduped.
func TestPullModel_Dedup_DifferentDigestsNotDeduped(t *testing.T) {

Copilot uses AI. Check for mistakes.
Comment on lines +538 to +544
// TestPullModel_Dedup_FullPullAfterExcludeVariant verifies that an existing
// volume populated by a partial-pull (exclude variant) is *not* used as a
// dedup source for subsequent full-pull requests, because findExistingModelDir
// only matches by reference + state. It also confirms that a subsequent
// full-pull's own output then becomes a valid dedup source for further
// full-pulls.
func TestPullModel_Dedup_SecondFullPullReusesFirst(t *testing.T) {

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

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

This test's docstring describes a "full pull after exclude variant" scenario, but the test body performs only full pulls (excludeModelWeights=false, excludeFilePatterns=nil) and does not create a partial-pull source. Also it references findExistingModelDir matching by "reference + state", which is no longer accurate after digest-keying. Please update the comment (and/or add the intended partial-pull setup) so the test matches what it claims to verify.

Suggested change
// TestPullModel_Dedup_FullPullAfterExcludeVariant verifies that an existing
// volume populated by a partial-pull (exclude variant) is *not* used as a
// dedup source for subsequent full-pull requests, because findExistingModelDir
// only matches by reference + state. It also confirms that a subsequent
// full-pull's own output then becomes a valid dedup source for further
// full-pulls.
func TestPullModel_Dedup_SecondFullPullReusesFirst(t *testing.T) {
// TestPullModel_Dedup_RepeatedFullPullsReuseFirst verifies that once a
// full pull has materialized content for a given digest, subsequent full pulls
// for that same digest reuse the existing model directory as the dedup source
// instead of invoking the puller again. It also confirms the reused files are
// hardlinked across the destinations.
func TestPullModel_Dedup_RepeatedFullPullsReuseFirst(t *testing.T) {

Copilot uses AI. Check for mistakes.
Comment thread pkg/service/worker.go
Comment on lines +297 to +306
// Cross-volume dedup: for full-pull requests (no exclude variants), try
// to hardlink from an existing successfully-pulled model dir of the
// same reference. This avoids both duplicate network I/O and duplicate
// disk usage when many pods on the same node mount the same model.
//
// The refMutex is held across the *entire* pull (including the real
// pull below) so that concurrent requests for the same reference are
// serialized: the first request performs the real pull, and the rest
// observe the now-populated source dir and clone via hardlink.
fullPull := !excludeModelWeights && len(excludeFilePatterns) == 0

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

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

Hardlink-based dedup means multiple volumes will share the same underlying inodes. Since volumes are currently bind-mounted without honoring the CSI readonly flag (see pkg/service/node.go and node_static*.go), a write in one pod could mutate the model for other pods/volumes. To avoid cross-volume data corruption and potential tenant isolation issues, please either (a) ensure mounts are enforced read-only when using hardlinks (e.g., bind-mount + remount ro) or (b) switch dedup cloning to a copy/reflink strategy when the target may be writable.

Copilot uses AI. Check for mistakes.
Comment thread pkg/service/worker.go
Comment on lines +299 to +305
// same reference. This avoids both duplicate network I/O and duplicate
// disk usage when many pods on the same node mount the same model.
//
// The refMutex is held across the *entire* pull (including the real
// pull below) so that concurrent requests for the same reference are
// serialized: the first request performs the real pull, and the rest
// observe the now-populated source dir and clone via hardlink.

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

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

The dedup block and refMutex comments describe serialization and matching as being by "reference", but the implementation is keyed by digest (refKey := "digest/" + digest). Please update the surrounding comments to reflect digest-based behavior (especially important for mutable tags like :latest).

Suggested change
// same reference. This avoids both duplicate network I/O and duplicate
// disk usage when many pods on the same node mount the same model.
//
// The refMutex is held across the *entire* pull (including the real
// pull below) so that concurrent requests for the same reference are
// serialized: the first request performs the real pull, and the rest
// observe the now-populated source dir and clone via hardlink.
// same resolved digest. This avoids both duplicate network I/O and
// duplicate disk usage when many pods on the same node mount the same
// model content, even if it was requested through different references.
//
// The refMutex is held across the *entire* pull (including the real
// pull below) so that concurrent requests that resolve to the same
// digest are serialized: the first request performs the real pull, and
// the rest observe the now-populated source dir and clone via hardlink.
// This is intentionally digest-based rather than reference-based so
// mutable tags like ":latest" do not incorrectly reuse stale content.

Copilot uses AI. Check for mistakes.
Comment thread pkg/service/worker.go
Comment on lines +404 to +406
// Returns the model sub-directory path if the volume's status indicates a
// successful pull of the requested reference and the on-disk model dir
// exists.

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

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

Comment says "successful pull of the requested reference" but matching is now performed by st.Digest (and the function argument is digest). Please update this comment to avoid misleading future readers.

Suggested change
// Returns the model sub-directory path if the volume's status indicates a
// successful pull of the requested reference and the on-disk model dir
// exists.
// Returns the model sub-directory path if the volume's status matches the
// requested digest, indicates a successfully pulled or mounted model, and
// the on-disk model dir exists.

Copilot uses AI. Check for mistakes.

@imeoer imeoer 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.

Thanks for the useful PR!

Comment thread pkg/service/worker.go
Comment thread pkg/service/worker.go
Comment thread pkg/service/worker.go
// real-pull path below; for the optimistic clone fast path
// we record best-effort progress and proceed.
_, _ = setStatus(status.StatePullRunning)
if err := cloneByHardlink(srcModelDir, modelDir); err == nil {

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.

Could it happen that files in the src model dir are being deleted one by one (triggered by DeleteVolume) while cloneByHardlink succeeds, yet the resulting file set is incomplete (in terms of count)?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good catch! will update this part to prevent this case.

@chlins

chlins commented May 6, 2026

Copy link
Copy Markdown
Member Author

@imeoer Please help review again, thanks!

Copy link
Copy Markdown

Reviewed the PR and found three correctness issues that should be addressed before merge:

  1. Partial pulls can become dedup sources. A pull with excludeModelWeights or excludeFilePatterns still writes the same digest and success state, so a later full pull may hardlink from an incomplete directory and report success.

  2. Tag pulls are not pinned after digest resolution. The code records the digest returned by Inspect, but still pulls the mutable tag. If the tag moves between Inspect and Pull, status can record digest A while the files on disk are from digest B, causing future digest-based dedup to reuse wrong content.

  3. Some unpublish paths bypass the new deletion lock. findExistingModelDir can use inline/dynamic dirs as dedup sources, but inline and dynamic unpublish paths still remove those dirs directly. That can race with cloneByHardlink and leave an incomplete cloned tree.

Suggested direction: only full pulls should be eligible as dedup sources, pull tag references by the resolved digest or verify the pulled digest afterward, and route every deletion of a possible dedup source through the same digest lock.

@chlins

chlins commented May 7, 2026

Copy link
Copy Markdown
Member Author

Reviewed the PR and found three correctness issues that should be addressed before merge:

  1. Partial pulls can become dedup sources. A pull with excludeModelWeights or excludeFilePatterns still writes the same digest and success state, so a later full pull may hardlink from an incomplete directory and report success.
  2. Tag pulls are not pinned after digest resolution. The code records the digest returned by Inspect, but still pulls the mutable tag. If the tag moves between Inspect and Pull, status can record digest A while the files on disk are from digest B, causing future digest-based dedup to reuse wrong content.
  3. Some unpublish paths bypass the new deletion lock. findExistingModelDir can use inline/dynamic dirs as dedup sources, but inline and dynamic unpublish paths still remove those dirs directly. That can race with cloneByHardlink and leave an incomplete cloned tree.

Suggested direction: only full pulls should be eligible as dedup sources, pull tag references by the resolved digest or verify the pulled digest afterward, and route every deletion of a possible dedup source through the same digest lock.

@aftersnow Thanks for the careful review. All three points addressed:

  1. Partial pulls as dedup sources — Status now persists ExcludeModelWeights / ExcludeFilePatterns. findExistingModelDir skips any source where !IsFullPull(). Zero-value defaults keep legacy status.json files compatible (treated as full pulls).

  2. Tag pulls not pinned after digest resolution — went with the "verify after pull" variant rather than rewriting the reference (modctl's backend.Pull rejects digest-only refs and fails with invalid reference). After a successful tag-form pull we re-resolve the digest and persist the post-pull value, so status.Digest always matches the bytes on disk.

  3. Unpublish paths bypassing the deletion lock — added Worker.SafeRemoveAll(root) which walks root, collects every fully-pulled status.json's digest, locks each refMutex in sorted order (deadlock-free), then RemoveAlls. Both nodeUnPublishVolumeDynamic and nodeUnPublishVolumeStaticInlineVolume now route through it instead of calling os.RemoveAll directly.

PTAL.

Signed-off-by: chlins <chlins.zhang@gmail.com>
@imeoer

imeoer commented May 7, 2026

Copy link
Copy Markdown
Collaborator

It appears there are indeed numerous race conditions to address, to mitigate these issues, we’ve had to introduce some compatibility code, could we proceed as suggested here: #35 (comment)

@chlins chlins closed this May 8, 2026
@chlins
chlins deleted the feat/hardlink branch May 9, 2026 03:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants