From 8511f92314536b52f085065a6b679fe312a13f18 Mon Sep 17 00:00:00 2001
From: Danny White <3104761+dnywh@users.noreply.github.com>
Date: Mon, 21 Sep 2026 10:48:19 +1000
Subject: [PATCH] feat(studio): show table WAL retention headroom (#50499)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## What kind of change does this PR introduce?
Feature
## What is the current behavior?
Replicated table rows show their temporary sync slot's pending bytes,
WAL risk, and last check-in, but omit the amount of WAL retention
remaining.
## What is the new behavior?
Replicated table rows include the temporary slot's WAL retention
headroom alongside the existing sync details. Finite values use a
compact byte value, an explicit unlimited value is labelled `Unlimited
WAL retention`, and absent values remain omitted.
| After |
| --- |
|
|
## To test
1. Open `/project/[/database/replication/` for a
BigQuery pipeline while at least one table is completing its initial
sync (or was just restarted).
2. Find that table under **Replicated tables**.
3. Confirm its **Details** cell reads in this order when all values are
available: `720 MB waiting to sync · 1.3 GB WAL retention remaining ·
Last check-in 2 min`.
4. Confirm a temporary slot with unlimited retention shows `Unlimited
WAL retention`, and a missing retention value adds no placeholder.
5. Confirm WAL warnings such as `Some changes at risk` or `Some changes
lost` still appear alongside the retention value.
## Summary by CodeRabbit
- **New Features**
- Replication status now displays remaining WAL retention.
- Shows unlimited retention when applicable or presents finite capacity
in a readable format.
- Reports when retention is exhausted and changes may be at risk.
- **Bug Fixes**
- Invalid retention values no longer produce misleading WAL retention
messages.
- Replication pipeline status more clearly identifies conditions where
changes may be at risk due to limited or exhausted retention.
---
.../ReplicationPipelineStatus.utils.test.ts | 49 ++++++++++++++++---
.../ReplicationPipelineStatus.utils.tsx | 12 +++++
2 files changed, 54 insertions(+), 7 deletions(-)
diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.utils.test.ts b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.utils.test.ts
index d614e0badd3..3c710816514 100644
--- a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.utils.test.ts
+++ b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.utils.test.ts
@@ -3,7 +3,7 @@ import { describe, expect, test } from 'vitest'
import { getTableSyncLagLabel } from './ReplicationPipelineStatus.utils'
describe('getTableSyncLagLabel', () => {
- test('omits healthy slot details when the table has caught up', () => {
+ test('reports unlimited WAL retention for a healthy caught-up table slot', () => {
expect(
getTableSyncLagLabel({
active: true,
@@ -12,20 +12,20 @@ describe('getTableSyncLagLabel', () => {
confirmed_flush_lsn_bytes: 0,
safe_wal_size_bytes: null,
})
- ).toEqual([])
+ ).toEqual(['Unlimited WAL retention'])
})
- test('reports the backlog and last check-in', () => {
+ test('reports the backlog, finite WAL retention, and last check-in', () => {
expect(
getTableSyncLagLabel({
active: true,
wal_status: 'reserved',
restart_lsn_bytes: 4096,
confirmed_flush_lsn_bytes: 2048,
- safe_wal_size_bytes: null,
+ safe_wal_size_bytes: 1_363_148_800,
reply_time_lag: 4800,
})
- ).toEqual(['2 KB waiting to sync', 'Last check-in 4.80 s'])
+ ).toEqual(['2 KB waiting to sync', '1.3 GB WAL retention remaining', 'Last check-in 4.80 s'])
})
test('reports slot risk without treating the expected inactive connection as a fault', () => {
@@ -35,8 +35,43 @@ describe('getTableSyncLagLabel', () => {
wal_status: 'unreserved',
restart_lsn_bytes: 0,
confirmed_flush_lsn_bytes: 0,
- safe_wal_size_bytes: null,
+ safe_wal_size_bytes: 0,
})
- ).toEqual(['Some changes at risk'])
+ ).toEqual(['0 bytes WAL retention remaining', 'Some changes at risk'])
+ })
+
+ test('omits WAL retention when the API does not provide it', () => {
+ expect(
+ getTableSyncLagLabel({
+ active: true,
+ wal_status: 'reserved',
+ restart_lsn_bytes: 0,
+ confirmed_flush_lsn_bytes: 0,
+ })
+ ).toEqual([])
+ })
+
+ test('omits an invalid WAL retention value', () => {
+ expect(
+ getTableSyncLagLabel({
+ active: true,
+ wal_status: 'reserved',
+ restart_lsn_bytes: 0,
+ confirmed_flush_lsn_bytes: 0,
+ safe_wal_size_bytes: Number.NaN,
+ })
+ ).toEqual([])
+ })
+
+ test('omits a negative WAL retention value', () => {
+ expect(
+ getTableSyncLagLabel({
+ active: true,
+ wal_status: 'reserved',
+ restart_lsn_bytes: 0,
+ confirmed_flush_lsn_bytes: 0,
+ safe_wal_size_bytes: -1,
+ })
+ ).toEqual([])
})
})
diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.utils.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.utils.tsx
index 29e15b43e3d..32401d89afb 100644
--- a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.utils.tsx
+++ b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.utils.tsx
@@ -276,6 +276,18 @@ export const getTableSyncLagLabel = (metrics: SlotLagMetrics): string[] => {
parts.push(`${formatBytes(pendingBytes, pendingBytes < 1024 ? 0 : 1)} waiting to sync`)
}
+ const safeWalSizeBytes = metrics.safe_wal_size_bytes
+ if (safeWalSizeBytes === null) {
+ parts.push('Unlimited WAL retention')
+ } else if (
+ typeof safeWalSizeBytes === 'number' &&
+ Number.isFinite(safeWalSizeBytes) &&
+ safeWalSizeBytes >= 0
+ ) {
+ const formattedBytes = formatBytes(safeWalSizeBytes, safeWalSizeBytes < 1024 ? 0 : 1)
+ parts.push(`${formattedBytes} WAL retention remaining`)
+ }
+
if (metrics.wal_status === 'unreserved') parts.push('Some changes at risk')
if (metrics.wal_status === 'lost') parts.push('Some changes lost')
]