Skip to content

Commit f908287

Browse files
oracledb_cdc: Add support for snapshot_only mode, deprecating stream_snapshot config (#4570)
1 parent 2789240 commit f908287

8 files changed

Lines changed: 188 additions & 39 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,4 @@ __pycache__
1717
*.test.exe
1818
compile_out.txt
1919
test_output.txt
20+
benchmark_results.json

docs/modules/components/pages/inputs/oracledb_cdc.adoc

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ input:
4242
connection_string: oracle://username:password@host:port/service_name # No default (required)
4343
wallet_path: /opt/oracle/wallet # No default (optional)
4444
wallet_password: "" # No default (optional)
45-
stream_snapshot: false
45+
snapshot_mode: "" # No default (optional)
4646
max_parallel_snapshot_tables: 1
4747
snapshot_max_batch_size: 1000
4848
logminer:
@@ -84,7 +84,7 @@ input:
8484
connection_string: oracle://username:password@host:port/service_name # No default (required)
8585
wallet_path: /opt/oracle/wallet # No default (optional)
8686
wallet_password: "" # No default (optional)
87-
stream_snapshot: false
87+
snapshot_mode: "" # No default (optional)
8888
max_parallel_snapshot_tables: 1
8989
snapshot_max_batch_size: 1000
9090
logminer:
@@ -183,20 +183,20 @@ This field contains sensitive information that usually shouldn't be added to a c
183183
*Type*: `string`
184184
185185
186-
=== `stream_snapshot`
186+
=== `snapshot_mode`
187187
188-
If set to true, the connector will query all the existing data as a part of snapshot process. Otherwise, it will start from the current System Change Number position.
188+
Controls snapshot behaviour. `none` (default) skips snapshotting and starts streaming from the current SCN. `snapshot_only` performs a full snapshot, persists the SCN checkpoint, then stops without streaming. `snapshot_and_stream` performs a full snapshot then transitions to streaming.
189189
190190
191-
*Type*: `bool`
192-
193-
*Default*: `false`
191+
*Type*: `string`
194192
195-
```yml
196-
# Examples
193+
Requires version 4.99.0 or newer
197194
198-
stream_snapshot: true
199-
```
195+
Options:
196+
`none`
197+
, `snapshot_only`
198+
, `snapshot_and_stream`
199+
.
200200
201201
=== `max_parallel_snapshot_tables`
202202

internal/impl/oracledb/batcher.go

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,7 @@ func newBatchPublisher(batcher *service.Batcher, checkpoint *checkpoint.Capped[r
5454
// loop creates a long-running process that periodically flushes batches by configured interval.
5555
// lifted from internal/impl/kafka/franz_reader_ordered.go
5656
func (p *batchPublisher) loop() {
57-
defer func() {
58-
if p.batcher != nil {
59-
p.batcher.Close(context.Background())
60-
}
61-
p.shutSig.TriggerHasStopped()
62-
}()
57+
defer p.shutSig.TriggerHasStopped()
6358

6459
// No need to loop when there's no batcher for async writes.
6560
if p.batcher == nil {
@@ -90,7 +85,10 @@ func (p *batchPublisher) loop() {
9085
flushBatch = flushBatchTicker.C
9186
}
9287

93-
closeAtLeisureCtx, done := p.shutSig.SoftStopCtx(context.Background())
88+
// hardStopCtx survives a soft stop so that an in-flight publishBatch send can
89+
// complete before the loop exits. Only a hard stop (triggered by Close)
90+
// cancels it, which is the forced-shutdown last resort.
91+
hardStopCtx, done := p.shutSig.HardStopCtx(context.Background())
9492
defer done()
9593

9694
for {
@@ -112,13 +110,13 @@ func (p *batchPublisher) loop() {
112110
return
113111
}
114112

115-
if sendBatch, _ = p.batcher.Flush(closeAtLeisureCtx); len(sendBatch) == 0 {
113+
if sendBatch, _ = p.batcher.Flush(hardStopCtx); len(sendBatch) == 0 {
116114
return
117115
}
118116
}()
119117

120118
if len(sendBatch) > 0 {
121-
if err := p.publishBatch(closeAtLeisureCtx, sendBatch); err != nil {
119+
if err := p.publishBatch(hardStopCtx, sendBatch); err != nil {
122120
return
123121
}
124122
}
@@ -275,8 +273,32 @@ func (b *batchPublisher) msgs() <-chan asyncMessage {
275273
return b.msgChan
276274
}
277275

276+
// FlushRemaining stops the loop goroutine and then flushes any partial batch
277+
// still held in the batcher, blocking until it is consumed by ReadBatch.
278+
func (b *batchPublisher) FlushRemaining(ctx context.Context) error {
279+
if b.batcher == nil {
280+
return nil
281+
}
282+
b.shutSig.TriggerSoftStop()
283+
<-b.shutSig.HasStoppedChan()
284+
285+
b.batcherMu.Lock()
286+
remaining, err := b.batcher.Flush(ctx)
287+
b.batcherMu.Unlock()
288+
if err != nil || len(remaining) == 0 {
289+
return err
290+
}
291+
return b.publishBatch(ctx, remaining)
292+
}
293+
278294
// Close signals the publisher's loop goroutine to stop and waits for it to exit.
295+
// TriggerHardStop cancels the HardStopCtx used by publishBatch, unblocking any
296+
// send that is waiting on msgChan when no consumer is left.
279297
func (b *batchPublisher) Close() {
280298
b.shutSig.TriggerSoftStop()
299+
b.shutSig.TriggerHardStop()
281300
<-b.shutSig.HasStoppedChan()
301+
if b.batcher != nil {
302+
_ = b.batcher.Close(context.Background())
303+
}
282304
}

internal/impl/oracledb/bench/benchmark_config.yaml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,9 @@ input:
66
# connection_string: oracle://testdb:testdb123@localhost:1521/TESTPDB
77
connection_string: oracle://c%23%23testdb:testdb123@localhost:1521/FREE
88
pdb_name: TESTPDB
9-
stream_snapshot: false
9+
snapshot_mode: snapshot_only
1010
snapshot_max_batch_size: 160000
1111
logminer:
12-
scn_window_size: 190000
1312
backoff_interval: 2s
1413
mining_interval: 0s
1514
# transaction_cache: testcache
@@ -19,7 +18,6 @@ input:
1918
- TESTDB.PRODUCTS
2019
- TESTDB.CART
2120
batching:
22-
count: 140000
2321
period: 1s
2422

2523
output:

internal/impl/oracledb/config.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,3 +102,42 @@ func parseWalletConfig(conf *service.ParsedConfig, overrides map[string]string)
102102

103103
return nil
104104
}
105+
106+
// SnapshotMode controls whether and how an initial table snapshot is taken before streaming begins.
107+
type SnapshotMode string
108+
109+
const (
110+
// SnapshotModeNone skips snapshotting and starts streaming from the current SCN.
111+
SnapshotModeNone SnapshotMode = "none"
112+
// SnapshotModeSnapshotOnly performs a full snapshot, persists the SCN checkpoint, then stops without streaming.
113+
SnapshotModeSnapshotOnly SnapshotMode = "snapshot_only"
114+
// SnapshotModeSnapshotAndStream performs a full snapshot then transitions to streaming.
115+
SnapshotModeSnapshotAndStream SnapshotMode = "snapshot_and_stream"
116+
)
117+
118+
// IsSnapshotOnly returns true if the snapshot_mode config is snapshot_only, otherwise false.
119+
func (s SnapshotMode) IsSnapshotOnly() bool {
120+
return s == SnapshotModeSnapshotOnly
121+
}
122+
123+
// IsSnapshotNone returns true if the snapshot_mode config is none, otherwise false.
124+
func (s SnapshotMode) IsSnapshotNone() bool {
125+
return s == SnapshotModeNone
126+
}
127+
128+
func parseSnapshotMode(conf *service.ParsedConfig) (SnapshotMode, error) {
129+
if conf.Contains(ociFieldSnapshotMode) {
130+
if raw, err := conf.FieldString(ociFieldSnapshotMode); err != nil {
131+
return SnapshotModeNone, err
132+
} else {
133+
return SnapshotMode(raw), nil
134+
}
135+
}
136+
// snapshot_mode not set — apply backward compat
137+
if streamSnapshot, err := conf.FieldBool(ociFieldStreamSnapshot); err != nil {
138+
return SnapshotModeNone, err
139+
} else if streamSnapshot {
140+
return SnapshotModeSnapshotAndStream, nil
141+
}
142+
return SnapshotModeNone, nil
143+
}

internal/impl/oracledb/config_test.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,3 +164,65 @@ func TestBuildConnectionURL(t *testing.T) {
164164
})
165165
}
166166
}
167+
168+
func TestParseSnapshotMode(t *testing.T) {
169+
const minimalOracleCDCYAML = `connection_string: oracle://user:pass@host:1521/svc
170+
include:
171+
- SCHEMA.TABLE
172+
logminer: {}
173+
`
174+
tests := []struct {
175+
name string
176+
yaml string
177+
want SnapshotMode
178+
}{
179+
{
180+
name: "omitted defaults to none",
181+
yaml: minimalOracleCDCYAML,
182+
want: SnapshotModeNone,
183+
},
184+
{
185+
name: "explicit none",
186+
yaml: minimalOracleCDCYAML + "snapshot_mode: none\n",
187+
want: SnapshotModeNone,
188+
},
189+
{
190+
name: "snapshot_only",
191+
yaml: minimalOracleCDCYAML + "snapshot_mode: snapshot_only\n",
192+
want: SnapshotModeSnapshotOnly,
193+
},
194+
{
195+
name: "snapshot_and_stream",
196+
yaml: minimalOracleCDCYAML + "snapshot_mode: snapshot_and_stream\n",
197+
want: SnapshotModeSnapshotAndStream,
198+
},
199+
{
200+
// backward compat: stream_snapshot: true with no snapshot_mode set
201+
name: "stream_snapshot true upgrades to snapshot_and_stream",
202+
yaml: minimalOracleCDCYAML + "stream_snapshot: true\n",
203+
want: SnapshotModeSnapshotAndStream,
204+
},
205+
{
206+
// explicit snapshot_mode: none must win over stream_snapshot: true
207+
name: "explicit snapshot_mode none overrides stream_snapshot true",
208+
yaml: minimalOracleCDCYAML + "snapshot_mode: none\nstream_snapshot: true\n",
209+
want: SnapshotModeNone,
210+
},
211+
{
212+
// explicit snapshot_mode wins over stream_snapshot: true
213+
name: "explicit snapshot_mode snapshot_only overrides stream_snapshot true",
214+
yaml: minimalOracleCDCYAML + "snapshot_mode: snapshot_only\nstream_snapshot: true\n",
215+
want: SnapshotModeSnapshotOnly,
216+
},
217+
}
218+
219+
for _, tt := range tests {
220+
t.Run(tt.name, func(t *testing.T) {
221+
conf, err := oracleDBStreamConfigSpec.ParseYAML(tt.yaml, nil)
222+
require.NoError(t, err)
223+
got, err := parseSnapshotMode(conf)
224+
require.NoError(t, err)
225+
assert.Equal(t, tt.want, got)
226+
})
227+
}
228+
}

internal/impl/oracledb/input_oracledb_cdc.go

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"regexp"
1717
"strconv"
1818
"strings"
19+
"sync/atomic"
1920
"time"
2021

2122
"github.com/Jeffail/checkpoint"
@@ -45,6 +46,7 @@ const (
4546
ociFieldCheckpointCacheTableName = "checkpoint_cache_table_name"
4647
ociFieldBatching = "batching"
4748
ociFieldPDBName = "pdb_name"
49+
ociFieldSnapshotMode = "snapshot_mode"
4850

4951
shutdownTimeout = 5 * time.Second
5052

@@ -108,7 +110,16 @@ When using the default Oracle based cache, the Connect user requires permission
108110
Field(service.NewBoolField(ociFieldStreamSnapshot).
109111
Description("If set to true, the connector will query all the existing data as a part of snapshot process. Otherwise, it will start from the current System Change Number position.").
110112
Example(true).
111-
Default(false),
113+
Default(false).
114+
Deprecated(),
115+
).
116+
Field(service.NewStringEnumField(ociFieldSnapshotMode,
117+
string(SnapshotModeNone),
118+
string(SnapshotModeSnapshotOnly),
119+
string(SnapshotModeSnapshotAndStream)).
120+
Description("Controls snapshot behaviour. `none` (default) skips snapshotting and starts streaming from the current SCN. `snapshot_only` performs a full snapshot, persists the SCN checkpoint, then stops without streaming. `snapshot_and_stream` performs a full snapshot then transitions to streaming.").
121+
Optional().
122+
Version("4.99.0"),
112123
).
113124
Field(service.NewIntField(ociFieldMaxParallelSnapshotTables).
114125
Description("Specifies a number of tables that will be processed in parallel during the snapshot processing stage.").
@@ -202,7 +213,7 @@ type asyncMessage struct {
202213
// Config is the configuration for a Oracle connector.
203214
type Config struct {
204215
ConnectionString string
205-
StreamSnapshot bool
216+
SnapshotMode SnapshotMode
206217
SnapshotMaxBatchSize int
207218
SnapshotMaxWorkers int
208219
TablesFilter *confx.RegexpFilter
@@ -221,15 +232,16 @@ type oracleDBCDCInput struct {
221232
publisher *batchPublisher
222233
metrics *service.Metrics
223234

224-
stopSig *shutdown.Signaller
225-
log *service.Logger
226-
cpCache service.Cache
235+
stopSig *shutdown.Signaller
236+
snapshotOnlyDone atomic.Bool
237+
log *service.Logger
238+
cpCache service.Cache
227239
}
228240

229241
func newOracleDBCDCInput(conf *service.ParsedConfig, resources *service.Resources) (s service.BatchInput, err error) {
230242
var (
231243
connectionString string
232-
streamSnapshot bool
244+
snapshotMode SnapshotMode
233245
snapshotMaxWorkers int
234246
snapshotMaxBatchSize int
235247

@@ -250,7 +262,7 @@ func newOracleDBCDCInput(conf *service.ParsedConfig, resources *service.Resource
250262
if connectionString, err = conf.FieldString(ociFieldConnectionString); err != nil {
251263
return nil, err
252264
}
253-
if streamSnapshot, err = conf.FieldBool(ociFieldStreamSnapshot); err != nil {
265+
if snapshotMode, err = parseSnapshotMode(conf); err != nil {
254266
return nil, err
255267
}
256268
if snapshotMaxWorkers, err = conf.FieldInt(ociFieldMaxParallelSnapshotTables); err != nil {
@@ -335,7 +347,7 @@ func newOracleDBCDCInput(conf *service.ParsedConfig, resources *service.Resource
335347
o := oracleDBCDCInput{
336348
cfg: Config{
337349
ConnectionString: connectionString,
338-
StreamSnapshot: streamSnapshot,
350+
SnapshotMode: snapshotMode,
339351
SnapshotMaxWorkers: snapshotMaxWorkers,
340352
SnapshotMaxBatchSize: snapshotMaxBatchSize,
341353
SCNCache: scnCache,
@@ -501,7 +513,7 @@ func (o *oracleDBCDCInput) Connect(ctx context.Context) (resErr error) {
501513
)
502514

503515
// no cached SCN means we're not recovering from a restart
504-
if o.cfg.StreamSnapshot && cachedSCN == replication.InvalidSCN {
516+
if !o.cfg.SnapshotMode.IsSnapshotNone() && cachedSCN == replication.InvalidSCN {
505517
if snapshotter, err = replication.NewSnapshot(ctx, o.cfg.ConnectionString, userTables, o.publisher, o.lmCfg.LOBEnabled, pdbNameForCache, o.log, o.metrics); err != nil {
506518
return fmt.Errorf("creating database snapshotter: %w", err)
507519
}
@@ -531,8 +543,9 @@ func (o *oracleDBCDCInput) Connect(ctx context.Context) (resErr error) {
531543
o.log.Infof("No cached SCN found, fetched current position from database: %d", cachedSCN)
532544
}
533545

534-
// Reset our stop signal
546+
// Reset our stop signal and snapshot-only completion flag
535547
o.stopSig = shutdown.NewSignaller()
548+
o.snapshotOnlyDone.Store(false)
536549

537550
go func() {
538551
var (
@@ -563,6 +576,16 @@ func (o *oracleDBCDCInput) Connect(ctx context.Context) (resErr error) {
563576
o.log.Infof("Successfully captured SCN following snapshot: %d", startSCN)
564577
}
565578

579+
if o.cfg.SnapshotMode.IsSnapshotOnly() {
580+
if err = o.publisher.FlushRemaining(softCtx); err != nil {
581+
o.log.Errorf("Failed to flush remaining snapshot events: %s", err)
582+
}
583+
o.log.Infof("Snapshot-only mode complete, stopping at SCN %s", startSCN)
584+
o.snapshotOnlyDone.Store(true)
585+
o.stopSig.TriggerHasStopped()
586+
return
587+
}
588+
566589
// streaming
567590
wg, _ := errgroup.WithContext(softCtx)
568591
wg.Go(func() error {
@@ -644,6 +667,9 @@ func (o *oracleDBCDCInput) ReadBatch(ctx context.Context) (service.MessageBatch,
644667
case m := <-o.publisher.msgs():
645668
return m.msg, m.ackFn, nil
646669
case <-o.stopSig.HasStoppedChan():
670+
if o.snapshotOnlyDone.Load() {
671+
return nil, nil, service.ErrEndOfInput
672+
}
647673
return nil, nil, service.ErrNotConnected
648674
case <-ctx.Done():
649675
return nil, nil, ctx.Err()

0 commit comments

Comments
 (0)