Skip to content

Commit ad4d109

Browse files
craig[bot]elizaMkraule
andcommitted
Merge #150501
150501: changefeedccl: add protobuf encoder support with envelope=enriched r=asg0451 a=elizaMkraule This change adds support for enriched envelopes when using protobuf format Fixes: #150499 Release note (general change): The protobuf format for changefeeds now support enriched envelopes. Co-authored-by: Eliza Kraule <[email protected]>
2 parents c17d29b + 1d2766d commit ad4d109

File tree

9 files changed

+223
-36
lines changed

9 files changed

+223
-36
lines changed

pkg/ccl/changefeedccl/changefeed_test.go

Lines changed: 64 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12218,13 +12218,13 @@ func TestChangefeedProtobuf(t *testing.T) {
1221812218
type testCase struct {
1221912219
envelope string
1222012220
withDiff bool
12221+
withSource bool
1222112222
expectedRows []string
1222212223
}
1222312224

1222412225
tests := []testCase{
1222512226
{
1222612227
envelope: "bare",
12227-
withDiff: false,
1222812228
expectedRows: []string{
1222912229
`pricing: {"id":1}->{"values":{"discount":15.75,"id":1,"name":"Chair","options":["Brown","Black"],"tax":"2.500"},"__crdb__":{"key":{"id":1},"topic":"pricing"}}`,
1223012230
`pricing: {"id":2}->{"values":{"discount":20,"id":2,"name":"Table","options":["Brown","Black"],"tax":"1.23456789"},"__crdb__":{"key":{"id":2},"topic":"pricing"}}`,
@@ -12238,12 +12238,48 @@ func TestChangefeedProtobuf(t *testing.T) {
1223812238
envelope: "wrapped",
1223912239
withDiff: true,
1224012240
expectedRows: []string{
12241-
`pricing: {"id":1}->{"after":{"values":{"discount":15.75,"id":1,"name":"Chair","options":["Brown","Black"],"tax":"2.500"}},"before":{},"key":{"id":1},"topic":"pricing"}`,
12242-
`pricing: {"id":2}->{"after":{"values":{"discount":20,"id":2,"name":"Table","options":["Brown","Black"],"tax":"1.23456789"}},"before":{},"key":{"id":2},"topic":"pricing"}`,
12241+
`pricing: {"id":1}->{"after":{"values":{"discount":15.75,"id":1,"name":"Chair","options":["Brown","Black"],"tax":"2.500"}},"key":{"id":1},"topic":"pricing"}`,
12242+
`pricing: {"id":2}->{"after":{"values":{"discount":20,"id":2,"name":"Table","options":["Brown","Black"],"tax":"1.23456789"}},"key":{"id":2},"topic":"pricing"}`,
1224312243
`pricing: {"id":2}->{"after":{"values":{"discount":25.5,"id":2,"name":"Table","options":["Brown","Black"],"tax":"1.23456789"}},"before":{"values":{"discount":20,"id":2,"name":"Table","options":["Brown","Black"],"tax":"1.23456789"}},"key":{"id":2},"topic":"pricing"}`,
1224412244
`pricing: {"id":1}->{"after":{"values":{"discount":10,"id":1,"name":"Armchair","options":["Red"],"tax":"1.000"}},"before":{"values":{"discount":15.75,"id":1,"name":"Chair","options":["Brown","Black"],"tax":"2.500"}},"key":{"id":1},"topic":"pricing"}`,
12245-
`pricing: {"id":3}->{"after":{"values":{"discount":50,"id":3,"name":"Sofa","options":["Gray"],"tax":"4.250"}},"before":{},"key":{"id":3},"topic":"pricing"}`,
12246-
`pricing: {"id":2}->{"after":{},"before":{"values":{"discount":25.5,"id":2,"name":"Table","options":["Brown","Black"],"tax":"1.23456789"}},"key":{"id":2},"topic":"pricing"}`,
12245+
`pricing: {"id":3}->{"after":{"values":{"discount":50,"id":3,"name":"Sofa","options":["Gray"],"tax":"4.250"}},"key":{"id":3},"topic":"pricing"}`,
12246+
`pricing: {"id":2}->{"before":{"values":{"discount":25.5,"id":2,"name":"Table","options":["Brown","Black"],"tax":"1.23456789"}},"key":{"id":2},"topic":"pricing"}`,
12247+
},
12248+
},
12249+
{
12250+
envelope: "wrapped",
12251+
expectedRows: []string{
12252+
`pricing: {"id":1}->{"after":{"values":{"discount":15.75,"id":1,"name":"Chair","options":["Brown","Black"],"tax":"2.500"}},"key":{"id":1},"topic":"pricing"}`,
12253+
`pricing: {"id":2}->{"after":{"values":{"discount":20,"id":2,"name":"Table","options":["Brown","Black"],"tax":"1.23456789"}},"key":{"id":2},"topic":"pricing"}`,
12254+
`pricing: {"id":2}->{"after":{"values":{"discount":25.5,"id":2,"name":"Table","options":["Brown","Black"],"tax":"1.23456789"}},"key":{"id":2},"topic":"pricing"}`,
12255+
`pricing: {"id":1}->{"after":{"values":{"discount":10,"id":1,"name":"Armchair","options":["Red"],"tax":"1.000"}},"key":{"id":1},"topic":"pricing"}`,
12256+
`pricing: {"id":3}->{"after":{"values":{"discount":50,"id":3,"name":"Sofa","options":["Gray"],"tax":"4.250"}},"key":{"id":3},"topic":"pricing"}`,
12257+
`pricing: {"id":2}->{"key":{"id":2},"topic":"pricing"}`,
12258+
},
12259+
},
12260+
{
12261+
envelope: "enriched",
12262+
withDiff: true,
12263+
withSource: true,
12264+
expectedRows: []string{
12265+
`pricing: {"id":1}->{"after": {"values": {"discount": 10, "id": 1, "name": "Armchair", "options": ["Red"], "tax": "1.000"}}, "before": {"values": {"discount": 15.75, "id": 1, "name": "Chair", "options": ["Brown", "Black"], "tax": "2.500"}}, "key": {"id": 1}, "op": 2}`,
12266+
`pricing: {"id":1}->{"after": {"values": {"discount": 15.75, "id": 1, "name": "Chair", "options": ["Brown", "Black"], "tax": "2.500"}}, "key": {"id": 1}, "op": 1}`,
12267+
`pricing: {"id":2}->{"after": {"values": {"discount": 20, "id": 2, "name": "Table", "options": ["Brown", "Black"], "tax": "1.23456789"}}, "key": {"id": 2}, "op": 1}`,
12268+
`pricing: {"id":2}->{"after": {"values": {"discount": 25.5, "id": 2, "name": "Table", "options": ["Brown", "Black"], "tax": "1.23456789"}}, "before": {"values": {"discount": 20, "id": 2, "name": "Table", "options": ["Brown", "Black"], "tax": "1.23456789"}}, "key": {"id": 2}, "op": 2}`,
12269+
`pricing: {"id":2}->{"before": {"values": {"discount": 25.5, "id": 2, "name": "Table", "options": ["Brown", "Black"], "tax": "1.23456789"}}, "key": {"id": 2}, "op": 3}`,
12270+
`pricing: {"id":3}->{"after": {"values": {"discount": 50, "id": 3, "name": "Sofa", "options": ["Gray"], "tax": "4.250"}}, "key": {"id": 3}, "op": 1}`,
12271+
},
12272+
},
12273+
{
12274+
envelope: "enriched",
12275+
withDiff: true,
12276+
expectedRows: []string{
12277+
`pricing: {"id":1}->{"after": {"values": {"discount": 10, "id": 1, "name": "Armchair", "options": ["Red"], "tax": "1.000"}}, "before": {"values": {"discount": 15.75, "id": 1, "name": "Chair", "options": ["Brown", "Black"], "tax": "2.500"}}, "key": {"id": 1}, "op": 2}`,
12278+
`pricing: {"id":1}->{"after": {"values": {"discount": 15.75, "id": 1, "name": "Chair", "options": ["Brown", "Black"], "tax": "2.500"}}, "key": {"id": 1}, "op": 1}`,
12279+
`pricing: {"id":2}->{"after": {"values": {"discount": 20, "id": 2, "name": "Table", "options": ["Brown", "Black"], "tax": "1.23456789"}}, "key": {"id": 2}, "op": 1}`,
12280+
`pricing: {"id":2}->{"after": {"values": {"discount": 25.5, "id": 2, "name": "Table", "options": ["Brown", "Black"], "tax": "1.23456789"}}, "before": {"values": {"discount": 20, "id": 2, "name": "Table", "options": ["Brown", "Black"], "tax": "1.23456789"}}, "key": {"id": 2}, "op": 2}`,
12281+
`pricing: {"id":2}->{"before": {"values": {"discount": 25.5, "id": 2, "name": "Table", "options": ["Brown", "Black"], "tax": "1.23456789"}}, "key": {"id": 2}, "op": 3}`,
12282+
`pricing: {"id":3}->{"after": {"values": {"discount": 50, "id": 3, "name": "Sofa", "options": ["Gray"], "tax": "4.250"}}, "key": {"id": 3}, "op": 1}`,
1224712283
},
1224812284
},
1224912285
}
@@ -12272,7 +12308,9 @@ func TestChangefeedProtobuf(t *testing.T) {
1227212308
if tc.withDiff {
1227312309
opts = append(opts, "diff")
1227412310
}
12275-
12311+
if tc.withSource {
12312+
opts = append(opts, "enriched_properties='source'")
12313+
}
1227612314
feed := feed(t, f, fmt.Sprintf("CREATE CHANGEFEED FOR pricing WITH %s", strings.Join(opts, ", ")))
1227712315
defer closeFeed(t, feed)
1227812316

@@ -12281,7 +12319,26 @@ func TestChangefeedProtobuf(t *testing.T) {
1228112319
sqlDB.Exec(t, `INSERT INTO pricing VALUES (3, 'Sofa', 50.00, 4.250, ARRAY['Gray'])`)
1228212320
sqlDB.Exec(t, `DELETE FROM pricing WHERE id = 2`)
1228312321

12284-
assertPayloads(t, feed, tc.expectedRows)
12322+
if tc.envelope == "enriched" {
12323+
sourceAssertion := func(source map[string]any) {
12324+
if tc.withSource {
12325+
require.NotNil(t, source)
12326+
require.Equal(t, "kafka", source["changefeed_sink"])
12327+
require.Equal(t, "d", source["database_name"])
12328+
require.Equal(t, "public", source["schema_name"])
12329+
require.Equal(t, "pricing", source["table_name"])
12330+
require.Equal(t, "cockroachdb", source["origin"])
12331+
require.ElementsMatch(t, []any{"id"}, source["primary_keys"].([]any))
12332+
} else {
12333+
require.Nil(t, source)
12334+
}
12335+
}
12336+
assertPayloadsEnriched(t, feed, tc.expectedRows, sourceAssertion)
12337+
} else {
12338+
12339+
assertPayloads(t, feed, tc.expectedRows)
12340+
}
12341+
1228512342
}
1228612343
cdcTest(t, testFn, feedTestForceSink("kafka"))
1228712344
})

pkg/ccl/changefeedccl/changefeedbase/options.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -935,8 +935,8 @@ func (e EncodingOptions) Validate() error {
935935
}
936936

937937
if e.Envelope == OptEnvelopeEnriched {
938-
if e.Format != OptFormatJSON && e.Format != OptFormatAvro {
939-
return errors.Errorf(`%s=%s is only usable with %s=%s/%s`, OptEnvelope, OptEnvelopeEnriched, OptFormat, OptFormatJSON, OptFormatAvro)
938+
if e.Format != OptFormatJSON && e.Format != OptFormatAvro && e.Format != OptFormatProtobuf {
939+
return errors.Errorf(`%s=%s is only usable with %s=%s/%s/%s`, OptEnvelope, OptEnvelopeEnriched, OptFormat, OptFormatJSON, OptFormatAvro, OptFormatProtobuf)
940940
}
941941
} else {
942942
if len(e.EnrichedProperties) > 0 {

pkg/ccl/changefeedccl/changefeedpb/changefeed.proto

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,9 @@ message EnrichedEnvelope {
6464
Record after = 1;
6565
Record before = 2;
6666
Op op = 3;
67-
int64 ts_ns = 4;
68-
EnrichedSource source = 5;
67+
Key key = 4;
68+
int64 ts_ns = 5;
69+
EnrichedSource source = 6;
6970
}
7071

7172
// Resolved carries resolved timestamp information for a changefeed span.

pkg/ccl/changefeedccl/encoder.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ func getEncoder(
6363
//information on why this was needed.
6464
return nil, nil
6565
case changefeedbase.OptFormatProtobuf:
66-
return newProtobufEncoder(ctx, protobufEncoderOptions{EncodingOptions: opts}, targets), nil
66+
return newProtobufEncoder(ctx, protobufEncoderOptions{EncodingOptions: opts}, targets, sourceProvider), nil
6767
default:
6868
return nil, errors.AssertionFailedf(`unknown format: %s`, opts.Format)
6969
}

pkg/ccl/changefeedccl/encoder_protobuf.go

Lines changed: 89 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,21 @@ import (
1717
"github.com/cockroachdb/cockroach/pkg/sql/sessiondatapb"
1818
"github.com/cockroachdb/cockroach/pkg/util/hlc"
1919
"github.com/cockroachdb/cockroach/pkg/util/protoutil"
20+
"github.com/cockroachdb/cockroach/pkg/util/timeutil"
2021
"github.com/cockroachdb/errors"
2122
"github.com/gogo/protobuf/types"
2223
)
2324

2425
type protobufEncoder struct {
25-
updatedField bool
26-
mvccTimestampField bool
27-
beforeField bool
28-
keyInValue bool
29-
topicInValue bool
30-
envelopeType changefeedbase.EnvelopeType
31-
targets changefeedbase.Targets
26+
updatedField bool
27+
mvccTimestampField bool
28+
beforeField bool
29+
keyInValue bool
30+
topicInValue bool
31+
sourceField bool
32+
envelopeType changefeedbase.EnvelopeType
33+
targets changefeedbase.Targets
34+
enrichedEnvelopeSourceProvider *enrichedSourceProvider
3235
}
3336

3437
// protobufEncoderOptions wraps EncodingOptions for initializing a protobufEncoder.
@@ -40,16 +43,21 @@ var _ Encoder = &protobufEncoder{}
4043

4144
// newProtobufEncoder constructs a new protobufEncoder from the given options and targets.
4245
func newProtobufEncoder(
43-
ctx context.Context, opts protobufEncoderOptions, targets changefeedbase.Targets,
46+
ctx context.Context,
47+
opts protobufEncoderOptions,
48+
targets changefeedbase.Targets,
49+
sourceProvider *enrichedSourceProvider,
4450
) Encoder {
4551
return &protobufEncoder{
46-
envelopeType: opts.Envelope,
47-
keyInValue: opts.KeyInValue,
48-
topicInValue: opts.TopicInValue,
49-
beforeField: opts.Diff,
50-
updatedField: opts.UpdatedTimestamps,
51-
mvccTimestampField: opts.MVCCTimestamps,
52-
targets: targets,
52+
envelopeType: opts.Envelope,
53+
keyInValue: opts.KeyInValue,
54+
topicInValue: opts.TopicInValue,
55+
beforeField: opts.Diff,
56+
updatedField: opts.UpdatedTimestamps,
57+
mvccTimestampField: opts.MVCCTimestamps,
58+
sourceField: inSet(changefeedbase.EnrichedPropertySource, opts.EnrichedProperties),
59+
targets: targets,
60+
enrichedEnvelopeSourceProvider: sourceProvider,
5361
}
5462
}
5563

@@ -71,11 +79,64 @@ func (e *protobufEncoder) EncodeValue(
7179
return e.buildBare(evCtx, updatedRow, prevRow)
7280
case changefeedbase.OptEnvelopeWrapped:
7381
return e.buildWrapped(ctx, evCtx, updatedRow, prevRow)
82+
case changefeedbase.OptEnvelopeEnriched:
83+
return e.buildEnriched(ctx, evCtx, updatedRow, prevRow)
7484
default:
7585
return nil, errors.AssertionFailedf("envelope format not supported: %s", e.envelopeType)
7686
}
7787
}
7888

89+
func (e *protobufEncoder) buildEnriched(
90+
ctx context.Context, evCtx eventContext, updatedRow cdcevent.Row, prevRow cdcevent.Row,
91+
) ([]byte, error) {
92+
var after *changefeedpb.Record
93+
var err error
94+
if updatedRow.IsInitialized() && !updatedRow.IsDeleted() {
95+
after, err = encodeRowToRecord(updatedRow)
96+
if err != nil {
97+
return nil, err
98+
}
99+
}
100+
101+
var before *changefeedpb.Record
102+
if e.beforeField {
103+
if prevRow.IsInitialized() && !prevRow.IsDeleted() {
104+
before, err = encodeRowToRecord(prevRow)
105+
if err != nil {
106+
return nil, err
107+
}
108+
}
109+
}
110+
var keyMsg *changefeedpb.Key
111+
if e.keyInValue {
112+
keyMsg, err = buildKeyMessage(updatedRow)
113+
if err != nil {
114+
return nil, err
115+
}
116+
}
117+
var src *changefeedpb.EnrichedSource
118+
if e.sourceField {
119+
src, err = e.enrichedEnvelopeSourceProvider.GetProtobuf(evCtx, updatedRow, prevRow)
120+
if err != nil {
121+
return nil, err
122+
}
123+
}
124+
125+
enriched := &changefeedpb.EnrichedEnvelope{
126+
After: after,
127+
Before: before,
128+
Key: keyMsg,
129+
TsNs: timeutil.Now().UnixNano(),
130+
Op: inferOp(updatedRow, prevRow),
131+
Source: src,
132+
}
133+
134+
env := &changefeedpb.Message{
135+
Data: &changefeedpb.Message_Enriched{Enriched: enriched},
136+
}
137+
return protoutil.Marshal(env)
138+
}
139+
79140
// EncodeResolvedTimestamp encodes a resolved timestamp message for the specified topic.
80141
func (e *protobufEncoder) EncodeResolvedTimestamp(
81142
ctx context.Context, topic string, ts hlc.Timestamp,
@@ -140,8 +201,6 @@ func (e *protobufEncoder) buildWrapped(
140201
if err != nil {
141202
return nil, err
142203
}
143-
} else {
144-
after = &changefeedpb.Record{}
145204
}
146205

147206
var before *changefeedpb.Record
@@ -151,8 +210,6 @@ func (e *protobufEncoder) buildWrapped(
151210
if err != nil {
152211
return nil, err
153212
}
154-
} else {
155-
before = &changefeedpb.Record{}
156213
}
157214
}
158215
var keyMsg *changefeedpb.Key
@@ -256,6 +313,19 @@ func buildKeyMessage(row cdcevent.Row) (*changefeedpb.Key, error) {
256313
return &changefeedpb.Key{Key: keyMap}, nil
257314
}
258315

316+
func inferOp(updated, prev cdcevent.Row) changefeedpb.Op {
317+
switch deduceOp(updated, prev) {
318+
case eventTypeCreate:
319+
return changefeedpb.Op_OP_CREATE
320+
case eventTypeUpdate:
321+
return changefeedpb.Op_OP_UPDATE
322+
case eventTypeDelete:
323+
return changefeedpb.Op_OP_DELETE
324+
default:
325+
return changefeedpb.Op_OP_UNSPECIFIED
326+
}
327+
}
328+
259329
// datumToProtoValue converts a tree.Datum into a changefeedpb.Value.
260330
// It handles all common CockroachDB datum types and maps them to their
261331
// corresponding protobuf representation.

pkg/ccl/changefeedccl/encoder_protobuf_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ func Test_ProtoEncoderAllTypes(t *testing.T) {
163163
}, false)
164164

165165
opts := changefeedbase.EncodingOptions{Envelope: changefeedbase.OptEnvelopeBare}
166-
enc := newProtobufEncoder(ctx, protobufEncoderOptions{EncodingOptions: opts}, targets)
166+
enc := newProtobufEncoder(ctx, protobufEncoderOptions{EncodingOptions: opts}, targets, nil)
167167

168168
evCtx := eventContext{updated: hlc.Timestamp{WallTime: 42}}
169169
valBytes, err := enc.EncodeValue(ctx, evCtx, row, cdcevent.Row{})
@@ -440,7 +440,7 @@ func Test_ProtoEncoder_Escaping(t *testing.T) {
440440
}, false)
441441

442442
opts := changefeedbase.EncodingOptions{Envelope: changefeedbase.OptEnvelopeBare}
443-
enc := newProtobufEncoder(ctx, protobufEncoderOptions{EncodingOptions: opts}, targets)
443+
enc := newProtobufEncoder(ctx, protobufEncoderOptions{EncodingOptions: opts}, targets, nil)
444444

445445
valBytes, err := enc.EncodeValue(ctx, eventContext{updated: hlc.Timestamp{WallTime: 42}}, row, cdcevent.Row{})
446446
require.NoError(t, err)
@@ -485,7 +485,7 @@ func TestProtoEncoder_BareEnvelope_WithMetadata(t *testing.T) {
485485
},
486486
}
487487

488-
encoder := newProtobufEncoder(context.Background(), encOpts, mkTargets(tableDesc))
488+
encoder := newProtobufEncoder(context.Background(), encOpts, mkTargets(tableDesc), nil)
489489

490490
valueBytes, err := encoder.EncodeValue(context.Background(), evCtx, row, cdcevent.Row{})
491491
require.NoError(t, err)

pkg/ccl/changefeedccl/enriched_source_provider.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/cockroachdb/cockroach/pkg/ccl/changefeedccl/avro"
1616
"github.com/cockroachdb/cockroach/pkg/ccl/changefeedccl/cdcevent"
1717
"github.com/cockroachdb/cockroach/pkg/ccl/changefeedccl/changefeedbase"
18+
"github.com/cockroachdb/cockroach/pkg/ccl/changefeedccl/changefeedpb"
1819
"github.com/cockroachdb/cockroach/pkg/ccl/changefeedccl/kcjsonschema"
1920
"github.com/cockroachdb/cockroach/pkg/security/username"
2021
"github.com/cockroachdb/cockroach/pkg/sql"
@@ -192,6 +193,41 @@ func (p *enrichedSourceProvider) KafkaConnectJSONSchema() kcjsonschema.Schema {
192193
return kafkaConnectJSONSchema
193194
}
194195

196+
func (p *enrichedSourceProvider) GetProtobuf(
197+
evCtx eventContext, updated, prev cdcevent.Row,
198+
) (*changefeedpb.EnrichedSource, error) {
199+
md := updated.Metadata
200+
tableInfo, ok := p.sourceData.tableSchemaInfo[md.TableID]
201+
if !ok {
202+
return nil, errors.AssertionFailedf("table %d not found in tableSchemaInfo", md.TableID)
203+
}
204+
205+
src := &changefeedpb.EnrichedSource{
206+
JobId: p.sourceData.jobID,
207+
ChangefeedSink: p.sourceData.sink,
208+
DbVersion: p.sourceData.dbVersion,
209+
ClusterName: p.sourceData.clusterName,
210+
ClusterId: p.sourceData.clusterID,
211+
SourceNodeLocality: p.sourceData.sourceNodeLocality,
212+
NodeName: p.sourceData.nodeName,
213+
NodeId: p.sourceData.nodeID,
214+
Origin: originCockroachDB,
215+
DatabaseName: tableInfo.dbName,
216+
SchemaName: tableInfo.schemaName,
217+
TableName: tableInfo.tableName,
218+
PrimaryKeys: tableInfo.primaryKeys,
219+
}
220+
221+
if p.opts.mvccTimestamp {
222+
src.MvccTimestamp = evCtx.mvcc.AsOfSystemTime()
223+
}
224+
if p.opts.updated {
225+
src.TsNs = evCtx.updated.WallTime
226+
src.TsHlc = evCtx.updated.AsOfSystemTime()
227+
}
228+
return src, nil
229+
}
230+
195231
// GetJSON returns a json object for the source data.
196232
func (p *enrichedSourceProvider) GetJSON(
197233
updated cdcevent.Row, evCtx eventContext,

0 commit comments

Comments
 (0)