Skip to content
Merged
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
109 changes: 86 additions & 23 deletions src/components/TableVersioningVisualization.vue
Original file line number Diff line number Diff line change
Expand Up @@ -1046,6 +1046,26 @@ const tags = computed<TagMeta[]>(() => {
return result;
});

// ─── Computed: snapshot-log IDs (snapshots natively committed on main) ────────
// The snapshot-log tracks the history of current-snapshot-id (always the main
// branch). Snapshots in main's ancestry but NOT in the snapshot-log were
// committed on other branches and later merged/fast-forwarded into main.
const mainSnapshotLogIds = computed<Set<number>>(() => {
const snapshotLog = (props.table.metadata as any)?.['snapshot-log'] as
| Array<{ 'snapshot-id': number }>
| undefined;
if (!snapshotLog || snapshotLog.length === 0) return new Set();
const mainBranch = branches.value.find((b) => b.name === 'main' || b.name === 'master');
if (!mainBranch) return new Set();
const ancestrySet = new Set(mainBranch.ancestry);
const result = new Set<number>();
snapshotLog.forEach((entry) => {
const sid = entry['snapshot-id'];
if (ancestrySet.has(sid)) result.add(sid);
});
return result;
});

// ─── Computed: nodes ─────────────────────────────────────────────────────────

const graphNodes = computed<GraphNode[]>(() => {
Expand All @@ -1060,7 +1080,16 @@ const graphNodes = computed<GraphNode[]>(() => {
const droppedBranches = branches.value.filter((b) => b.type === 'dropped');

const snapshotRow = new Map<number, number>();
if (mainBranch) mainBranch.ancestry.forEach((id) => snapshotRow.set(id, 0));
const logIds = mainSnapshotLogIds.value;
if (mainBranch) {
mainBranch.ancestry.forEach((id) => {
// If snapshot-log is available, only assign to main row if the snapshot
// was natively committed on main (appears in snapshot-log).
if (logIds.size === 0 || logIds.has(id)) {
snapshotRow.set(id, 0);
}
});
}
namedBranches.forEach((branch, idx) => {
branch.ancestry.forEach((id) => {
if (!snapshotRow.has(id)) snapshotRow.set(id, -(idx + 1));
Expand Down Expand Up @@ -1183,39 +1212,71 @@ const graphLinks = computed<GraphLink[]>(() => {
// Build links for each branch
const mainBr = branches.value.find((b) => b.name === 'main' || b.name === 'master');

// Determine which snapshot IDs are "on main" for link-ownership purposes.
// If snapshot-log is available, use it; otherwise fall back to full ancestry.
const logIds = mainSnapshotLogIds.value;
const mainOwnedIds: Set<number> =
logIds.size > 0 && mainBr ? logIds : mainBr ? new Set(mainBr.ancestry) : new Set<number>();

// For main branch: draw links between consecutive snapshot-log entries
// (skips snapshots that were merged in from other branches).
if (mainBr && logIds.size > 0) {
const snapshotLog = (props.table.metadata as any)?.['snapshot-log'] as
| Array<{ 'snapshot-id': number }>
| undefined;
if (snapshotLog) {
const mainLogOrdered = snapshotLog
.map((entry) => entry['snapshot-id'])
.filter((id) => mainBr.ancestry.includes(id));
for (let i = 0; i < mainLogOrdered.length - 1; i++) {
const parentNode = nodeMap.get(mainLogOrdered[i]);
const childNode = nodeMap.get(mainLogOrdered[i + 1]);
if (parentNode && childNode) addLink(parentNode, childNode, mainBr.color, 0.8);
}
Comment on lines +1215 to +1235
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

Apache Iceberg snapshot-log semantics: is snapshot-loga time-ordered history ofcurrent-snapshot-id, and can the same snapshot-idappear multiple times afterrollbackToorsetCurrentSnapshot?

💡 Result:

  • Yes (conceptually): snapshot-log is a chronological record of changes to the table’s current-snapshot-id (i.e., when a given snapshot became “current”). The spec says: each time the current-snapshot-id is changed, a new entry should be added using last-updated-ms and the new current-snapshot-id. It’s also used to resolve “TIMESTAMP AS OF” queries by finding the snapshot just prior to a timestamp. [1]
  • It is not a permanent full history: when snapshots are expired, entries before an expired snapshot “should be removed,” so the log can be truncated. [1]
  • Duplicates are allowed/expected: because the rule is “append an entry on every change of current-snapshot-id” (not “only when it changes to a never-before-current snapshot”), the same snapshot-id can appear multiple times if you move the current pointer back and forth—e.g., via rollbackTo or setCurrentSnapshot (both change what snapshot is current rather than creating a new snapshot). This follows directly from the spec’s “add an entry each time it is changed” behavior. [1]
  • Ordering: implementations/documentation commonly state entries are ordered by timestamp (chronological). [2]

Sources
[1] Apache Iceberg Table Spec – table metadata fields (current-snapshot-id, snapshot-log) and time-travel behavior
[2] DeepWiki: “Snapshot system and time travel” (Iceberg) – notes snapshot-log is chronological / sorted by timestamp


🏁 Script executed:

cd src/components && wc -l TableVersioningVisualization.vue

Repository: lakekeeper/console-components

Length of output: 110


🏁 Script executed:

cd src/components && sed -n '1200,1250p' TableVersioningVisualization.vue

Repository: lakekeeper/console-components

Length of output: 2306


🏁 Script executed:

cd src/components && sed -n '1,100p' TableVersioningVisualization.vue | head -50

Repository: lakekeeper/console-components

Length of output: 2275


🏁 Script executed:

cd src/components && sed -n '1,400p' TableVersioningVisualization.vue | grep -n -A 5 -B 5 "branches\|ancestry\|snapshot"

Repository: lakekeeper/console-components

Length of output: 3556


🏁 Script executed:

cd src/components && rg "ancestry" TableVersioningVisualization.vue -n -B 2 -A 2 | head -80

Repository: lakekeeper/console-components

Length of output: 3442


🏁 Script executed:

cd src/components && rg "const branches\|\.value.*branches" TableVersioningVisualization.vue -n -B 3 -A 10

Repository: lakekeeper/console-components

Length of output: 55


🏁 Script executed:

cd src/components && rg "traceAncestry" TableVersioningVisualization.vue -n -B 3 -A 15 | head -80

Repository: lakekeeper/console-components

Length of output: 2172


🏁 Script executed:

cd src/components && rg "function traceAncestry|const traceAncestry" TableVersioningVisualization.vue -n -B 2 -A 20

Repository: lakekeeper/console-components

Length of output: 890


Use ancestry order instead of snapshot-log chronological order for main branch links.

Iceberg's snapshot-log is a chronological record of current-snapshot-id changes, where the same snapshot ID can legitimately reappear after rollbackTo() or setCurrentSnapshot(). Using this raw log order to draw graph links produces self-links or backward edges. Instead, filter mainBr.ancestry by logIds to get the correct topological ordering of snapshots natively committed to main.

🛠️ Suggested change
-    const snapshotLog = (props.table.metadata as any)?.['snapshot-log'] as
-      | Array<{ 'snapshot-id': number }>
-      | undefined;
-    if (snapshotLog) {
-      const mainLogOrdered = snapshotLog
-        .map((entry) => entry['snapshot-id'])
-        .filter((id) => mainBr.ancestry.includes(id));
+    const mainLogOrdered = [...mainBr.ancestry].reverse().filter((id) => logIds.has(id));
+    if (mainLogOrdered.length > 1) {
       for (let i = 0; i < mainLogOrdered.length - 1; i++) {
         const parentNode = nodeMap.get(mainLogOrdered[i]);
         const childNode = nodeMap.get(mainLogOrdered[i + 1]);
         if (parentNode && childNode) addLink(parentNode, childNode, mainBr.color, 0.8);
       }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Determine which snapshot IDs are "on main" for link-ownership purposes.
// If snapshot-log is available, use it; otherwise fall back to full ancestry.
const logIds = mainSnapshotLogIds.value;
const mainOwnedIds: Set<number> =
logIds.size > 0 && mainBr ? logIds : mainBr ? new Set(mainBr.ancestry) : new Set<number>();
// For main branch: draw links between consecutive snapshot-log entries
// (skips snapshots that were merged in from other branches).
if (mainBr && logIds.size > 0) {
const snapshotLog = (props.table.metadata as any)?.['snapshot-log'] as
| Array<{ 'snapshot-id': number }>
| undefined;
if (snapshotLog) {
const mainLogOrdered = snapshotLog
.map((entry) => entry['snapshot-id'])
.filter((id) => mainBr.ancestry.includes(id));
for (let i = 0; i < mainLogOrdered.length - 1; i++) {
const parentNode = nodeMap.get(mainLogOrdered[i]);
const childNode = nodeMap.get(mainLogOrdered[i + 1]);
if (parentNode && childNode) addLink(parentNode, childNode, mainBr.color, 0.8);
}
// Determine which snapshot IDs are "on main" for link-ownership purposes.
// If snapshot-log is available, use it; otherwise fall back to full ancestry.
const logIds = mainSnapshotLogIds.value;
const mainOwnedIds: Set<number> =
logIds.size > 0 && mainBr ? logIds : mainBr ? new Set(mainBr.ancestry) : new Set<number>();
// For main branch: draw links between consecutive snapshot-log entries
// (skips snapshots that were merged in from other branches).
if (mainBr && logIds.size > 0) {
const mainLogOrdered = [...mainBr.ancestry].reverse().filter((id) => logIds.has(id));
if (mainLogOrdered.length > 1) {
for (let i = 0; i < mainLogOrdered.length - 1; i++) {
const parentNode = nodeMap.get(mainLogOrdered[i]);
const childNode = nodeMap.get(mainLogOrdered[i + 1]);
if (parentNode && childNode) addLink(parentNode, childNode, mainBr.color, 0.8);
}
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/TableVersioningVisualization.vue` around lines 1215 - 1235,
The current logic builds mainLogOrdered from snapshotLog chronological entries
which can reintroduce snapshot IDs out of topological order; instead derive the
ordered list from mainBr.ancestry filtered by mainSnapshotLogIds so the order
follows ancestry (topology) and only includes IDs present in the log. Replace
the creation of mainLogOrdered with a filter over mainBr.ancestry (using
mainSnapshotLogIds or logIds.has) and then iterate that ordered array to get
parentNode/childNode from nodeMap and call addLink(parentNode, childNode,
mainBr.color, 0.8).

}
}

branches.value.forEach((branch) => {
// Tagged-dropped branches get higher opacity (teal links)
const hasTagOnDropped =
branch.type === 'dropped' &&
branch.ancestry.some((id) => tags.value.some((t) => t.snapshotId === id));
const opacity = branch.type === 'dropped' ? (hasTagOnDropped ? 0.7 : 0.5) : 0.8;

// (stop once we hit a snapshot owned by main, to avoid coloring main's links)
const mainAncestrySet = mainBr ? new Set(mainBr.ancestry) : new Set<number>();
for (let i = 0; i < branch.ancestry.length - 1; i++) {
const childId = branch.ancestry[i];
const parentId = branch.ancestry[i + 1];
const child = nodeMap.get(childId);
const parent = nodeMap.get(parentId);
if (!child || !parent) continue;
// If both child and parent are on main, skip — main will draw its own links
if (
branch !== mainBr &&
branch.type === 'branch' &&
mainAncestrySet.has(childId) &&
mainAncestrySet.has(parentId)
) {
continue;
// For main when snapshot-log links were already drawn above, skip ancestry walk
if (branch === mainBr && logIds.size > 0) {
// Main's links already drawn from snapshot-log; skip to divergence/dropped
} else {
// Walk ancestry and draw links (skip pairs where both are on main)
for (let i = 0; i < branch.ancestry.length - 1; i++) {
const childId = branch.ancestry[i];
const parentId = branch.ancestry[i + 1];
const child = nodeMap.get(childId);
const parent = nodeMap.get(parentId);
if (!child || !parent) continue;
// If both child and parent are owned by main, skip — main draws its own links
if (
branch !== mainBr &&
branch.type === 'branch' &&
mainOwnedIds.has(childId) &&
mainOwnedIds.has(parentId)
) {
continue;
}
addLink(parent, child, hasTagOnDropped ? TAG_COLOR : branch.color, opacity);
Comment on lines +1251 to +1266
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Avoid double-drawing the fork edge.

The ancestry walk already emits the mainOwned -> first non-main pair, so the extra diverge- pass redraws the exact same segment. The split edge ends up brighter/thicker than the rest of the branch.

🛠️ Suggested change
-    // Divergence from main: draw a colored link where the branch leaves main's owned set.
-    // Only draw if the child snapshot is NOT owned by main (i.e. an actual fork exists).
-    if (branch.type === 'branch' && branch.name !== 'main' && branch.name !== 'master') {
-      if (mainBr) {
-        for (let i = 0; i < branch.ancestry.length; i++) {
-          if (
-            mainOwnedIds.has(branch.ancestry[i]) &&
-            i > 0 &&
-            !mainOwnedIds.has(branch.ancestry[i - 1])
-          ) {
-            const from = nodeMap.get(branch.ancestry[i]);
-            const to = nodeMap.get(branch.ancestry[i - 1]);
-            if (from && to) addLink(from, to, branch.color, 0.8, 'diverge-');
-            break;
-          }
-        }
-      }
-    }

Also applies to: 1270-1283

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/TableVersioningVisualization.vue` around lines 1251 - 1266,
The ancestry loop is double-drawing the fork edge (making it brighter) because
the same mainOwned -> first-non-main pair is emitted again in the "diverge"
pass; update the diverge-pass logic (the code that creates "diverge-" links,
referenced alongside the existing ancestry loop and addLink calls) to skip
creating a link when both the childId and parentId are in mainOwnedIds or when
branch === mainBr/branch.type === 'branch' matches the same condition used
above, so the addLink call is not invoked twice for the same segment.

}
addLink(parent, child, hasTagOnDropped ? TAG_COLOR : branch.color, opacity);
}

// Divergence from main
// Divergence from main: draw a colored link where the branch leaves main's owned set.
// Only draw if the child snapshot is NOT owned by main (i.e. an actual fork exists).
if (branch.type === 'branch' && branch.name !== 'main' && branch.name !== 'master') {
if (mainBr) {
const mainSet = new Set(mainBr.ancestry);
for (let i = 0; i < branch.ancestry.length; i++) {
if (mainSet.has(branch.ancestry[i]) && i > 0) {
if (
mainOwnedIds.has(branch.ancestry[i]) &&
i > 0 &&
!mainOwnedIds.has(branch.ancestry[i - 1])
) {
const from = nodeMap.get(branch.ancestry[i]);
const to = nodeMap.get(branch.ancestry[i - 1]);
if (from && to) addLink(from, to, branch.color, 0.8, 'diverge-');
Expand Down Expand Up @@ -1551,10 +1612,12 @@ function renderChart() {
{ dx: 0, dy: 65, anchor: 'middle' },
];

// Helper to pick slots based on label count
// Helper to pick slots based on label count.
// For 2 labels, stack vertically (centered) to avoid horizontal offset
// that visually overlaps with adjacent nodes (spacingX=90, old dx=±70).
function pickSlots(pool: typeof aboveSlots, count: number) {
if (count === 1) return [pool[0]];
if (count === 2) return [pool[1], pool[2]];
if (count === 2) return [pool[0], { dx: 0, dy: pool[0].dy + 20, anchor: 'middle' }];
if (count === 3) return [pool[1], pool[0], pool[2]];
return Array.from({ length: count }, (_, i) => pool[i % pool.length]);
}
Expand Down
Loading