Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions server/src/error_util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ impl From<StoreError> for tonic::Status {
let msg = err.to_string();
match err {
StoreError::Decode(_) => tonic::Status::data_loss(msg),
StoreError::CasConflict(_) => tonic::Status::aborted(msg),
StoreError::Read(_)
| StoreError::Write(_)
| StoreError::Encode(_)
Expand Down
6 changes: 5 additions & 1 deletion server/src/op_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,11 @@ impl OpStoreService for CommitCloudOpStoreService {

let current_heads = self
.store
.update_op_heads(req.repo_id, &req.old_op_head_ids, req.new_op_head_id)
.update_op_heads_append_new_remove_old(
req.repo_id,
&req.old_op_head_ids,
req.new_op_head_id,
)
.await?;

Ok(tonic::Response::new(UpdateOpHeadsResponse {
Expand Down
65 changes: 45 additions & 20 deletions server/src/store/memorystore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use cc_common::op_store::*;
use std::collections::{HashMap, HashSet};
use std::sync::Mutex;

use super::{Store, StoreResult};
use super::{Store, StoreError, StoreResult};

pub type RepoId = String;
pub type CommitId = Vec<u8>;
Expand Down Expand Up @@ -121,39 +121,64 @@ impl Store for MemoryStore {
Ok(op_heads.get(repo_id).cloned())
}

// Updates op heads by removing old superseded heads and appending new_id, while preserving concurrent sibling heads.
// This is required for divergent op head resolution down the road—if we wiped out sibling heads here, client-side reconciliation wouldn't receive the divergent heads it needs to merge them.
async fn update_op_heads(
async fn update_op_heads_append_new_remove_old(
&self,
repo_id: String,
old_ids: &[Vec<u8>],
new_id: Vec<u8>,
ids_to_remove: &[Vec<u8>],
id_to_add: Vec<u8>,
) -> StoreResult<Vec<OpId>> {
let mut op_heads = self.op_heads.lock().unwrap();
let current_heads = op_heads
.entry(repo_id)
.entry(repo_id.clone())
.or_insert_with(|| vec![cc_common::ROOT_OPERATION_ID_BYTES.to_vec()]);

if old_ids.is_empty() {
if !current_heads.contains(&new_id) {
current_heads.push(new_id);
if ids_to_remove.is_empty() {
if !current_heads.contains(&id_to_add) {
current_heads.push(id_to_add);
}
} else {
let mut removed_any = false;
current_heads.retain(|head| {
if old_ids.contains(head) {
removed_any = true;
false
} else {
true
for old in ids_to_remove {
if !current_heads.contains(old) {
return Err(StoreError::CasConflict(format!(
"write race detected on stale old_op_head_ids: head {} no longer active in repo {}",

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.

I had a bit of a confusion due to the naming... 'append_row' makes me think that this is purely an additive operation, and 'old_ids' seemed like it was meant for write race detection. Maybe ids_to_remove, and id_to_add would have made this a bit clearer?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

fixed. changed the name to update_op_heads_append_new_remove_old
changed variable names to ids_to_remove and id_to_add (singular)

hex::encode(old),
repo_id
)));
}
});
}

if removed_any && !current_heads.contains(&new_id) {
current_heads.push(new_id);
current_heads.retain(|head| !ids_to_remove.contains(head));
if !current_heads.contains(&id_to_add) {
current_heads.push(id_to_add);
}
}

Ok(current_heads.clone())
}

async fn update_op_heads_compare_and_swap(
&self,
repo_id: String,
expected_exact_heads: &[Vec<u8>],
new_id: Vec<u8>,
) -> StoreResult<Vec<OpId>> {
let mut op_heads = self.op_heads.lock().unwrap();
let current_heads = op_heads
.entry(repo_id.clone())
.or_insert_with(|| vec![cc_common::ROOT_OPERATION_ID_BYTES.to_vec()]);

let current_set: std::collections::HashSet<Vec<u8>> =
current_heads.iter().cloned().collect();
let expected_set: std::collections::HashSet<Vec<u8>> =
expected_exact_heads.iter().cloned().collect();

if current_set != expected_set {
return Err(StoreError::CasConflict(format!(
"reconciliation CAS conflict for repo {repo_id}: expected heads {expected_set:?}, found {current_set:?}"
)));
}

*current_heads = vec![new_id.clone()];
Ok(vec![new_id])
}
}
19 changes: 17 additions & 2 deletions server/src/store/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ pub enum StoreError {
Read(String),
// A write to the storage backend failed.
Write(String),
// A Compare-And-Swap (CAS) optimistic concurrency conflict occurred.
CasConflict(String),
// Data failed to be encoded into protobuf format.
Encode(String),
// Data was retrieved but could not be decoded, indicating corruption.
Expand All @@ -31,6 +33,7 @@ impl std::fmt::Display for StoreError {
match self {
StoreError::Read(msg) => write!(f, "storage read error: {msg}"),
StoreError::Write(msg) => write!(f, "storage write error: {msg}"),
StoreError::CasConflict(msg) => write!(f, "CAS conflict: {msg}"),
StoreError::Encode(msg) => write!(f, "storage encode error: {msg}"),
StoreError::Decode(msg) => write!(f, "stored data is corrupt: {msg}"),
StoreError::Task(msg) => write!(f, "storage task error: {msg}"),
Expand Down Expand Up @@ -74,10 +77,22 @@ pub trait Store: Send + Sync {
async fn put_view(&self, repo_id: String, view_id: Vec<u8>, view: View) -> StoreResult<()>;

async fn get_op_heads(&self, repo_id: &str) -> StoreResult<Option<Vec<Vec<u8>>>>;
async fn update_op_heads(
// Updates op heads by removing `ids_to_remove` (if present) and appending `id_to_add`.
// Validates that `ids_to_remove` are currently active to prevent stale client overwrites.
async fn update_op_heads_append_new_remove_old(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Instead of two separate update_op_heads_append_new_remove_old and update_op_heads_compare_and_swap methods, I think you can keep a single update_op_heads:

async fn update_op_heads(
        &self,
        repo_id: String,
        ids_to_remove: &[Vec<u8>],
        ids_to_add: &[Vec<u8>],
        expected_op_heads: Option<&[Vec<u8>]>,
) -> StoreResult<Vec<Vec<u8>>>;

This would first read the current_opheads. If expected_op_heads is set and is different from current_opheads it returns an error. Otherwise it applies new_opheads = current_opheads - ids_to_remove + ids_to_add. It should fail if new_opheads is empty. Otherwise it updates opheads = new_opheads. The whole thing needs to be atomic.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Doing it this way makes it possible to update op heads to some arbitrary set of new opheads, and it can easily be used by the reconciliation logic.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I would prefer to have 2 different function, both atomic transactions. I think the functionality will be the exact same, and I would prefer to have the reconcile vs normal update call the separate database function, rather than call the same one with different arguments, and have it resolve inside with an if statement.

I agree that changing to ids_to_add: &[Vec<u8>], from id_to_add: Vec<u8> is better, I will make the change.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

hmm.. I thought jj's upstream OpHeadsStore trait allowed passing any number of operations to add, but that is not the case; it allows exactly 1 operation to add: https://github.com/jj-vcs/jj/blob/e9f48b3e4c1c5e816fbbfbfd06c154a6a96b0215/lib/src/op_heads_store.rs#L60

It is ok to keep id_to_add: Vec<u8>. Sorry for the backtracking.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Ok sounds, good. I will leave it as is. no changes made.

&self,
repo_id: String,
old_ids: &[Vec<u8>],
ids_to_remove: &[Vec<u8>],
id_to_add: Vec<u8>,
) -> StoreResult<Vec<Vec<u8>>>;

// Atomically compares the full set of currently active op heads against `expected_exact_heads`
// and replaces them entirely with `[new_id]`. Fails immediately with `StoreError::CasConflict`
// if the active set does not match `expected_exact_heads`. Used during server-side reconciliation.
async fn update_op_heads_compare_and_swap(
&self,
repo_id: String,
expected_exact_heads: &[Vec<u8>],
new_id: Vec<u8>,
) -> StoreResult<Vec<Vec<u8>>>;
}
Loading