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
10 changes: 10 additions & 0 deletions changelog.d/branch-list-details.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
- `GET /graphs/{graph_id}/branches?detailed=true`, the MCP `branches_list`
tool with `detailed` and `omnigraph branch list --detailed` report each
branch's head commit, the branch and commit it started from (the `from` and
`from_commit_id` its create receipt named), and when it was created and last
moved, beside the names. A client no longer reads each branch's whole commit
list for these: the listing reads each branch's manifest once, or twice once
the branch has written a commit, whatever its history. A branch deleted and
created again reports its new creation. See [Branches][branch-list-details].

[branch-list-details]: ../docs/user/branching/index.md#branch-workflow
73 changes: 73 additions & 0 deletions crates/omnigraph-api-types/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -420,9 +420,58 @@ pub struct BranchCreateOutput {
pub from_commit_id: String,
}

/// Query parameters for listing branches.
#[derive(Debug, Clone, Default, Deserialize, IntoParams)]
#[into_params(parameter_in = Query)]
pub struct BranchListQuery {
/// `true` adds `details`: each branch's head, the branch and commit it
/// started from, when it was created, and when it last moved. Defaults
/// to `false`. Reads each listed branch's latest version, and the version
/// it was created from once it has written a commit, never its commit
/// history; the names alone read neither.
#[serde(default)]
pub detailed: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct BranchListOutput {
/// Every live branch, sorted by name in byte order.
pub branches: Vec<String>,
/// Present only with `detailed=true`: one entry per branch the caller
/// may `read`, in the order of `branches`. A branch the caller may not
/// read is listed in `branches` alone.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub details: Option<Vec<BranchDetailOutput>>,
}

/// One live branch as `GET /branches?detailed=true` reports it. `from` and
/// `from_commit_id` say where the branch started, as its `POST /branches`
/// receipt did. A branch deleted and created again under the same name is a
/// new lifetime: its start and creation time are the later creation's. The
/// optional fields are absent on `main`.
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct BranchDetailOutput {
pub name: String,
/// The commit a read of the branch sees: its newest commit, or the commit
/// it started at while it has written none.
pub head: CommitOutput,
/// The branch this one started from, as that branch was named then; it
/// may since have been deleted or recreated.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub from: Option<String>,
/// The commit the branch started at. A later merge does not move it;
/// `GET /branches/merge-base` answers where two branches last met.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub from_commit_id: Option<String>,
/// When the branch was created, as Unix epoch microseconds recorded to
/// the second.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schema(example = 1714000000000000i64)]
pub created_at: Option<i64>,
/// When the head last moved, as Unix epoch microseconds: the time of the
/// branch's newest commit, or `created_at` while it has written none.
#[schema(example = 1714000000000000i64)]
pub updated_at: i64,
}

#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
Expand Down Expand Up @@ -3120,6 +3169,30 @@ pub fn diff_output(page: &omnigraph::changes::GraphDiffPage) -> DiffOutput {
}
}

/// A detailed branch listing (`detailed=true`): names and details, each in
/// byte order.
pub fn branch_list_output(listing: &omnigraph::db::BranchListing) -> BranchListOutput {
let mut branches = listing.branches.clone();
branches.sort();
let mut details: Vec<_> = listing.details.iter().map(branch_detail_output).collect();
details.sort_by(|left, right| left.name.cmp(&right.name));
BranchListOutput {
branches,
details: Some(details),
}
}

pub fn branch_detail_output(detail: &omnigraph::db::BranchDetail) -> BranchDetailOutput {
BranchDetailOutput {
name: detail.name.clone(),
head: commit_output(&detail.head),
from: detail.from.clone(),
from_commit_id: detail.from_commit_id.clone(),
created_at: detail.created_at,
updated_at: detail.updated_at,
}
}

pub fn merge_base_output(
source: &str,
target: &str,
Expand Down
197 changes: 197 additions & 0 deletions crates/omnigraph-catalog/src/branch_detail.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
//! What a detailed branch listing reports about each live graph branch: its
//! head, the branch and commit its current lifetime started from, and when
//! that lifetime was created. Every fact is read from the branch refs and each
//! branch's own `__manifest`; nothing is stored for the listing.

use std::sync::Arc;

use futures::StreamExt;
use lance::Dataset;
use lance::dataset::refs::BranchContents;

use crate::branch_control::list_live_manifest_branch_contents;
use crate::error::{OmniError, Result};
use crate::history::ExtentCache;
use crate::layout::open_manifest_dataset_with_session;
use crate::state::{GraphLineageRow, exact_head, read_manifest_rows_projected};

/// One live named graph branch: its logical name and the native ref, with
/// that ref's contents, of its current lifetime.
pub(crate) struct LiveBranch {
pub(crate) name: String,
pub(crate) native: String,
pub(crate) contents: BranchContents,
}

/// The live named graph branches of a `__manifest`, sorted by name: each live
/// native ref mapped to its one logical name, `main` excluded. A name with
/// more than one live incarnation fails loudly. The one enumeration both
/// branch listings use, so names and details agree on what is live.
pub(crate) async fn live_named_branches(dataset: &Dataset) -> Result<Vec<LiveBranch>> {
let mut named: Vec<_> = list_live_manifest_branch_contents(dataset)
.await?
.into_iter()
.filter(|(native, _)| native != "main")
.map(|(native, contents)| LiveBranch {
name: crate::branch_names::logical_branch_name(&native).to_string(),
native,
contents,
})
.collect();
named.sort_by(|left, right| left.name.cmp(&right.name));
if let Some(pair) = named.windows(2).find(|pair| pair[0].name == pair[1].name) {
return Err(OmniError::manifest_conflict(format!(
"branch '{}' has more than one live native incarnation; run cleanup before using it",
pair[0].name
)));
}
Ok(named)
}

/// How many branches a listing reads at once.
const BRANCH_DETAIL_READ_CONCURRENCY: usize = 8;

/// One listing of the live graph branches.
#[derive(Debug, Clone)]
pub struct GraphBranchListing {
/// Every live branch, `main` first and the rest by name.
pub branches: Vec<String>,
/// One entry per branch the caller admitted, in the order of `branches`.
pub details: Vec<GraphBranchDetail>,
}

/// One live graph branch as a detailed listing reports it.
#[derive(Debug, Clone)]
pub struct GraphBranchDetail {
pub name: String,
/// The head record of the branch's latest `__manifest` version: its newest
/// commit, or the commit it was created at while it has written none.
pub head: GraphLineageRow,
/// Whether the branch's current lifetime wrote `head`.
pub head_is_own: bool,
/// The logical name of the branch the current lifetime was created from,
/// as that branch was named then; it may since have been deleted or
/// recreated. `None` on main.
pub from: Option<String>,
/// The head commit of the `__manifest` version the lifetime was created
/// from: the commit it started at. `None` on main.
pub from_commit_id: Option<String>,
/// `BranchContents::create_at` of the lifetime's native ref, in Unix
/// seconds; `None` on main.
pub created_at_seconds: Option<u64>,
}

/// Every live graph branch, with details of those `can_read` admits. One open
/// of main's `__manifest` and one listing of its refs; then, for each admitted
/// branch, a checkout of its latest version and one scan of its rows, and when
/// the branch has written a commit, the checkout and scan of the version it was
/// created from. A branch whose ref is gone before its own reads keeps its name
/// and has no details: it was deleted after the listing.
pub async fn list_graph_branches_detailed(
root_uri: &str,
control_session: &Arc<lance::session::Session>,
can_read: &mut (dyn FnMut(&str) -> bool + Send + '_),
) -> Result<GraphBranchListing> {
let root = root_uri.trim_end_matches('/');
let main = open_manifest_dataset_with_session(root, None, control_session).await?;
let named = live_named_branches(&main).await?;
let branches = std::iter::once("main".to_string())
.chain(named.iter().map(|branch| branch.name.clone()))
.collect();
let mut details = Vec::with_capacity(named.len() + 1);
if can_read("main") {
let head = read_manifest_rows_projected(&main).await?.head;
details.push(GraphBranchDetail {
name: "main".to_string(),
head_is_own: exact_head(&head, None).is_some(),
head,
from: None,
from_commit_id: None,
created_at_seconds: None,
});
}
let admitted: Vec<_> = named
.into_iter()
.filter(|branch| can_read(&branch.name))
.collect();
let legacy = ExtentCache::default();
let mut reads = futures::stream::iter(admitted)
.map(|branch| {
let (main, legacy) = (&main, &legacy);
async move { branch_detail(root, main, legacy, branch).await }
})
.buffered(BRANCH_DETAIL_READ_CONCURRENCY);
while let Some(detail) = reads.next().await {
details.extend(detail?);
}
Ok(GraphBranchListing { branches, details })
}

async fn branch_detail(
root: &str,
main: &Dataset,
legacy: &ExtentCache,
branch: LiveBranch,
) -> Result<Option<GraphBranchDetail>> {
let LiveBranch {
name: logical,
native,
contents,
} = branch;
match read_branch(root, main, legacy, &native, &contents).await {
Ok((head, head_is_own, from_commit_id)) => {
let from = contents
.parent_branch
.as_deref()
.map(crate::branch_names::logical_branch_name)
.unwrap_or("main")
.to_string();
Ok(Some(GraphBranchDetail {
name: logical,
from: Some(from),
from_commit_id: Some(from_commit_id),
head,
head_is_own,
created_at_seconds: Some(contents.create_at),
}))
}
// The listing saw this lifetime live, and a native name is never
// reused. A read that fails once its ref is gone reports a branch
// deleted since; any other failure is the read's own.
Err(error) => match crate::branch_control::get_branch_identifier(main, &native).await {
Err(lance::Error::RefNotFound { .. }) => Ok(None),
_ => Err(error),
},
}
}

/// The latest head of the lifetime `native`, whether the lifetime wrote it,
/// and the commit the lifetime started at: the head of the version it was
/// created from, which is still its head while it has written none.
async fn read_branch(
root: &str,
main: &Dataset,
legacy: &ExtentCache,
native: &str,
contents: &BranchContents,
) -> Result<(GraphLineageRow, bool, String)> {
let branch = main
.checkout_branch(native)
.await
.map_err(OmniError::storage)?;
let head = read_manifest_rows_projected(&branch).await?.head;
if exact_head(&head, Some(native)).is_none() {
let started_at = head.graph_commit_id.clone();
return Ok((head, false, started_at));
}
let created = branch
.checkout_version(contents.parent_version)
.await
.map_err(OmniError::storage)?;
let started_at =
match crate::legacy::at_version(root, &created.session(), legacy, &created).await? {
None => read_manifest_rows_projected(&created).await?.head,
Some(at) => at.served(Some(native), contents.parent_version)?.commit,
};
Ok((head, true, started_at.graph_commit_id))
}
23 changes: 6 additions & 17 deletions crates/omnigraph-catalog/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ pub(crate) use omnigraph_core::{
branch_control, branch_names, dataset_index, error, handle_cache, instrumentation,
lance_access, lance_clone, manifest_request, metadata, seams, staging, storage,
};
pub mod branch_detail;
pub mod commit_graph;

mod commit;
Expand Down Expand Up @@ -63,6 +64,7 @@ pub mod retention;
pub mod state;
pub mod unattached;
pub mod unpublished;
pub use branch_detail::{GraphBranchDetail, GraphBranchListing, list_graph_branches_detailed};
pub use branch_names::is_merge_input_tag;
pub use migrations::{
UNATTACHED_BRANCHES_STAMP, UNPUBLISHED_COMMITS_STAMP, require_unattached_branches,
Expand Down Expand Up @@ -2668,23 +2670,10 @@ impl ManifestCoordinator {
/// Logical graph branches, `main` first. Each live native ref maps to
/// exactly one logical name; a duplicate incarnation fails loudly.
pub async fn list_graph_branches(&self) -> Result<Vec<String>> {
let branches = list_live_manifest_branch_contents(&self.dataset).await?;
let mut names = Vec::with_capacity(branches.len());
let mut seen = HashSet::with_capacity(branches.len());
for native in branches.keys().filter(|name| *name != "main") {
let logical = crate::branch_names::logical_branch_name(native).to_string();
if !seen.insert(logical.clone()) {
return Err(OmniError::manifest_conflict(format!(
"branch '{logical}' has more than one live native incarnation; run cleanup \
before using it"
)));
}
names.push(logical);
}
names.sort();
let mut all = vec!["main".to_string()];
all.extend(names);
Ok(all)
let named = branch_detail::live_named_branches(&self.dataset).await?;
Ok(std::iter::once("main".to_string())
.chain(named.into_iter().map(|branch| branch.name))
.collect())
}
}

Expand Down
43 changes: 37 additions & 6 deletions crates/omnigraph-cli/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@ use omnigraph_api_types::{
ChangeRequest, CommitChangesOutput, CommitListOutput, CommitOutput, DiffOutput, EntityKind,
ExportRequest, GraphBatchLoadOutput, GraphDiscoveryOutput, GraphListOutput,
InvokeStoredQueryRequest, MergeBaseOutput, QueriesCatalogOutput, QueryRequest, ReadOutput,
SchemaApplyOutput, SchemaOutput, SettingsRequest, SnapshotOutput, branch_list_read_output,
branch_read_output, change_baseline_output, change_feed_output, change_scope,
commit_changes_output, commit_output, diff_output, graph_batch_load_receipt_output,
load_executed_output, merge_base_output, read_output, schema_apply_output, show_read_output,
snapshot_payload,
SchemaApplyOutput, SchemaOutput, SettingsRequest, SnapshotOutput, branch_list_output,
branch_list_read_output, branch_read_output, change_baseline_output, change_feed_output,
change_scope, commit_changes_output, commit_output, diff_output,
graph_batch_load_receipt_output, load_executed_output, merge_base_output, read_output,
schema_apply_output, show_read_output, snapshot_payload,
};
use omnigraph_compiler::catalog::Catalog;
use omnigraph_compiler::query::ast::{BranchCreateFrom, BranchRead, BranchWrite, CommitWrite};
Expand Down Expand Up @@ -579,7 +579,38 @@ impl GraphClient {
let session = Self::open_read_session(uri, binding, &[]).await?;
let mut branches = session.branch_list().await?;
branches.sort();
Ok(BranchListOutput { branches })
Ok(BranchListOutput {
branches,
details: None,
})
}
GraphClient::Catalogue => Err(catalogue_scope_refusal("branch list")),
}
}

/// [`Self::branch_list`] with each readable branch's details
/// (`GET /branches?detailed=true`).
pub(crate) async fn branch_list_detailed(&self) -> Result<BranchListOutput> {
match self {
GraphClient::Remote {
http,
base_url,
token,
..
} => {
remote_json(
http,
Method::GET,
remote_url(base_url, &["branches"], &[("detailed", "true")])?,
None,
token.as_deref(),
)
.await
}
GraphClient::Embedded { uri, binding, .. } => {
let session = Self::open_read_session(uri, binding, &[]).await?;
let listing = session.branch_list_detailed(&mut |_| true).await?;
Ok(branch_list_output(&listing))
}
GraphClient::Catalogue => Err(catalogue_scope_refusal("branch list")),
}
Expand Down
Loading
Loading