In short: four OpStore methods map one-to-one onto ReadView,
WriteView, ReadOperation and WriteOperation, using the same
thread-per-call bridge as the client Backend.
Op-ID prefix lookup only knows the root operation, and gc does nothing. The op heads store
calls GetOpHeads and UpdateOpHeads, and its lock is a no-op. The views it
stores leave out tags, Git refs and Git HEAD. Operation times lose their time zone. And when two
clients publish on top of the same operation, the server keeps the first and silently drops the
second.
The modules are lib/src/cc_op_store.rs and
lib/src/cc_op_heads_store.rs. Both are registered under the
name commit_cloud by add_commit_cloud, which
turns load errors into BackendLoadError and ignores the root-operation data jj passes in.
The messages are on protobufs, and the
operation and view hashes on object-ids.
jj's own model is on /jj/operations and /jj/view.
Those pages describe the jj version /jj is pinned to. This project uses jj-lib
0.43.0.
Loading
Neither store has an init. Both have only load(store_path)
(op store, op heads store),
which reads server_url and repo_id through
CommitCloudConfig::load_from_store. Their store paths
are .jj/repo/op_store and .jj/repo/op_heads, which have no
config.toml, so the loader falls back to ../store/config.toml, the file the
backend's init wrote. All three stores therefore talk to the same server and repository.
No network call is made until the first read or write.
| Field | Type | Value |
|---|---|---|
server_url | String | From the shared config.toml. |
repo_id | String | Sent with every request. |
root_operation_id (op store only) | OperationId | 16 zero bytes (ROOT_OPERATION_ID_BYTES). |
CommitCloudOpStore · CommitCloudOpHeadsStore · constants
OpStore methods
Every method of jj-lib's OpStore
trait is implemented (impl). The four RPC methods each clone
the server URL and repo ID, convert their argument, and run
OpStoreServiceClient::connect plus the call inside
run_async: a new thread, a new tokio runtime and a new
connection per call, as described on client-backend.
| Method | Behaviour | RPC | Source |
|---|---|---|---|
name | "commit_cloud" | — | L250 |
root_operation_id | The 16 zero bytes. No call. | — | L254 |
read_view | Sends the view ID and converts the returned view. A response with no view is an error. | ReadView | L258 |
write_view | Converts the view and returns the server's view ID. | WriteView | L280 |
read_operation | Sends the operation ID and converts the returned operation. A response with no operation is an error. | ReadOperation | L299 |
write_operation | Converts the operation and returns the server's operation ID. | WriteOperation | L321 |
resolve_operation_id_prefix | No RPC exists for it. A prefix of the root ID is a SingleMatch on the root operation. Anything else is NoMatch. A TODO asks for a server RPC. | — | L340 |
gc | Does nothing. | — | L355 |
The prefix gap reaches further than jj op show. jj-lib resolves any operation written as
hex, even a full ID, through resolve_operation_id_prefix
(resolve_single_op_from_store).
So with these stores, an explicit operation ID other than the root's gives "no such operation", in any
command that accepts one. Symbolic forms such as @ start from the op heads and don't
use it.
The root operation and root view
The client never builds the root operation itself. It reads it from the server like any other. When
the store has nothing under the root ID, ReadOperation
answers with a fixed operation: view ID of 16 zero bytes, no parents, description "root()",
zero times, empty host and user names, and commit_predecessors_set: true with no entries.
ReadView answers the zero view ID with a view whose only
head is the root commit. Any other unknown ID is a gRPC NotFound, as is any request for an
unregistered repo ID (ensure_repo_registered_error).
Conversions
Operations
| jj-lib field | On the wire | Read back |
|---|---|---|
view_id, parents | Raw bytes. | Unchanged. |
metadata.time.start, .end | Milliseconds only. | tz_offset is always 0, so the local time zone is lost. |
description, hostname, username, is_snapshot | Copied. | Unchanged. |
workspace_name | Optional string. | Unchanged, including None. |
attributes | A map. | Collected back into a BTreeMap. |
commit_predecessors | A list of CommitPredecessors entries plus commit_predecessors_set, so None and an empty map stay distinct. | Unchanged. |
operation_to_proto · operation_from_proto · jj-lib Timestamp
A missing metadata message reads back as all defaults. The operation ID is computed by the
server from the converted message (WriteOperation), so
the dropped time zone isn't part of it.
Views
jj-lib View field | On the wire | Read back |
|---|---|---|
head_ids | List of bytes. | A HashSet. |
wc_commit_ids | Map from workspace name. | Unchanged. |
local_bookmarks | Map of RefTargets (removes and adds). | Rebuilt with RefTarget::from_legacy_form. |
remote_views[r].bookmarks[b] | One flat map, keyed "b@r", with the target and is_tracked. | Split at the last @. Tracked or New. A missing target becomes absent. |
remote_views[r].tags | Not sent. | Empty. |
local_tags, git_refs | Not sent. | Empty. |
git_head | Not sent. | Absent. |
ref target helpers · view_to_proto · view_from_proto · jj-lib View, RemoteView
Three consequences follow from the code:
- Tags, Git-tracking refs and Git HEAD written by any command are gone the next time the view is read.
- A remote whose name contains
@is split wrongly on read. Bookmarkmainon remotea@bis sent asmain@a@band comes back as bookmarkmain@aon remoteb. An@in a bookmark name is fine, since the split is at the last@. - A remote with no bookmarks has no entries in the flat map, so it doesn't come back at all.
OpHeadsStore methods
| Method | Behaviour | RPC | Source |
|---|---|---|---|
name | "commit_cloud" | — | L37 |
get_op_heads | Returns the server's list. Errors become OpHeadsStoreError::Read. | GetOpHeads | L71 |
update_op_heads(old_ids, new_id) | Sends both and ignores the current_op_head_ids the server returns. Errors become OpHeadsStoreError::Write. A TODO asks for locking. | UpdateOpHeads | L41 |
lock | Returns CommitCloudOpHeadsStoreNoLock, an empty type. No call. | — | L89 |
jj-lib's OpHeadsStore docs say the
lock "is not needed for correctness" and only stops two processes from merging the same divergent
heads. What correctness does rest on is update_op_heads: jj calls it when a transaction is
published, with the new operation's parents as old_ids
(Transaction::publish; see /jj/transactions).
What the server does with an update
GetOpHeads returns the stored heads, or just the root
operation if the repository has never had an update. UpdateOpHeads
passes the request to the store. The in-memory engine
(MemoryStore::update_op_heads) does the whole update
under one mutex, so each update is atomic on its own:
- Start from the stored heads, or
[root]if there are none. - If
old_idsis empty, addnew_idunless it's already there. - Otherwise remove every head in
old_ids. Addnew_idonly if at least one head was removed. - Return the resulting heads.
A code comment says sibling heads are kept so that clients can later reconcile divergent heads.
Step 3 works against that. jj-lib's local
SimpleOpHeadsStore always adds
the new head first, then removes the old ones. So when two processes publish on the same parent, both
operations remain as heads and the next command merges them
(resolve_op_heads).
Concurrent operations
With the commit-cloud stores, the second of two concurrent publishes is dropped, as the diagram
shows. Both clients start from head H. Client A publishes A and the heads
become [A]. Client B then publishes B with old_ids = [H]. No current
head matches, so nothing is removed and B isn't added. The server answers with
[A], B's client ignores the answer, and B's command succeeds. B's operation and view stay
stored on the server, but nothing points to them, so the next command anywhere starts from
A. No divergence is reported. This follows from the code and was checked against a
reimplementation of the update rule. It hasn't been reproduced against a live server: it needs two
commands to interleave between reading and publishing op heads.
Concurrent operations from one client behave the same way: two jj processes on one
machine share these stores, and lock doesn't stop them either.
Errors
Every OpStore failure, including a gRPC NotFound, becomes
OpStoreError::Other. jj-lib's ObjectNotFound, ReadObject and
WriteObject variants are never produced
(OpStoreError). The op heads
store uses OpHeadsStoreError::Read for get_op_heads, and Write with the
new operation's ID for update_op_heads. It never returns Lock
(OpHeadsStoreError).
What these stores don't do
- A concurrent publish on the same parent operation is lost without any error, and divergent operations never appear.
locktakes no lock, and the update response is ignored.- Operation IDs given as hex, other than the root's, can't be resolved.
gcdoes nothing, so unreachable operations and views stay on the server.- Views drop local tags, remote tags, Git refs and Git HEAD, and remote names containing
@are split wrongly. - Operation times come back in UTC, with the time zone offset lost.
- Each call opens a new thread, runtime and connection, with no timeouts or authentication.