Skip to content
Closed
Show file tree
Hide file tree
Changes from 6 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.

67 changes: 53 additions & 14 deletions crates/git-cache-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use git_cache_domain::{
MaterializerExecutor,
};
use git_cache_git::UploadPackProcess;
use git_cache_worker::{InMemoryRepoLeaseManager, UpdateCoordinator, UpdateDisposition};
use git_cache_worker::{ObjectStoreRepoLeaseManager, UpdateCoordinator, UpdateDisposition};
use http::{header, Method, StatusCode, Uri};
use serde::Serialize;
use std::collections::HashMap;
Expand All @@ -28,6 +28,7 @@ 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;

Expand Down Expand Up @@ -92,7 +93,10 @@ 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 leases = Arc::new(ObjectStoreRepoLeaseManager::new(
Arc::clone(&domain.store),
&domain.config.leases,
));
let coordinator = UpdateCoordinator::new(executor, leases);
Materializer::new(Arc::clone(&domain)).enqueue_pending_generation_scan();
Ok(Self {
Expand Down Expand Up @@ -174,10 +178,7 @@ async fn handle_materialize_request(
.materialize_total
.fetch_add(1, Ordering::Relaxed);

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

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.

what? why are we even assigning this to a variable then

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 17ccb0b. I removed the dead use_coordinator = true branch entirely: the handler now always calls coordinator.read_through(...), handles LeaseBusy, then runs materialize_after_upstream_validation(...). Much cleaner.


let verified_by_coordinator = if use_coordinator {
let outcome = state
Expand All @@ -186,10 +187,18 @@ async fn handle_materialize_request(
.await;
match outcome {
Ok(o) if o.disposition == UpdateDisposition::LeaseBusy => {
return Err(ApiError {
status: StatusCode::SERVICE_UNAVAILABLE,
message: "update in progress, retry later".into(),
});
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"));
}
Err(error) => {
state
Expand Down Expand Up @@ -372,6 +381,29 @@ async fn git_repo(
.git_remote_upload_pack_total
.fetch_add(1, Ordering::Relaxed);

// Acquire the repo-write lease before processing unknown-want
// fetches so that any publish_generation calls inside
// ensure_wants_available run under durable coordination.
let outcome = state
.coordinator
.read_through(repo.clone(), Selector::DefaultBranch)
.await;
match outcome {
Ok(o) if o.disposition == UpdateDisposition::LeaseBusy => {
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) => {
warn!(%repo, ?error, "coordinator pre-fetch for upload-pack failed; proceeding without lease");
}
Ok(_) => {}
}

match materializer.handle_upload_pack(&repo, &body).await {
Ok(process) => stream_upload_pack_response(&state, process),
Err(error) => ApiError::from(error).into_response(),
Expand Down Expand Up @@ -492,10 +524,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 +701,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
86 changes: 86 additions & 0 deletions crates/git-cache-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ pub struct AppConfig {
pub session_cleanup_interval_secs: u64,
#[serde(default = "default_max_concurrent_generation_verifications")]
pub max_concurrent_generation_verifications: usize,
#[serde(default)]
pub leases: LeaseConfig,
}

impl AppConfig {
Expand Down Expand Up @@ -122,6 +124,22 @@ impl AppConfig {
"GIT_CACHE_MAX_CONCURRENT_GENERATION_VERIFICATIONS",
default_max_concurrent_generation_verifications(),
)?,
leases: LeaseConfig {
worker_id: env::var("GIT_CACHE_WORKER_ID").ok(),
ttl_seconds: parse_env("GIT_CACHE_LEASE_TTL_SECONDS", default_lease_ttl_seconds())?,
renew_interval_seconds: parse_env(
"GIT_CACHE_LEASE_RENEW_INTERVAL_SECONDS",
default_lease_renew_interval_seconds(),
)?,
steal_skew_seconds: parse_env(
"GIT_CACHE_LEASE_STEAL_SKEW_SECONDS",
default_lease_steal_skew_seconds(),
)?,
busy_retry_after_seconds: parse_env(
"GIT_CACHE_LEASE_BUSY_RETRY_AFTER_SECONDS",
default_lease_busy_retry_after_seconds(),
)?,
},
})
}
}
Expand Down Expand Up @@ -205,6 +223,48 @@ impl Default for CompactionConfig {
}
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LeaseConfig {
#[serde(default)]
pub worker_id: Option<String>,
#[serde(default = "default_lease_ttl_seconds")]
pub ttl_seconds: u64,
#[serde(default = "default_lease_renew_interval_seconds")]
pub renew_interval_seconds: u64,
#[serde(default = "default_lease_steal_skew_seconds")]
pub steal_skew_seconds: u64,
#[serde(default = "default_lease_busy_retry_after_seconds")]
pub busy_retry_after_seconds: u64,

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.

[P3] This config is parsed but never used when lease acquisition returns busy: the API returns a bare 503 body without a Retry-After header. That changes the behavior promised by the plan and makes clients retry blind. Please thread busy_retry_after_seconds into the lease-busy response headers or remove the setting until it is wired.

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 abffe48. The 503 lease-busy response now includes a Retry-After header populated from config.leases.busy_retry_after_seconds. Both the /v1/materialize and /git/ upload-pack paths emit this header.

}

impl Default for LeaseConfig {
fn default() -> Self {
Self {
worker_id: None,
ttl_seconds: default_lease_ttl_seconds(),
renew_interval_seconds: default_lease_renew_interval_seconds(),
steal_skew_seconds: default_lease_steal_skew_seconds(),
busy_retry_after_seconds: default_lease_busy_retry_after_seconds(),
}
}
}

pub fn default_lease_ttl_seconds() -> u64 {
300
}

pub fn default_lease_renew_interval_seconds() -> u64 {
60
}

pub fn default_lease_steal_skew_seconds() -> u64 {
30
}

pub fn default_lease_busy_retry_after_seconds() -> u64 {
5
}

fn default_compaction_threshold() -> u32 {
10
}
Expand Down Expand Up @@ -354,6 +414,11 @@ mod tests {
"GIT_CACHE_MAX_CONCURRENT_GIT_PROCESSES",
"GIT_CACHE_SESSION_CLEANUP_INTERVAL_SECS",
"GIT_CACHE_MAX_CONCURRENT_GENERATION_VERIFICATIONS",
"GIT_CACHE_WORKER_ID",
"GIT_CACHE_LEASE_TTL_SECONDS",
"GIT_CACHE_LEASE_RENEW_INTERVAL_SECONDS",
"GIT_CACHE_LEASE_STEAL_SKEW_SECONDS",
"GIT_CACHE_LEASE_BUSY_RETRY_AFTER_SECONDS",
];

struct EnvGuard {
Expand Down Expand Up @@ -452,6 +517,7 @@ min_free_bytes = 100000
assert_eq!(config.max_git_output_bytes, 16 * 1024 * 1024);
assert_eq!(config.compaction, CompactionConfig::default());
assert_eq!(config.max_concurrent_generation_verifications, 1);
assert_eq!(config.leases, LeaseConfig::default());
}

#[test]
Expand All @@ -461,6 +527,16 @@ min_free_bytes = 100000
assert!(!config.inline);
}

#[test]
fn lease_config_default_values() {
let config = LeaseConfig::default();
assert_eq!(config.worker_id, None);
assert_eq!(config.ttl_seconds, 300);
assert_eq!(config.renew_interval_seconds, 60);
assert_eq!(config.steal_skew_seconds, 30);
assert_eq!(config.busy_retry_after_seconds, 5);
}

#[test]
fn git_remote_config_default_values() {
let config = GitRemoteConfig::default();
Expand Down Expand Up @@ -497,6 +573,11 @@ min_free_bytes = 100000
("GIT_CACHE_COMPACTION_CHAIN_DEPTH_THRESHOLD", "4"),
("GIT_CACHE_COMPACTION_INLINE", "yes"),
("GIT_CACHE_MAX_CONCURRENT_GENERATION_VERIFICATIONS", "3"),
("GIT_CACHE_WORKER_ID", "worker-a"),
("GIT_CACHE_LEASE_TTL_SECONDS", "11"),
("GIT_CACHE_LEASE_RENEW_INTERVAL_SECONDS", "3"),
("GIT_CACHE_LEASE_STEAL_SKEW_SECONDS", "2"),
("GIT_CACHE_LEASE_BUSY_RETRY_AFTER_SECONDS", "1"),
]);

let config = AppConfig::from_env().unwrap();
Expand All @@ -512,6 +593,11 @@ min_free_bytes = 100000
assert_eq!(config.compaction.chain_depth_threshold, 4);
assert!(config.compaction.inline);
assert_eq!(config.max_concurrent_generation_verifications, 3);
assert_eq!(config.leases.worker_id.as_deref(), Some("worker-a"));
assert_eq!(config.leases.ttl_seconds, 11);
assert_eq!(config.leases.renew_interval_seconds, 3);
assert_eq!(config.leases.steal_skew_seconds, 2);
assert_eq!(config.leases.busy_retry_after_seconds, 1);

match config.object_store {
ObjectStoreConfig::S3 {
Expand Down
Loading
Loading