Skip to content
Closed
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
f4d1f87
docs: plan multi-worker conflict avoidance
0lut Jun 4, 2026
39b371a
docs: make multi-worker plan self-contained
0lut Jun 4, 2026
0551ef5
Implement multi-worker conflict avoidance
devin-ai-integration[bot] Jun 4, 2026
d35c5ef
Handle exact commit pending verification race
devin-ai-integration[bot] Jun 4, 2026
b3a47e6
Stabilize git performance test fixtures
devin-ai-integration[bot] Jun 4, 2026
abffe48
Harden multi-worker conflict avoidance (review feedback)
devin-ai-integration[bot] Jun 5, 2026
17ccb0b
Hold repo-write lease during git upload-pack wants
devin-ai-integration[bot] Jun 5, 2026
9afbdd4
Wait briefly before returning lease busy
devin-ai-integration[bot] Jun 5, 2026
23ca917
Harden multi-worker coordination edge cases
devin-ai-integration[bot] Jun 5, 2026
d2c32ba
Preserve short-commit materialize source
devin-ai-integration[bot] Jun 5, 2026
8818acb
Address remaining lease and generation review feedback
devin-ai-integration[bot] Jun 5, 2026
c6269e3
Stabilize lease expiry and generation tests
devin-ai-integration[bot] Jun 5, 2026
eb65e55
Address pending generation and lock review feedback
devin-ai-integration[bot] Jun 5, 2026
78da4d8
Fence pending verification publication
devin-ai-integration[bot] Jun 5, 2026
b27c787
Report compaction lease contention
devin-ai-integration[bot] Jun 5, 2026
531291a
Retry compaction on head-CAS loss instead of returning None
devin-ai-integration[bot] Jun 5, 2026
653a414
Post-CAS manifest validation and local lock cancellation safety
devin-ai-integration[bot] Jun 5, 2026
eaec26c
Wait for generation head in compaction test
devin-ai-integration[bot] Jun 5, 2026
8fb86d9
Extend API integration CI timeout
devin-ai-integration[bot] Jun 5, 2026
5d6753a
Stabilize worker lease and inflight tests
devin-ai-integration[bot] Jun 5, 2026
0a55e17
Merge remote-tracking branch 'origin/main' into codex/multi-worker-co…
0lut Jun 8, 2026
635e6ff
Merge remote-tracking branch 'origin/main' into codex/multi-worker-co…
0lut Jun 8, 2026
834356b
Fix Docker release build cache
0lut Jun 8, 2026
2332fef
Merge remote-tracking branch 'origin/main' into codex/multi-worker-co…
0lut Jun 8, 2026
fc332a7
Restore multi-worker lease fencing
devin-ai-integration[bot] Jun 8, 2026
ff7285b
Merge main into multi-worker conflict branch
devin-ai-integration[bot] Jun 8, 2026
5d7ec8b
Harden multi-worker update coordination
0lut Jun 8, 2026
c7ed4a8
Merge main cold-miss proxy updates
devin-ai-integration[bot] Jun 9, 2026
62e5603
Merge latest direct git proxy warm fixes
devin-ai-integration[bot] Jun 9, 2026
5519a32
Merge main test module hosting updates
devin-ai-integration[bot] Jun 9, 2026
593c38b
Merge main request plumbing updates
devin-ai-integration[bot] Jun 9, 2026
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
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

171 changes: 125 additions & 46 deletions crates/git-cache-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ use git_cache_domain::{
MaterializerExecutor,
};
use git_cache_git::UploadPackProcess;
use git_cache_worker::{InMemoryRepoLeaseManager, UpdateCoordinator, UpdateDisposition};
use git_cache_worker::{
LeaseAcquire, ObjectStoreRepoLeaseManager, RepoLeaseManager, UpdateCoordinator,
UpdateDisposition,
};
use http::{header, Method, StatusCode, Uri};
use serde::Serialize;
use std::collections::HashMap;
Expand All @@ -28,8 +31,10 @@ use tokio::io::AsyncRead;
use tokio::sync::OwnedSemaphorePermit;
use tokio::time::Sleep;
use tokio_util::io::ReaderStream;
use tracing::warn;

const GIT_UPLOAD_PACK_STREAM_BUFFER_BYTES: usize = 64 * 1024;
const LEASE_BUSY_RETRY_INTERVAL: Duration = Duration::from_millis(100);

pub fn app(config: AppConfig) -> Router {
app_result(config).expect("failed to initialize git-cache-api")
Expand Down Expand Up @@ -73,6 +78,7 @@ fn router(git_remote_enabled: bool, state: Arc<ApiState>) -> CoreResult<Router>
struct ApiState {
domain: Arc<AppState>,
coordinator: UpdateCoordinator,
leases: Arc<dyn RepoLeaseManager>,
metrics: Arc<Metrics>,
rate_limiter: Arc<RateLimiter>,
}
Expand All @@ -92,18 +98,64 @@ impl ApiState {

fn with_domain(rate_limiter: RateLimiter, domain: Arc<AppState>) -> CoreResult<Self> {
let executor = Arc::new(MaterializerExecutor::new(Arc::clone(&domain)));
let leases = Arc::new(InMemoryRepoLeaseManager::new());
let coordinator = UpdateCoordinator::new(executor, leases);
let leases: Arc<dyn RepoLeaseManager> = Arc::new(ObjectStoreRepoLeaseManager::new(
Arc::clone(&domain.store),
&domain.config.leases,
));
let coordinator = UpdateCoordinator::new(executor, Arc::clone(&leases));
Materializer::new(Arc::clone(&domain)).enqueue_pending_generation_scan();
Ok(Self {
domain,
coordinator,
leases,
metrics: Arc::new(Metrics::default()),
rate_limiter: Arc::new(rate_limiter),
})
}
}

async fn read_through_with_busy_wait(
state: &Arc<ApiState>,
repo: git_cache_core::RepoKey,
selector: Selector,
) -> CoreResult<git_cache_worker::UpdateOutcome> {
let max_wait = Duration::from_secs(state.domain.config.leases.busy_retry_after_seconds);
let started_at = Instant::now();

loop {
let outcome = state
.coordinator
.read_through(repo.clone(), selector.clone())
.await?;
if outcome.disposition != UpdateDisposition::LeaseBusy || started_at.elapsed() >= max_wait {
return Ok(outcome);
}
tokio::time::sleep(
LEASE_BUSY_RETRY_INTERVAL.min(max_wait.saturating_sub(started_at.elapsed())),
)
.await;
}
}

async fn acquire_repo_write_lease_with_busy_wait(
state: &Arc<ApiState>,
repo: &git_cache_core::RepoKey,
) -> CoreResult<LeaseAcquire> {
let max_wait = Duration::from_secs(state.domain.config.leases.busy_retry_after_seconds);
let started_at = Instant::now();

loop {
let lease = state.leases.acquire(repo).await?;
if !matches!(lease, LeaseAcquire::Busy) || started_at.elapsed() >= max_wait {
return Ok(lease);
}
tokio::time::sleep(
LEASE_BUSY_RETRY_INTERVAL.min(max_wait.saturating_sub(started_at.elapsed())),
)
.await;
}
}

async fn healthz() -> Json<HealthResponse> {
Json(HealthResponse {
ok: true,
Expand Down Expand Up @@ -174,45 +226,37 @@ async fn handle_materialize_request(
.materialize_total
.fetch_add(1, Ordering::Relaxed);

let use_coordinator = matches!(
request.selector,
Selector::Branch(_) | Selector::DefaultBranch
);

let verified_by_coordinator = if use_coordinator {
let outcome = state
.coordinator
.read_through(request.repo.clone(), request.selector.clone())
.await;
match outcome {
Ok(o) if o.disposition == UpdateDisposition::LeaseBusy => {
return Err(ApiError {
status: StatusCode::SERVICE_UNAVAILABLE,
message: "update in progress, retry later".into(),
});
}
Err(error) => {
state
.metrics
.materialize_errors_total
.fetch_add(1, Ordering::Relaxed);
return Err(error.into());
}
Ok(_) => {}
let outcome =

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

[P1] This enters the coordinator before API-side host validation and before checking whether an exact commit is already complete. For Commit/ShortCommit, the worker calls materialize_commit/materialize_short_commit directly, and those methods do not validate the repo host, so a disallowed host can perform upstream git work before being rejected; complete exact-cache hits also now wait/503 behind unrelated repo-write work. Validate the repo first, keep the complete-manifest fast path outside the lease, and coordinate only missing/incomplete read-through work.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Fixed in 23ca917: handle_materialize_request now validates the repo before coordinator entry, serves complete exact-commit manifests as an outside-lease fast path, and routes only missing/incomplete read-through work through coordination before local-only response materialization.

read_through_with_busy_wait(state, request.repo.clone(), request.selector.clone()).await;
match outcome {
Ok(o) if o.disposition == UpdateDisposition::LeaseBusy => {
let retry_after = state.domain.config.leases.busy_retry_after_seconds;
return Ok(Response::builder()
.status(StatusCode::SERVICE_UNAVAILABLE)
.header(header::RETRY_AFTER, retry_after.to_string())
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({
"error": "update in progress, retry later"
}))
.expect("json serialization"),
))
.expect("lease busy response"));
}
true
} else {
false
};
Err(error) => {
state
.metrics
.materialize_errors_total
.fetch_add(1, Ordering::Relaxed);
return Err(error.into());
}
Ok(_) => {}
}

let materializer = Materializer::new(Arc::clone(&state.domain));
let result = if verified_by_coordinator {
materializer
.materialize_after_upstream_validation(request)
.await
} else {
materializer.materialize(request).await
};
let result = materializer
.materialize_after_upstream_validation(request)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

[P1] This second materialization re-executes ShortCommit after the coordinator pass. materialize_after_upstream_validation() falls through to materialize() for short commits, which reruns fetch_all_refs and abbreviation resolution outside the repo-write lease. If upstream moves or the abbreviation resolves differently between the coordinated pass and this pass, the returned session can be for a commit that was not validated under coordination. Carry the resolved commit from the coordinated pass, or add a local/session creation path that does not refetch.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Fixed in 23ca917 and corrected in d2c32ba: coordinator commit/short targets now use session-free ensure paths, and the post-coordinator short-commit response resolves against local refs only without refetching upstream. Short-commit responses keep github_verified source semantics.

.await;

match result {
Ok(response) => Ok(Json(response).into_response()),
Expand Down Expand Up @@ -372,9 +416,37 @@ async fn git_repo(
.git_remote_upload_pack_total
.fetch_add(1, Ordering::Relaxed);

match materializer.handle_upload_pack(&repo, &body).await {
Ok(process) => stream_upload_pack_response(&state, process),
Err(error) => ApiError::from(error).into_response(),
// Wait briefly for repo-write before falling back to 503, then hold the
// lease while processing wants. This keeps ensure_wants_available() and
// any publish_generation() it triggers under durable coordination, and
// passes the fencing token into Materializer so publish writes can verify
// ownership immediately before mutation.
let lease = match acquire_repo_write_lease_with_busy_wait(&state, &repo).await {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

[P1] The direct upload-pack path can now fail ordinary Git protocol requests with repo-write contention. cargo test --workspace --all-targets fails in filtered_clone_checkout_fetches_blob_wants because a partial-clone checkout lazy blob fetch receives HTTP 409. This path needs to wait/retry internally, or otherwise hide repo-write contention from Git clients during lazy want hydration; returning a protocol-visible 409/503 breaks filtered clones.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Fixed in 23ca917: upload-pack requests with wants now wait internally for the repo-write lease up to the git operation timeout instead of surfacing the short lease-busy window as a Git-protocol 409/503. Empty-want upload-pack requests skip the lease.

Ok(LeaseAcquire::Acquired(lease)) => lease,
Ok(LeaseAcquire::Busy) => {
let retry_after = state.domain.config.leases.busy_retry_after_seconds;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Why dont we retry ourselves first by holding the request for X seconds (X perhaps few seconds?)
the goal of this is to increase correctness, not reduce the overall successful requests made, this way, we are punishing having multiple workers

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Fixed in 9afbdd4. The API now waits/retries before returning LeaseBusy instead of immediately failing the request:

  • Added read_through_with_busy_wait(...) for /v1/materialize coordinator calls.
  • Added acquire_repo_write_lease_with_busy_wait(...) for /git/ upload-pack so it waits before falling back to 503.
  • The retry window is config.leases.busy_retry_after_seconds (default 5s), with 100ms polling. If still busy after that, it returns 503 + Retry-After as before.

Verification: cargo clippy --workspace -- -D warnings and cargo test --workspace -p git-cache-api --test git_remote_integration -- --test-threads=1 passed.

return Response::builder()
.status(StatusCode::SERVICE_UNAVAILABLE)
.header(header::RETRY_AFTER, retry_after.to_string())
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"error":"update in progress, retry later"}"#))
.expect("lease busy response");
}
Err(error) => return ApiError::from(error).into_response(),
};
let token = lease.token().to_string();
let leased_materializer = Materializer::with_lease_token(Arc::clone(&state.domain), token);
let process_result = leased_materializer.handle_upload_pack(&repo, &body).await;
let release_result = lease.release().await;

match (process_result, release_result) {
(Ok(process), Ok(())) => stream_upload_pack_response(&state, process),
(Err(error), Ok(())) => ApiError::from(error).into_response(),
(Ok(_), Err(error)) => ApiError::from(error).into_response(),
(Err(error), Err(release_error)) => {
warn!(%repo, %release_error, "failed to release repo lease after upload-pack error");
ApiError::from(error).into_response()
}
}
} else {
ApiError::from(GitCacheError::Unsupported(format!(
Expand Down Expand Up @@ -492,10 +564,16 @@ impl From<GitCacheError> for ApiError {
GitCacheError::NotImplemented(_) => StatusCode::NOT_IMPLEMENTED,
GitCacheError::Validation(_) => StatusCode::BAD_REQUEST,
GitCacheError::Timeout(_) => StatusCode::GATEWAY_TIMEOUT,
GitCacheError::Conflict(_) => StatusCode::CONFLICT,
GitCacheError::Internal(_) | GitCacheError::Io(_) | GitCacheError::Json(_) => {
StatusCode::INTERNAL_SERVER_ERROR
}
GitCacheError::LeaseBusy(_) => StatusCode::SERVICE_UNAVAILABLE,
GitCacheError::Conflict(_)
| GitCacheError::LeaseLost(_)
| GitCacheError::LeaseStealConflict(_)
| GitCacheError::CasConflict(_) => StatusCode::CONFLICT,
GitCacheError::PendingGenerationInvalid(_)
| GitCacheError::ColdHydrationFailed(_)
| GitCacheError::Internal(_)
| GitCacheError::Io(_)
| GitCacheError::Json(_) => StatusCode::INTERNAL_SERVER_ERROR,
};

Self {
Expand Down Expand Up @@ -663,6 +741,7 @@ mod tests {
max_concurrent_git_processes: git_cache_core::default_max_concurrent_git_processes(),
session_cleanup_interval_secs: 300,
max_concurrent_generation_verifications: 1,
leases: Default::default(),
};
let api_state = ApiState::try_new(config).unwrap();
let mut query = HashMap::new();
Expand Down
1 change: 1 addition & 0 deletions crates/git-cache-api/tests/contention.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ impl TestServer {
max_concurrent_git_processes: git_cache_core::default_max_concurrent_git_processes(),
session_cleanup_interval_secs: 300,
max_concurrent_generation_verifications: 1,
leases: Default::default(),
});

let router = app(config);
Expand Down
2 changes: 2 additions & 0 deletions crates/git-cache-api/tests/contention_advanced.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ impl TestServer {
max_concurrent_git_processes: git_cache_core::default_max_concurrent_git_processes(),
session_cleanup_interval_secs: 300,
max_concurrent_generation_verifications: 1,
leases: Default::default(),
allowed_upstream_hosts: vec!["github.com".into()],
disk: git_cache_core::DiskConfig {
quota_bytes: 1024 * 1024 * 1024,
Expand Down Expand Up @@ -210,6 +211,7 @@ impl MultiRepoTestServer {
max_concurrent_git_processes: git_cache_core::default_max_concurrent_git_processes(),
session_cleanup_interval_secs: 300,
max_concurrent_generation_verifications: 1,
leases: Default::default(),
allowed_upstream_hosts: vec!["github.com".into()],
disk: git_cache_core::DiskConfig {
quota_bytes: 1024 * 1024 * 1024,
Expand Down
1 change: 1 addition & 0 deletions crates/git-cache-api/tests/correctness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ impl TestServer {
max_concurrent_git_processes: git_cache_core::default_max_concurrent_git_processes(),
session_cleanup_interval_secs: 300,
max_concurrent_generation_verifications: 1,
leases: Default::default(),
allowed_upstream_hosts: vec!["github.com".into()],
disk: git_cache_core::DiskConfig {
quota_bytes: 1024 * 1024 * 1024,
Expand Down
1 change: 1 addition & 0 deletions crates/git-cache-api/tests/git_client_advanced.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ impl TestServer {
max_concurrent_git_processes: git_cache_core::default_max_concurrent_git_processes(),
session_cleanup_interval_secs: 300,
max_concurrent_generation_verifications: 1,
leases: Default::default(),
allowed_upstream_hosts: vec!["github.com".into()],
disk: git_cache_core::DiskConfig {
quota_bytes: 1024 * 1024 * 1024,
Expand Down
1 change: 1 addition & 0 deletions crates/git-cache-api/tests/git_client_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ impl TestServer {
max_concurrent_git_processes: git_cache_core::default_max_concurrent_git_processes(),
session_cleanup_interval_secs: 300,
max_concurrent_generation_verifications: 1,
leases: Default::default(),
};

let router = app(config);
Expand Down
1 change: 1 addition & 0 deletions crates/git-cache-api/tests/git_protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ impl TestServer {
max_concurrent_git_processes: git_cache_core::default_max_concurrent_git_processes(),
session_cleanup_interval_secs: 300,
max_concurrent_generation_verifications: 1,
leases: Default::default(),
};

let router = app(config);
Expand Down
1 change: 1 addition & 0 deletions crates/git-cache-api/tests/git_remote_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ impl TestServer {
max_concurrent_git_processes: git_cache_core::default_max_concurrent_git_processes(),
session_cleanup_interval_secs: 300,
max_concurrent_generation_verifications: 1,
leases: Default::default(),
};

let router = app(config);
Expand Down
1 change: 1 addition & 0 deletions crates/git-cache-api/tests/git_session_protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ impl TestServer {
max_concurrent_git_processes: git_cache_core::default_max_concurrent_git_processes(),
session_cleanup_interval_secs: 300,
max_concurrent_generation_verifications: 1,
leases: Default::default(),
};

let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
Expand Down
1 change: 1 addition & 0 deletions crates/git-cache-api/tests/load.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ impl TestServer {
max_concurrent_git_processes: git_cache_core::default_max_concurrent_git_processes(),
session_cleanup_interval_secs: 300,
max_concurrent_generation_verifications: 1,
leases: Default::default(),
allowed_upstream_hosts: vec!["github.com".into()],
disk: git_cache_core::DiskConfig {
quota_bytes: 2 * 1024 * 1024 * 1024,
Expand Down
1 change: 1 addition & 0 deletions crates/git-cache-api/tests/performance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ impl TestServer {
max_concurrent_git_processes: git_cache_core::default_max_concurrent_git_processes(),
session_cleanup_interval_secs: 300,
max_concurrent_generation_verifications: 1,
leases: Default::default(),
};

let router = app(config);
Expand Down
1 change: 1 addition & 0 deletions crates/git-cache-api/tests/performance_advanced.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ impl TestServer {
max_concurrent_git_processes: git_cache_core::default_max_concurrent_git_processes(),
session_cleanup_interval_secs: 300,
max_concurrent_generation_verifications: 1,
leases: Default::default(),
allowed_upstream_hosts: vec!["github.com".into()],
disk: git_cache_core::DiskConfig {
quota_bytes: 1024 * 1024 * 1024,
Expand Down
1 change: 1 addition & 0 deletions crates/git-cache-api/tests/runtime_cache_recovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ impl TestServer {
max_concurrent_git_processes: git_cache_core::default_max_concurrent_git_processes(),
session_cleanup_interval_secs: 300,
max_concurrent_generation_verifications: 1,
leases: Default::default(),
};

let router = app(config);
Expand Down
Loading
Loading