Skip to content

Commit b66251b

Browse files
bmehta001Copilot
andcommitted
fix(download): per-model serialization + airtight cross-process lock
Address the download-lock review items: - Per-model in-process lock. Replace the single global download_mutex_ with a per-model mutex keyed on the resolved cache path. Two downloads of the same model serialize; downloads of different models run concurrently in-process instead of queuing behind each other's (up to 3 h) cross-process waits. - Close the POSIX flock()+unlink() orphan-inode race. After flock() succeeds, verify (fstat vs stat) that the inode we locked is still the file at the lock path; if a racing releaser unlinked it and a third process recreated it, drop the stale lock and report contention so the caller retries. This makes the self-cleaning unlink-on-release provably safe and guarantees two processes can never both believe they hold the lock - so a model can never be downloaded to the same directory twice at once, across any number of processes or apps. - Fix the misleading Windows ACCESS_DENIED comment: it is the DELETE_ON_CLOSE delete-pending window (STATUS_DELETE_PENDING), not "narrower access rights". - Document why the POSIX unlink-before-close is safe (fresh-fd non-blocking waiters; no work between unlink and close; the inode check above). - Decouple the lock-wait cadence from the progress heartbeat: poll the cancellation/heartbeat callback once per poll_interval instead of every 100 ms, so a user callback is not invoked ~10x/s for the whole wait. - Tests: add ConcurrentDownloadsOfDifferentModelsRunConcurrently (proves different models do not serialize). Existing same-model serialize and CrossProcessFileLock acquire/release/wait/cancel/timeout tests still pass (66 download-suite tests green; POSIX branch syntax-checked under g++). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent f7f0ada commit b66251b

4 files changed

Lines changed: 172 additions & 24 deletions

File tree

sdk_v2/cpp/src/download/cross_process_file_lock.cc

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
#include <errno.h>
2323
#include <fcntl.h>
2424
#include <sys/file.h>
25+
#include <sys/stat.h>
2526
#include <unistd.h>
2627
#endif
2728

@@ -71,8 +72,15 @@ struct CrossProcessFileLock::State {
7172
std::filesystem::path path;
7273
~State() {
7374
if (fd >= 0) {
74-
// Unlink before close so the file disappears at the same instant the
75-
// lock releases; a concurrent acquirer simply recreates it.
75+
// Unlink before close so the file disappears the instant the lock
76+
// releases; a concurrent acquirer simply recreates it. This is the
77+
// classic flock()+unlink() pattern, and it is safe here because every
78+
// acquirer verifies, while holding the flock, that the inode it locked is
79+
// still the one at `path` (see the fstat/stat check in
80+
// TryAcquireForDirectory). An acquirer that raced in on the old inode
81+
// between our unlink and a third party's recreate will see the inode
82+
// mismatch and retry, so two processes never hold "the lock" at once.
83+
// There is also no protected work between this unlink and close.
7684
::unlink(path.c_str());
7785
::close(fd);
7886
}
@@ -118,8 +126,14 @@ std::unique_ptr<CrossProcessFileLock> CrossProcessFileLock::TryAcquireForDirecto
118126
if (handle == INVALID_HANDLE_VALUE) {
119127
DWORD err = GetLastError();
120128
if (err == ERROR_SHARING_VIOLATION || err == ERROR_LOCK_VIOLATION || err == ERROR_ACCESS_DENIED) {
121-
// ACCESS_DENIED can surface on FILE_SHARE_NONE collisions when the
122-
// existing handle has narrower access rights — treat as contention.
129+
// SHARING/LOCK_VIOLATION: another handle already holds the share-none
130+
// lock. ACCESS_DENIED: the holder is mid-release — FILE_FLAG_DELETE_ON_CLOSE
131+
// puts the file into STATUS_DELETE_PENDING during the close window, and a
132+
// concurrent open of a delete-pending file is reported as ACCESS_DENIED.
133+
// All three mean "another process has it"; treat as contention so the
134+
// caller retries. (A genuine permission error also lands here and would
135+
// poll until timeout, but the directory was just created successfully so
136+
// that is improbable.)
123137
return nullptr;
124138
}
125139
FL_THROW(FOUNDRY_LOCAL_ERROR_INTERNAL,
@@ -149,6 +163,23 @@ std::unique_ptr<CrossProcessFileLock> CrossProcessFileLock::TryAcquireForDirecto
149163
"flock failed for '" + lock_path.string() + "' (errno=" + std::to_string(err) + ")");
150164
}
151165

166+
// Robust-flock inode check. We now hold an exclusive flock on whatever inode
167+
// `fd` refers to, but a releaser unlink()s the lock file in its destructor —
168+
// so between our open() and flock() the path may have been unlinked and a
169+
// third process may have recreated it. If so, we are holding a lock on an
170+
// orphaned inode that guards nothing while the live file at `lock_path` is a
171+
// different inode. Confirm the inode we locked is still the one at the path;
172+
// if not, drop it and report contention so the caller retries against the
173+
// live file. This closes the flock()+unlink() orphan-inode race, which is
174+
// what lets two processes never both believe they hold the lock.
175+
struct stat fd_stat {};
176+
struct stat path_stat {};
177+
if (::fstat(fd, &fd_stat) != 0 || ::stat(lock_path.c_str(), &path_stat) != 0 ||
178+
fd_stat.st_dev != path_stat.st_dev || fd_stat.st_ino != path_stat.st_ino) {
179+
::close(fd); // releases the flock on the stale / orphaned inode
180+
return nullptr;
181+
}
182+
152183
(void)::ftruncate(fd, 0);
153184
auto info = FormatProcessInfo();
154185
(void)::write(fd, info.data(), info.size());
@@ -170,9 +201,13 @@ std::unique_ptr<CrossProcessFileLock> WaitForLockForDirectory(
170201
std::chrono::milliseconds poll_interval,
171202
std::chrono::milliseconds timeout) {
172203
auto deadline = std::chrono::steady_clock::now() + timeout;
173-
// Poll cancellation in slices of at most 100 ms so a long poll interval
174-
// (1.25 s default) doesn't keep a cancelling caller waiting.
175-
constexpr std::chrono::milliseconds kCancelSlice{100};
204+
// `is_cancelled` is the caller's progress callback, which also serves as the
205+
// liveness heartbeat — it emits 0% on every invocation. We therefore poll it
206+
// on a single cadence (once per `poll_interval`) rather than on a separate
207+
// fast cancellation tick: a faster tick would spam the user callback (~10x/s)
208+
// for the entire wait, and cancelling a multi-minute cross-process wait a
209+
// second sooner is imperceptible. There is no separate cancellation channel
210+
// to decouple the heartbeat from.
176211
while (true) {
177212
if (is_cancelled && is_cancelled()) {
178213
FL_THROW(FOUNDRY_LOCAL_ERROR_OPERATION_CANCELLED, "lock acquisition cancelled");
@@ -185,13 +220,7 @@ std::unique_ptr<CrossProcessFileLock> WaitForLockForDirectory(
185220
FL_THROW(FOUNDRY_LOCAL_ERROR_INTERNAL,
186221
"timed out waiting for cross-process download lock on '" + directory.string() + "'");
187222
}
188-
auto slice_end = std::chrono::steady_clock::now() + poll_interval;
189-
while (std::chrono::steady_clock::now() < slice_end) {
190-
if (is_cancelled && is_cancelled()) {
191-
FL_THROW(FOUNDRY_LOCAL_ERROR_OPERATION_CANCELLED, "lock acquisition cancelled");
192-
}
193-
std::this_thread::sleep_for(std::min(kCancelSlice, poll_interval));
194-
}
223+
std::this_thread::sleep_for(poll_interval);
195224
}
196225
}
197226

sdk_v2/cpp/src/download/download_manager.cc

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
#include <cctype>
1414
#include <filesystem>
1515
#include <fstream>
16+
#include <memory>
17+
#include <mutex>
1618
#include <string>
1719
#include <string_view>
1820

@@ -212,15 +214,25 @@ std::string DownloadManager::ComputeModelPath(const ModelInfo& info) const {
212214
return full_path.string();
213215
}
214216

217+
std::shared_ptr<std::mutex> DownloadManager::GetModelLock(const std::string& model_path) const {
218+
std::lock_guard<std::mutex> guard(model_locks_mutex_);
219+
auto& slot = model_locks_[model_path];
220+
if (!slot) {
221+
slot = std::make_shared<std::mutex>();
222+
}
223+
return slot;
224+
}
225+
215226
std::string DownloadManager::DownloadModel(const ModelInfo& info,
216227
std::function<int(float)> progress_cb) {
217-
// Serialize all downloads. Concurrent downloads of the same model would race into
218-
// creating the same directory and double-writing inference_model.json; concurrent
219-
// downloads of different models would compete for the same per-blob chunk parallelism.
220-
// A single global lock keeps the model simple and predictable.
221-
std::lock_guard<std::mutex> download_guard(download_mutex_);
222-
228+
// Resolve the cache path first, then serialize per model. Two downloads of the
229+
// same model share one mutex and run one-at-a-time; downloads of different
230+
// models take different mutexes and proceed concurrently. The cross-process
231+
// file lock taken below extends the same-model guarantee across every process
232+
// and app that shares this cache directory.
223233
auto model_path = ComputeModelPath(info);
234+
auto model_lock = GetModelLock(model_path);
235+
std::lock_guard<std::mutex> download_guard(*model_lock);
224236

225237
// Fast path: serve the cache without taking the cross-process lock.
226238
// A valid cache hit requires: directory exists, no in-progress signal file, and

sdk_v2/cpp/src/download/download_manager.h

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
#include <atomic>
1111
#include <functional>
12+
#include <map>
1213
#include <memory>
1314
#include <mutex>
1415
#include <string>
@@ -65,16 +66,28 @@ class DownloadManager {
6566
/// Uses {cache_dir}/{publisher}/{model_id_with_version_fix}
6667
std::string ComputeModelPath(const ModelInfo& info) const;
6768

69+
/// Get (creating on first use) the per-model serialization mutex for the
70+
/// resolved cache path `model_path`. Downloads of the same model share one
71+
/// mutex and run one-at-a-time; downloads of different models get distinct
72+
/// mutexes and proceed concurrently in-process.
73+
std::shared_ptr<std::mutex> GetModelLock(const std::string& model_path) const;
74+
6875
std::string cache_directory_;
6976
int max_concurrency_;
7077
ILogger& logger_;
7178
std::unique_ptr<ModelRegistryClient> registry_client_;
7279
std::unique_ptr<IBlobDownloader> blob_downloader_;
7380

74-
/// Serializes all DownloadModel calls. Only one model downloads at a time — simpler
75-
/// than per-model locking and avoids contending with the per-blob chunk parallelism
76-
/// (`max_concurrency_`) inside a single download.
77-
mutable std::mutex download_mutex_;
81+
/// Guards `model_locks_`. Held only briefly to look up or insert a per-model
82+
/// mutex — never across an actual download.
83+
mutable std::mutex model_locks_mutex_;
84+
85+
/// Per-model serialization mutexes, keyed by resolved cache path. Bounded by
86+
/// the number of distinct models this process downloads. The `shared_ptr`
87+
/// keeps a mutex alive for an in-flight download even though its map entry
88+
/// persists. Cross-process serialization is handled separately by
89+
/// CrossProcessFileLock.
90+
mutable std::map<std::string, std::shared_ptr<std::mutex>> model_locks_;
7891
};
7992

8093
} // namespace fl

sdk_v2/cpp/test/internal_api/download_test.cc

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727

2828
#include <algorithm>
2929
#include <atomic>
30+
#include <condition_variable>
3031
#include <filesystem>
3132
#include <fstream>
3233
#include <functional>
@@ -981,6 +982,99 @@ TEST(DownloadManagerTest, ConcurrentDownloadsOfSameModelSerialize) {
981982
}
982983
}
983984

985+
// With per-model locking, two *different* models must download concurrently —
986+
// they must not serialize through a shared in-process mutex. A rendezvous inside
987+
// the blob downloader proves both downloads occupy the critical section at the
988+
// same time: each arrival waits for its peer, so if the two ever serialized the
989+
// first arrival would time out waiting for a peer that can't enter yet.
990+
TEST(DownloadManagerTest, ConcurrentDownloadsOfDifferentModelsRunConcurrently) {
991+
TempDir tmpdir;
992+
DownloadManager manager(tmpdir.string(), "eastus", 64, fl::test::NullLog());
993+
994+
auto registry = std::make_unique<ModelRegistryClient>("eastus", fl::test::NullLog());
995+
registry->SetHttpGet([](const std::string&) -> std::string {
996+
return R"({"blobSasUri": "https://storage.blob.core.windows.net/c?sig=test"})";
997+
});
998+
manager.SetModelRegistryClient(std::move(registry));
999+
1000+
class RendezvousDownloader : public IBlobDownloader {
1001+
public:
1002+
std::mutex m;
1003+
std::condition_variable cv;
1004+
int arrived = 0;
1005+
bool released = false;
1006+
std::atomic<int> timeouts{0};
1007+
1008+
std::vector<BlobItemInfo> ListBlobs(const std::string&) override {
1009+
return {{"variant-cpu/weights.bin", 16}};
1010+
}
1011+
1012+
void DownloadBlob(const std::string&, const std::string& blob_name,
1013+
const std::string& local_path, int,
1014+
BlobBytesWrittenFn bytes_written_cb,
1015+
std::atomic<bool>*) override {
1016+
{
1017+
std::unique_lock<std::mutex> lk(m);
1018+
if (++arrived >= 2) { // both concurrent downloads reached the rendezvous
1019+
released = true;
1020+
cv.notify_all();
1021+
} else if (!cv.wait_for(lk, std::chrono::seconds(5), [&] { return released; })) {
1022+
++timeouts; // peer never arrived within the window → downloads serialized
1023+
}
1024+
}
1025+
1026+
auto parent = fs::path(local_path).parent_path();
1027+
if (!parent.empty()) {
1028+
fs::create_directories(parent);
1029+
}
1030+
std::ofstream f(local_path);
1031+
f << "data for " << blob_name;
1032+
if (bytes_written_cb) {
1033+
bytes_written_cb(16);
1034+
}
1035+
}
1036+
};
1037+
1038+
auto rendezvous = std::make_unique<RendezvousDownloader>();
1039+
auto* rendezvous_raw = rendezvous.get();
1040+
manager.SetBlobDownloader(std::move(rendezvous));
1041+
1042+
auto make_info = [](const char* id, const char* publisher) {
1043+
ModelInfo info;
1044+
info.model_id = id;
1045+
info.name = id;
1046+
info.uri = std::string("azureml://registries/test/models/") + id + "/versions/1";
1047+
info.string_properties[FOUNDRY_LOCAL_MODEL_PROP_PUBLISHER_STR] = publisher;
1048+
return info;
1049+
};
1050+
auto info_a = make_info("model-a:1", "PubA");
1051+
auto info_b = make_info("model-b:1", "PubB");
1052+
1053+
std::atomic<int> exceptions{0};
1054+
std::thread t1([&] {
1055+
try {
1056+
manager.DownloadModel(info_a);
1057+
} catch (...) {
1058+
++exceptions;
1059+
}
1060+
});
1061+
std::thread t2([&] {
1062+
try {
1063+
manager.DownloadModel(info_b);
1064+
} catch (...) {
1065+
++exceptions;
1066+
}
1067+
});
1068+
t1.join();
1069+
t2.join();
1070+
1071+
EXPECT_EQ(exceptions.load(), 0);
1072+
EXPECT_TRUE(rendezvous_raw->released)
1073+
<< "Both different-model downloads should have met at the rendezvous.";
1074+
EXPECT_EQ(rendezvous_raw->timeouts.load(), 0)
1075+
<< "Downloads of different models must run concurrently, not serialize.";
1076+
}
1077+
9841078
// HasInferenceModelJson must return false instead of throwing when the path
9851079
// it's asked about is not a directory (e.g. a regular file). Previously the
9861080
// underlying directory_iterator would throw filesystem_error.

0 commit comments

Comments
 (0)