Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
2 changes: 1 addition & 1 deletion docs/modules/components/pages/inputs/oracledb_cdc.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ Specifies a number of tables that will be processed in parallel during the snaps

=== `snapshot_max_batch_size`

The maximum number of rows to be streamed in a single batch when taking a snapshot.
The maximum number of rows fetched per query when taking a snapshot of a table with a `snapshot_filters` entry configured. Tables without one are streamed through a single unordered cursor, where this value only paces how often a cancellation is checked.


*Type*: `int`
Expand Down
2 changes: 1 addition & 1 deletion internal/impl/oracledb/input_oracledb_cdc.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ Redo log retention must cover idle periods, not just outages: the SCN checkpoint
Description("Specifies a number of tables that will be processed in parallel during the snapshot processing stage.").
Default(1)).
Field(service.NewIntField(ociFieldSnapshotMaxBatchSize).
Description("The maximum number of rows to be streamed in a single batch when taking a snapshot.").
Description("The maximum number of rows fetched per query when taking a snapshot of a table with a `" + ociFieldSnapshotFilters + "` entry configured. Tables without one are streamed through a single unordered cursor, where this value only paces how often a cancellation is checked.").

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

snapshot_max_batch_size now carries two unrelated meanings depending on whether the table happens to have a snapshot_filters entry: for filtered tables it is the page size of the keyset-pagination query, and for every other table (the default, since snapshot_filters is optional) it degrades to "how often ctx.Err() is polled" — see snapshot.go#L362-L381.

This is a material UX regression under CONTRIBUTING §1.1.3 ("UX should be intuitive and require minimal explanation") and §3.1.5 (consistency with the rest of the fleet, where snapshot_max_batch_size uniformly means rows-per-fetch). The description text itself is the evidence — it has to explain two divergent behaviours for one knob.

Concretely: a user who raised snapshot_max_batch_size to tune snapshot throughput — exactly what docs/benchmark-results/oracledb-cdc.md documents with snapshot_max_batch_size: 160000 — now gets no throughput effect at all on the default path, silently.

Suggested fix: keep the knob meaning "rows fetched per round trip" on both paths by wiring it into the driver's prefetch/array-fetch size for the full-scan cursor, and use a separate fixed internal constant for the cancellation-poll interval. If the two really must differ, a dedicated field for the scan path would be clearer than overloading this one.

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'm not sure we want snapshot_max_batch_size to be the prefetch_rows value, it would be better to have that as a dedicated config and deprecate snapshot_max_batch_size if needed.

Default(1000),
).
// logminer config
Expand Down
199 changes: 139 additions & 60 deletions internal/impl/oracledb/replication/snapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,8 @@ func (s *Snapshot) snapshotTable(ctx context.Context, table UserTable, maxBatchS
tableName = table.FullName()
)
l := s.log.With("src_table", tableName)
if _, hasFilter := s.filters[tableName]; hasFilter {
customQuery, hasFilter := s.filters[tableName]
if hasFilter {
l.Infof("Launching snapshot of table '%s' with snapshot filter", tableName)
} else {
l.Infof("Launching snapshot of table '%s'", tableName)
Expand Down Expand Up @@ -196,34 +197,45 @@ func (s *Snapshot) snapshotTable(ctx context.Context, table UserTable, maxBatchS
}
}()

var tablePks []string
if tablePks, err = getTablePrimaryKeys(ctx, tx, table); err != nil {
return err
}

l.Debugf("Found primary keys for table '%s': %v", table, tablePks)
lastSeenPksValues := map[string]any{}
for _, pk := range tablePks {
lastSeenPksValues[pk] = nil
}

var (
numRowsProcessed int
batchCount int
)
for {
var pksForQuery map[string]any
if numRowsProcessed > 0 {
pksForQuery = lastSeenPksValues
var numRowsProcessed int
if hasFilter {
// Custom filters may join or aggregate, so a physical-order scan can't safely
// assume every row maps to a stable ROWID/heap position; keep the PK-keyset
// pagination path for these.
var tablePks []string
if tablePks, err = getTablePrimaryKeys(ctx, tx, table); err != nil {
return err
}
batchCount, err = s.processBatch(ctx, tx, table, tablePks, pksForQuery, lastSeenPksValues, maxBatchSize, tableName)
if err != nil {
return fmt.Errorf("processing snapshot batch: %w", err)

l.Debugf("Found primary keys for table '%s': %v", table, tablePks)
lastSeenPksValues := map[string]any{}
for _, pk := range tablePks {
lastSeenPksValues[pk] = nil
}

numRowsProcessed += batchCount
if batchCount < maxBatchSize {
break
var batchCount int
for {
var pksForQuery map[string]any
if numRowsProcessed > 0 {
pksForQuery = lastSeenPksValues
}
batchCount, err = s.processBatch(ctx, tx, table, tablePks, pksForQuery, lastSeenPksValues, maxBatchSize, tableName, customQuery)
if err != nil {
return fmt.Errorf("processing snapshot batch: %w", err)
}

numRowsProcessed += batchCount
if batchCount < maxBatchSize {
break
}
}
} else {
// No filter: read the whole table through a single unordered cursor so Oracle
// picks a full table scan (sequential multiblock reads) instead of the
// PK-index-driven, table-access-by-rowid random I/O that ORDER BY pk forces
// once the table no longer fits in buffer cache.
if numRowsProcessed, err = s.snapshotTableFullScan(ctx, tx, table, maxBatchSize, tableName); err != nil {
Comment thread
josephwoodward marked this conversation as resolved.
return fmt.Errorf("processing snapshot table scan: %w", err)
}
}

Expand All @@ -241,8 +253,7 @@ func (s *Snapshot) snapshotTable(ctx context.Context, table UserTable, maxBatchS
// pksForQuery is passed to querySnapshotTable for cursor-based pagination (nil on first batch).
// lastSeenPksValues is mutated in place with the PK values from the last row of the batch,
// so the caller can pass it as pksForQuery on the next iteration.
func (s *Snapshot) processBatch(ctx context.Context, tx *sql.Tx, table UserTable, tablePks []string, pksForQuery map[string]any, lastSeenPksValues map[string]any, maxBatchSize int, tableName string) (batchCount int, err error) {
customQuery := s.filters[table.FullName()]
func (s *Snapshot) processBatch(ctx context.Context, tx *sql.Tx, table UserTable, tablePks []string, pksForQuery map[string]any, lastSeenPksValues map[string]any, maxBatchSize int, tableName, customQuery string) (batchCount int, err error) {
batchRows, err := querySnapshotTable(ctx, tx, table, tablePks, pksForQuery, maxBatchSize, customQuery)
if err != nil {
return 0, fmt.Errorf("execute snapshot table query: %w", err)
Expand Down Expand Up @@ -274,48 +285,116 @@ func (s *Snapshot) processBatch(ctx context.Context, tx *sql.Tx, table UserTable
return 0, err
}

var (
v any
mapErr error
)
row := map[string]any{}
for idx, value := range values {
if v, mapErr = mappers[idx](value); mapErr != nil {
return 0, mapErr
}
if !s.lobEnabled && IsLOBTypeName(types[idx].DatabaseTypeName()) {
v = nil
}
row[columns[idx]] = v
if _, ok := lastSeenPksValues[columns[idx]]; ok {
lastSeenPksValues[columns[idx]] = value
}
if err := s.publishRow(ctx, table, columns, types, values, mappers, colMeta, lastSeenPksValues); err != nil {
return 0, err
}
}

if err = batchRows.Err(); err != nil {
return 0, fmt.Errorf("iterating snapshot table row: %w", err)
}
s.snapshotRowsTotalMetric.Incr(int64(batchCount), tableName)
return batchCount, nil
}

// publishRow maps a single scanned row's values into a MessageEvent and publishes
// it. When lastSeenPksValues is non-nil, it's mutated in place with this row's
// primary key values so the caller can resume PK-keyset pagination from it on the
// next batch; pass nil when there's no keyset cursor to maintain (full table scan).
func (s *Snapshot) publishRow(ctx context.Context, table UserTable, columns []string, types []*sql.ColumnType, values []any, mappers []func(any) (any, error), colMeta []ColumnMeta, lastSeenPksValues map[string]any) error {
row := map[string]any{}
for idx, value := range values {
v, err := mappers[idx](value)
if err != nil {
return err
}
if !s.lobEnabled && IsLOBTypeName(types[idx].DatabaseTypeName()) {
v = nil
}
row[columns[idx]] = v
if _, ok := lastSeenPksValues[columns[idx]]; ok {
lastSeenPksValues[columns[idx]] = value
}
}

m := MessageEvent{
Table: table.Name,
Schema: table.Schema,
Data: row,
Operation: MessageOperationRead,
ColumnMeta: colMeta,
m := MessageEvent{
Table: table.Name,
Schema: table.Schema,
Data: row,
Operation: MessageOperationRead,
ColumnMeta: colMeta,
}
if s.scn != InvalidSCN {
m.SCN = s.scn
}
if !s.commitTimestamp.IsZero() {
m.CommitTimestamp = s.commitTimestamp
}

if err := s.publisher.Publish(ctx, &m); err != nil {
return fmt.Errorf("handling snapshot table row: %w", err)
}
return nil
}

// snapshotTableFullScan reads every row of table through a single, unordered
// query so Oracle's optimizer picks a full table scan (sequential multiblock
// reads) rather than the PK-index-driven, table-access-by-rowid random I/O that
// ORDER BY pk forces once the table exceeds buffer cache. There's no query-level
// batching here — maxBatchSize only paces how often cancellation is checked.
func (s *Snapshot) snapshotTableFullScan(ctx context.Context, tx *sql.Tx, table UserTable, maxBatchSize int, tableName string) (numRowsProcessed int, err error) {
Comment thread
josephwoodward marked this conversation as resolved.
q := fmt.Sprintf(`SELECT * FROM "%s"."%s"`, table.Schema, table.Name)
Comment on lines +334 to +337

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This change is justified purely on throughput grounds — the doc comment here and the inline comment at snapshot.go#L232-L234 both claim the unordered scan is faster than the previous ORDER BY keyset pagination — but no benchmark evidence ships with it.

CONTRIBUTING §1.3.4/§1.3.5 requires both local and real-endpoint benchmarking at various throughput levels, with results recorded under docs/benchmark-results/. docs/benchmark-results/oracledb-cdc.md already has a "Snapshot Results" section, and it is left untouched by this PR — so the recorded numbers now describe a code path that no longer runs for unfiltered tables, and were captured under a snapshot_max_batch_size: 160000 setting whose meaning this PR changes.

Suggested fix: re-run the snapshot benchmarks against the new scan path and update docs/benchmark-results/oracledb-cdc.md, so the performance claim in this comment is backed by recorded numbers rather than an assertion about Oracle's optimizer.

@josephwoodward josephwoodward Aug 20, 2026

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.

Benchmarks included in proof of work. Will look to update these as a separate PR.

rows, err := tx.QueryContext(ctx, q)
if err != nil {
return 0, fmt.Errorf("execute snapshot table scan: %w", err)
}
defer func() {
if closeErr := rows.Close(); closeErr != nil && err == nil {
err = fmt.Errorf("closing snapshot rows: %w", closeErr)
}
if s.scn != InvalidSCN {
m.SCN = s.scn
}()

types, err := rows.ColumnTypes()
if err != nil {
return 0, fmt.Errorf("fetch column types: %w", err)
}

values, mappers := prepSnapshotScannerAndMappers(types)

columns, err := rows.Columns()
if err != nil {
return 0, fmt.Errorf("fetch columns: %w", err)
}

colMeta := buildColumnMeta(types)

var sinceCancelCheck int
for rows.Next() {
if err = rows.Scan(values...); err != nil {
return numRowsProcessed, err
}
if !s.commitTimestamp.IsZero() {
m.CommitTimestamp = s.commitTimestamp

if err = s.publishRow(ctx, table, columns, types, values, mappers, colMeta, nil); err != nil {
return numRowsProcessed, err
}

if err = s.publisher.Publish(ctx, &m); err != nil {
return 0, fmt.Errorf("handling snapshot table row: %w", err)
numRowsProcessed++
s.snapshotRowsTotalMetric.Incr(1, tableName)

sinceCancelCheck++
if sinceCancelCheck >= maxBatchSize {
sinceCancelCheck = 0
if err = ctx.Err(); err != nil {
return numRowsProcessed, err
}
}
}

if err = batchRows.Err(); err != nil {
return 0, fmt.Errorf("iterating snapshot table row: %w", err)
if err = rows.Err(); err != nil {
return numRowsProcessed, fmt.Errorf("iterating snapshot table row: %w", err)
}
s.snapshotRowsTotalMetric.Incr(int64(batchCount), tableName)
return batchCount, nil

return numRowsProcessed, nil
}

func getTablePrimaryKeys(ctx context.Context, tx *sql.Tx, table UserTable) ([]string, error) {
Expand Down
98 changes: 93 additions & 5 deletions internal/impl/oracledb/replication/snapshot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ package replication_test

import (
"context"
"fmt"
"io"
"log/slog"
"sync"
Expand Down Expand Up @@ -78,7 +79,8 @@ func TestIntegrationSnapshot(t *testing.T) {
require.NoError(t, err)
require.NotZero(t, scn)

// Read snapshot with small batch size to trigger pagination
// No filter configured, so this exercises the unordered full table scan path,
// batched by small maxBatchSize.
err = snapshot.Read(t.Context(), 1, 12)
require.NoError(t, err)

Expand All @@ -88,7 +90,7 @@ func TestIntegrationSnapshot(t *testing.T) {
}
})

t.Run("TwoColumnCompositeKey_WithPagination", func(t *testing.T) {
t.Run("TwoColumnCompositeKey_FullScan", func(t *testing.T) {
var totalRows int
for i := range 10 {
for j := range 5 {
Expand All @@ -110,7 +112,8 @@ func TestIntegrationSnapshot(t *testing.T) {
require.NoError(t, err)
require.NotZero(t, scn)

// Read snapshot with small batch size to trigger pagination
// No filter configured, so this exercises the unordered full table scan path,
// batched by small maxBatchSize.
err = snapshot.Read(t.Context(), 1, 10)
require.NoError(t, err)

Expand All @@ -120,7 +123,48 @@ func TestIntegrationSnapshot(t *testing.T) {
}
})

t.Run("ThreeColumnCompositeKey_WithPagination", func(t *testing.T) {
t.Run("TwoColumnCompositeKey_WithFilterPagination", func(t *testing.T) {
// Offset col1 so these rows occupy a disjoint key range from
// TwoColumnCompositeKey_FullScan, which shares this table and is never
// truncated between subtests.
const col1Offset = 100

var totalRows int
for i := range 10 {
for j := range 5 {
totalRows++
db.MustExec("INSERT INTO TESTDB.composite_key_test (col1, col2, data) VALUES (:1, :2, :3)", col1Offset+i, j, "test-data")
}
}
Comment thread
josephwoodward marked this conversation as resolved.

publisher := &publisherStub{}
tables := []replication.UserTable{
{Schema: "TESTDB", Name: "COMPOSITE_KEY_TEST"},
}
filters := map[string]string{
"TESTDB.COMPOSITE_KEY_TEST": fmt.Sprintf("SELECT col1, col2, data FROM TESTDB.COMPOSITE_KEY_TEST WHERE col1 >= %d", col1Offset),
}

snapshot, err := replication.NewSnapshot(t.Context(), connStr, tables, filters, publisher, false, "", service.NewLoggerFromSlog(log), service.MockResources().Metrics())
require.NoError(t, err)
defer snapshot.Close()

scn, err := snapshot.Prepare(t.Context())
require.NoError(t, err)
require.NotZero(t, scn)

// A snapshot filter forces the PK-keyset pagination path, exercising the
// composite-key lexicographic WHERE/ORDER BY construction in querySnapshotTable.
err = snapshot.Read(t.Context(), 1, 10)
require.NoError(t, err)

assert.Equalf(t, totalRows, publisher.count(), "Expected all %d rows to be captured during snapshot", totalRows)
for i, msg := range publisher.messages {
assert.Equalf(t, scn, msg.SCN, "Expected snapshot message[%d] to carry the captured SCN", i)
}
})

t.Run("ThreeColumnCompositeKey_FullScan", func(t *testing.T) {
var totalRows int
for i := range 5 {
for j := range 3 {
Expand All @@ -144,7 +188,51 @@ func TestIntegrationSnapshot(t *testing.T) {
require.NoError(t, err)
require.NotZero(t, scn)

// Read snapshot with small batch size to trigger pagination
// No filter configured, so this exercises the unordered full table scan path,
// batched by small maxBatchSize.
err = snapshot.Read(t.Context(), 1, 8)
require.NoError(t, err)

assert.Equalf(t, totalRows, publisher.count(), "Expected all %d rows to be captured during snapshot", totalRows)
for i, msg := range publisher.messages {
assert.Equalf(t, scn, msg.SCN, "Expected snapshot message[%d] to carry the captured SCN", i)
}
})

t.Run("ThreeColumnCompositeKey_WithFilterPagination", func(t *testing.T) {
// Offset col1 so these rows occupy a disjoint key range from
// ThreeColumnCompositeKey_FullScan, which shares this table and is never
// truncated between subtests.
const col1Offset = 100

var totalRows int
for i := range 5 {
for j := range 3 {
for k := range 4 {
totalRows++
db.MustExec("INSERT INTO TESTDB.three_col_key_test (col1, col2, col3, data) VALUES (:1, :2, :3, :4)", col1Offset+i, j, k, "test-data")
}
}
}

publisher := &publisherStub{}
tables := []replication.UserTable{
{Schema: "TESTDB", Name: "THREE_COL_KEY_TEST"},
}
filters := map[string]string{
"TESTDB.THREE_COL_KEY_TEST": fmt.Sprintf("SELECT col1, col2, col3, data FROM TESTDB.THREE_COL_KEY_TEST WHERE col1 >= %d", col1Offset),
}

snapshot, err := replication.NewSnapshot(t.Context(), connStr, tables, filters, publisher, false, "", service.NewLoggerFromSlog(log), service.MockResources().Metrics())
require.NoError(t, err)
defer snapshot.Close()

scn, err := snapshot.Prepare(t.Context())
require.NoError(t, err)
require.NotZero(t, scn)

// A snapshot filter forces the PK-keyset pagination path, exercising the
// composite-key lexicographic WHERE/ORDER BY construction in querySnapshotTable.
err = snapshot.Read(t.Context(), 1, 8)
require.NoError(t, err)

Expand Down
Loading