Repository: cockroachdb/cockroach
Pull request #143422: Metadata schema info
Author: login: aerfrei
Labels: name: v25.2.0-prerelease
Created: 2025-03-25T15:01:57Z | Merged: 2025-04-09T02:38:23Z

changefeedccl: add schema info to enriched envelope source
This change adds the fields table_name, schema_name, database_name
and primary_keys to the source for changefeed messages with the
enriched envelope type. They are fetched for each target table
on starting the changefeed agregator and stored by table id.

Epic: [CRDB-8665](https://cockroachlabs.atlassian.net/browse/CRDB-8665)
Fixes: https://github.com/cockroachdb/cockroach/issues/139692
Release note: None


Diff:
diff --git a/pkg/ccl/changefeedccl/avro/avro.go b/pkg/ccl/changefeedccl/avro/avro.go
index 83ed628e2d11..a4491228abc4 100644
--- a/pkg/ccl/changefeedccl/avro/avro.go
+++ b/pkg/ccl/changefeedccl/avro/avro.go
@@ -76,7 +76,7 @@ type logicalType struct {
 	Scale       *int       `json:"scale,omitempty"`
 }
 
-type arrayType struct {
+type ArrayType struct {
 	SchemaType SchemaType `json:"type"`
 	Items      SchemaType `json:"items"`
 }
@@ -87,7 +87,7 @@ func unionKey(t SchemaType) string {
 		return s
 	case logicalType:
 		return unionKey(s.SchemaType) + `.` + s.LogicalType
-	case arrayType:
+	case ArrayType:
 		return unionKey(s.SchemaType)
 	case *Record:
 		if s.Namespace == "" {
@@ -339,7 +339,7 @@ func typeToSchema(typ *types.T) (*SchemaField, error) {
 		)
 	case types.BitFamily:
 		setNullable(
-			arrayType{
+			ArrayType{
 				SchemaType: SchemaTypeArray,
 				Items:      SchemaTypeLong,
 			},
@@ -710,7 +710,7 @@ func typeToSchema(typ *types.T) (*SchemaField, error) {
 		itemUnionKey := unionKey(itemSchema.SchemaType.([]SchemaType)[1])
 
 		setNullable(
-			arrayType{
+			ArrayType{
 				SchemaType: SchemaTypeArray,
 				Items:      itemSchema.SchemaType,
 			},
diff --git a/pkg/ccl/changefeedccl/changefeed_test.go b/pkg/ccl/changefeedccl/changefeed_test.go
index 9ba75215ce6b..efa974c981b8 100644
--- a/pkg/ccl/changefeedccl/changefeed_test.go
+++ b/pkg/ccl/changefeedccl/changefeed_test.go
@@ -73,6 +73,7 @@ import (
 	"github.com/cockroachdb/cockroach/pkg/sql/pgwire/pgcode"
 	"github.com/cockroachdb/cockroach/pkg/sql/pgwire/pgerror"
 	"github.com/cockroachdb/cockroach/pkg/sql/randgen"
+	"github.com/cockroachdb/cockroach/pkg/sql/rowenc"
 	"github.com/cockroachdb/cockroach/pkg/sql/sem/catid"
 	"github.com/cockroachdb/cockroach/pkg/sql/sem/eval"
 	"github.com/cockroachdb/cockroach/pkg/sql/sem/tree"
@@ -3978,9 +3979,9 @@ func TestChangefeedEnriched(t *testing.T) {
 	// Create an enriched source provider with no data. The contents of source
 	// will be tested in another test, we just want to make sure the structure &
 	// schema is right here.
-	esp, err := newEnrichedSourceProvider(changefeedbase.EncodingOptions{}, enrichedSourceData{})
+	esp, err := newEnrichedSourceProvider(changefeedbase.EncodingOptions{}, getTestingEnrichedSourceData())
 	require.NoError(t, err)
-	source, err := esp.GetJSON(cdcevent.Row{}, eventContext{})
+	source, err := esp.GetJSON(cdcevent.TestingMakeEventRowFromEncDatums([]rowenc.EncDatum{}, nil, 0, false), eventContext{})
 	require.NoError(t, err)
 
 	var sourceMap map[string]any
@@ -4325,188 +4326,496 @@ func TestChangefeedEnrichedWithDiff(t *testing.T) {
 		})
 	}
 }
+func TestChangefeedEnrichedSourceWithDataAvro(t *testing.T) {
+	defer leaktest.AfterTest(t)()
+	defer log.Scope(t).Close(t)
+
+	testutils.RunTrueAndFalse(t, "ts_{ns,hlc}", func(t *testing.T, withUpdated bool) {
+		testutils.RunTrueAndFalse(t, "mvcc_ts", func(t *testing.T, withMVCCTS bool) {
+			clusterName := "clusterName123"
+			dbVersion := "v999.0.0"
+			defer build.TestingOverrideVersion(dbVersion)()
+			mkTestFn := func(sink string) func(t *testing.T, s TestServer, f cdctest.TestFeedFactory) {
+				return func(t *testing.T, s TestServer, f cdctest.TestFeedFactory) {
+					clusterID := s.Server.ExecutorConfig().(sql.ExecutorConfig).NodeInfo.LogicalClusterID().String()
+
+					sqlDB := sqlutils.MakeSQLRunner(s.DB)
+
+					sqlDB.Exec(t, `CREATE TABLE foo (i INT PRIMARY KEY)`)
+					sqlDB.Exec(t, `INSERT INTO foo values (0)`)
+					stmt := `CREATE CHANGEFEED FOR foo WITH envelope=enriched, enriched_properties='source', format=avro`
+					if withMVCCTS {
+						stmt += ", mvcc_timestamp"
+					}
+					if withUpdated {
+						stmt += ", updated"
+					}
+					testFeed := feed(t, f, stmt)
+					defer closeFeed(t, testFeed)
+
+					var jobID int64
+					var nodeName string
+					var sourceAssertion func(actualSource map[string]any)
+					if ef, ok := testFeed.(cdctest.EnterpriseTestFeed); ok {
+						jobID = int64(ef.JobID())
+					}
+					sqlDB.QueryRow(t, `SELECT value FROM crdb_internal.node_runtime_info where component = 'DB' and field = 'Host'`).Scan(&nodeName)
+
+					sourceAssertion = func(actualSource map[string]any) {
+						var nodeID any
+						actualSourceValue := actualSource["source"].(map[string]any)
+						nodeID = actualSourceValue["node_id"].(map[string]any)["string"]
+
+						require.NotNil(t, nodeID)
+
+						sourceNodeLocality := fmt.Sprintf(`region=%s`, testServerRegion)
+
+						const dummyMvccTimestamp = "1234567890.0001"
+						jobIDStr := strconv.FormatInt(jobID, 10)
+
+						dummyUpdatedTSNS := 12345678900001000
+						dummyUpdatedTSHLC :=
+							hlc.Timestamp{WallTime: int64(dummyUpdatedTSNS), Logical: 0}.AsOfSystemTime()
+
+						var assertion string
+						assertionMap := map[string]any{
+							"source": map[string]any{
+								"changefeed_sink": map[string]any{"string": sink},
+								"cluster_id":      map[string]any{"string": clusterID},
+								"cluster_name":    map[string]any{"string": clusterName},
+								"database_name":   map[string]any{"string": "d"},
+								"db_version":      map[string]any{"string": dbVersion},
+								"job_id":          map[string]any{"string": jobIDStr},
+								// Note that the field is still present in the avro schema, so it appears here as nil.
+								"mvcc_timestamp":       nil,
+								"node_id":              map[string]any{"string": nodeID},
+								"origin":               map[string]any{"string": "cockroachdb"},
+								"node_name":            map[string]any{"string": nodeName},
+								"primary_keys":         map[string]any{"array": []any{"i"}},
+								"schema_name":          map[string]any{"string": "public"},
+								"source_node_locality": map[string]any{"string": sourceNodeLocality},
+								"table_name":           map[string]any{"string": "foo"},
+								"ts_ns":                nil,
+								"ts_hlc":               nil,
+							},
+						}
+						if withMVCCTS {
+							mvccTsMap := actualSource["source"].(map[string]any)["mvcc_timestamp"].(map[string]any)
+							assertReasonableMVCCTimestamp(t, mvccTsMap["string"].(string))
+
+							mvccTsMap["string"] = dummyMvccTimestamp
+							assertionMap["source"].(map[string]any)["mvcc_timestamp"] = map[string]any{"string": dummyMvccTimestamp}
+						}
+						if withUpdated {
+							tsnsMap := actualSource["source"].(map[string]any)["ts_ns"].(map[string]any)
+							tsns := tsnsMap["long"].(gojson.Number)
+							tsnsInt, err := tsns.Int64()
+							require.NoError(t, err)
+							tsnsString := tsns.String()
+							assertReasonableMVCCTimestamp(t, tsnsString)
+							tsnsMap["long"] = dummyUpdatedTSNS
+							assertionMap["source"].(map[string]any)["ts_ns"] = map[string]any{"long": dummyUpdatedTSNS}
+
+							tshlcMap := actualSource["source"].(map[string]any)["ts_hlc"].(map[string]any)
+							assertEqualTSNSHLCWalltime(t, tsnsInt, tshlcMap["string"].(string))
+
+							tshlcMap["string"] = dummyUpdatedTSHLC
+							assertionMap["source"].(map[string]any)["ts_hlc"] = map[string]any{"string": dummyUpdatedTSHLC}
+						}
+						assertion = toJSON(t, assertionMap)
+
+						value, err := reformatJSON(actualSource)
+						require.NoError(t, err)
+						require.JSONEq(t, assertion, string(value))
+					}
+
+					assertPayloadsEnriched(t, testFeed, []string{`foo: {"i":{"long":0}}->{"after": {"foo": {"i": {"long": 0}}}, "op": {"string": "c"}}`}, sourceAssertion)
+				}
+			}
+			testLocality := roachpb.Locality{
+				Tiers: []roachpb.Tier{{
+					Key:   "region",
+					Value: testServerRegion,
+				}}}
+			cdcTest(t, mkTestFn("kafka"), feedTestForceSink("kafka"), feedTestUseClusterName(clusterName),
+				feedTestUseLocality(testLocality))
+
+		})
+	})
+}
+func TestChangefeedEnrichedSourceWithDataJSON(t *testing.T) {
+	defer leaktest.AfterTest(t)()
+	defer log.Scope(t).Close(t)
+
+	testutils.RunTrueAndFalse(t, "ts_{ns,hlc}", func(t *testing.T, withUpdated bool) {
+		testutils.RunTrueAndFalse(t, "mvcc_ts", func(t *testing.T, withMVCCTS bool) {
+			clusterName := "clusterName123"
+			dbVersion := "v999.0.0"
+			defer build.TestingOverrideVersion(dbVersion)()
+			mkTestFn := func(sink string) func(t *testing.T, s TestServer, f cdctest.TestFeedFactory) {
+				return func(t *testing.T, s TestServer, f cdctest.TestFeedFactory) {
+					clusterID := s.Server.ExecutorConfig().(sql.ExecutorConfig).NodeInfo.LogicalClusterID().String()
+
+					sqlDB := sqlutils.MakeSQLRunner(s.DB)
+
+					sqlDB.Exec(t, `CREATE TABLE foo (i INT PRIMARY KEY)`)
+					sqlDB.Exec(t, `INSERT INTO foo values (0)`)
+					stmt := `CREATE CHANGEFEED FOR foo WITH envelope=enriched, enriched_properties='source', format=json`
+					if withMVCCTS {
+						stmt += ", mvcc_timestamp"
+					}
+					if withUpdated {
+						stmt += ", updated"
+					}
+					testFeed := feed(t, f, stmt)
+					defer closeFeed(t, testFeed)
+
+					var jobID int64
+					var nodeName string
+					var sourceAssertion func(actualSource map[string]any)
+					if ef, ok := testFeed.(cdctest.EnterpriseTestFeed); ok {
+						jobID = int64(ef.JobID())
+					}
+					sqlDB.QueryRow(t, `SELECT value FROM crdb_internal.node_runtime_info where component = 'DB' and field = 'Host'`).Scan(&nodeName)
+
+					sourceAssertion = func(actualSource map[string]any) {
+						nodeID := actualSource["node_id"]
+						require.NotNil(t, nodeID)
+
+						sourceNodeLocality := fmt.Sprintf(`region=%s`, testServerRegion)
+
+						// There are some differences between how we specify sinks here and their actual names.
+						if sink == "sinkless" {
+							sink = sinkTypeSinklessBuffer.String()
+						}
+
+						const dummyMvccTimestamp = "1234567890.0001"
+						jobIDStr := strconv.FormatInt(jobID, 10)
+
+						dummyUpdatedTSNS := 12345678900001000
+						dummyUpdatedTSHLC :=
+							hlc.Timestamp{WallTime: int64(dummyUpdatedTSNS), Logical: 0}.AsOfSystemTime()
+
+						var assertion string
+						assertionMap := map[string]any{
+							"cluster_id":           clusterID,
+							"cluster_name":         clusterName,
+							"db_version":           dbVersion,
+							"job_id":               jobIDStr,
+							"node_id":              nodeID,
+							"node_name":            nodeName,
+							"origin":               "cockroachdb",
+							"changefeed_sink":      sink,
+							"source_node_locality": sourceNodeLocality,
+							"database_name":        "d",
+							"schema_name":          "public",
+							"table_name":           "foo",
+							"primary_keys":         []any{"i"},
+						}
+						if withMVCCTS {
+							assertReasonableMVCCTimestamp(t, actualSource["mvcc_timestamp"].(string))
+							actualSource["mvcc_timestamp"] = dummyMvccTimestamp
+							assertionMap["mvcc_timestamp"] = dummyMvccTimestamp
+						}
+						if withUpdated {
+							tsns := actualSource["ts_ns"].(gojson.Number)
+							tsnsInt, err := tsns.Int64()
+							require.NoError(t, err)
+							assertReasonableMVCCTimestamp(t, tsns.String())
+							actualSource["ts_ns"] = dummyUpdatedTSNS
+							assertionMap["ts_ns"] = dummyUpdatedTSNS
+							assertEqualTSNSHLCWalltime(t, tsnsInt, actualSource["ts_hlc"].(string))
+							actualSource["ts_hlc"] = dummyUpdatedTSHLC
+							assertionMap["ts_hlc"] = dummyUpdatedTSHLC
+						}
+						assertion = toJSON(t, assertionMap)
+
+						value, err := reformatJSON(actualSource)
+						require.NoError(t, err)
+						require.JSONEq(t, assertion, string(value))
+					}
+
+					assertPayloadsEnriched(t, testFeed, []string{`foo: {"i": 0}->{"after": {"i": 0}, "op": "c"}`}, sourceAssertion)
+				}
+			}
+			for _, sink := range []string{"kafka", "pubsub", "sinkless"} {
+				testLocality := roachpb.Locality{
+					Tiers: []roachpb.Tier{{
+						Key:   "region",
+						Value: testServerRegion,
+					}}}
+				cdcTest(t, mkTestFn(sink), feedTestForceSink(sink), feedTestUseClusterName(clusterName),
+					feedTestUseLocality(testLocality))
+			}
+		})
+	})
+}
+
+// TODO(#139660): the webhook sink forces topic_in_value, but
+// this is not supported by the enriched envelope type. We should adapt
+// the test framework to account for this.
+func TestChangefeedEnrichedSourceWithDataJSONWebhook(t *testing.T) {
+	defer leaktest.AfterTest(t)()
+	defer log.Scope(t).Close(t)
+
+	testutils.RunTrueAndFalse(t, "ts_{ns,hlc}", func(t *testing.T, withUpdated bool) {
+		testutils.RunTrueAndFalse(t, "mvcc_ts", func(t *testing.T, withMVCCTS bool) {
+			clusterName := "clusterName123"
+			dbVersion := "v999.0.0"
+			defer build.TestingOverrideVersion(dbVersion)()
+			mkTestFn := func(sink string) func(t *testing.T, s TestServer, f cdctest.TestFeedFactory) {
+				return func(t *testing.T, s TestServer, f cdctest.TestFeedFactory) {
+					clusterID := s.Server.ExecutorConfig().(sql.ExecutorConfig).NodeInfo.LogicalClusterID().String()
+
+					sqlDB := sqlutils.MakeSQLRunner(s.DB)
+
+					sqlDB.Exec(t, `CREATE TABLE foo (i INT PRIMARY KEY)`)
+					sqlDB.Exec(t, `INSERT INTO foo values (0)`)
+					stmt := `CREATE CHANGEFEED FOR foo WITH envelope=enriched, enriched_properties='source', format=json`
+					if withMVCCTS {
+						stmt += ", mvcc_timestamp"
+					}
+					if withUpdated {
+						stmt += ", updated"
+					}
+					testFeed := feed(t, f, stmt)
+					defer closeFeed(t, testFeed)
+
+					var jobID int64
+					var nodeName string
+					var sourceAssertion func(actualSource map[string]any)
+					if ef, ok := testFeed.(cdctest.EnterpriseTestFeed); ok {
+						jobID = int64(ef.JobID())
+					}
+					sqlDB.QueryRow(t, `SELECT value FROM crdb_internal.node_runtime_info where component = 'DB' and field = 'Host'`).Scan(&nodeName)
+
+					sourceAssertion = func(actualSource map[string]any) {
+						nodeID := actualSource["node_id"]
+						require.NotNil(t, nodeID)
+
+						sourceNodeLocality := fmt.Sprintf(`region=%s`, testServerRegion)
+
+						const dummyMvccTimestamp = "1234567890.0001"
+						jobIDStr := strconv.FormatInt(jobID, 10)
+
+						dummyUpdatedTSNS := 12345678900001000
+						dummyUpdatedTSHLC :=
+							hlc.Timestamp{WallTime: int64(dummyUpdatedTSNS), Logical: 0}.AsOfSystemTime()
+
+						var assertion string
+						assertionMap := map[string]any{
+							"cluster_id":           clusterID,
+							"cluster_name":         clusterName,
+							"db_version":           dbVersion,
+							"job_id":               jobIDStr,
+							"node_id":              nodeID,
+							"node_name":            nodeName,
+							"origin":               "cockroachdb",
+							"changefeed_sink":      sink,
+							"source_node_locality": sourceNodeLocality,
+							"database_name":        "d",
+							"schema_name":          "public",
+							"table_name":           "foo",
+							"primary_keys":         []any{"i"},
+						}
+						if withMVCCTS {
+							assertReasonableMVCCTimestamp(t, actualSource["mvcc_timestamp"].(string))
+							actualSource["mvcc_timestamp"] = dummyMvccTimestamp
+							assertionMap["mvcc_timestamp"] = dummyMvccTimestamp
+						}
+						if withUpdated {
+							tsns := actualSource["ts_ns"].(gojson.Number)
+							tsnsInt, err := tsns.Int64()
+							require.NoError(t, err)
+							assertReasonableMVCCTimestamp(t, tsns.String())
+							actualSource["ts_ns"] = dummyUpdatedTSNS
+							assertionMap["ts_ns"] = dummyUpdatedTSNS
+							assertEqualTSNSHLCWalltime(t, tsnsInt, actualSource["ts_hlc"].(string))
+							actualSource["ts_hlc"] = dummyUpdatedTSHLC
+							assertionMap["ts_hlc"] = dummyUpdatedTSHLC
+						}
+						assertion = toJSON(t, assertionMap)
+
+						value, err := reformatJSON(actualSource)
+						require.NoError(t, err)
+						require.JSONEq(t, assertion, string(value))
+					}
 
-func TestChangefeedEnrichedSourceWithData(t *testing.T) {
+					assertPayloadsEnriched(t, testFeed, []string{`: {"i": 0}->{"after": {"i": 0}, "op": "c"}`}, sourceAssertion)
+				}
+			}
+			testLocality := roachpb.Locality{
+				Tiers: []roachpb.Tier{{
+					Key:   "region",
+					Value: testServerRegion,
+				}}}
+			cdcTest(t, mkTestFn("webhook"), feedTestForceSink("webhook"), feedTestUseClusterName(clusterName),
+				feedTestUseLocality(testLocality))
+		})
+	})
+}
+
+func TestChangefeedEnrichedSourceSchemaInfo(t *testing.T) {
 	defer leaktest.AfterTest(t)()
 	defer log.Scope(t).Close(t)
 
 	cases := []struct {
-		name            string
-		format          string
-		expectedMessage string
-		supportedSinks  []string
+		name                          string
+		format                        string
+		expectedRow                   string
+		expectedRowsAfterSchemaChange []string
 	}{
 		{
-			name:            "json",
-			format:          "json",
-			expectedMessage: `foo: {"i": 0}->{"after": {"i": 0}, "op": "c"}`,
-			supportedSinks:  []string{"kafka", "pubsub", "sinkless"},
-		},
-		{
-			// TODO(#139660): the webhook sink forces topic_in_value, but
-			// this is not supported by the enriched envelope type. We should adapt
-			// the test framework to account for this.
-			name:            "json-webhook",
-			format:          "json",
-			expectedMessage: `: {"i": 0}->{"after": {"i": 0}, "op": "c"}`,
-			supportedSinks:  []string{"webhook"},
+			name:        "json",
+			format:      "json",
+			expectedRow: `foo: {"a": 1, "b": "key1"}->{"after": {"a": 1, "b": "key1", "c": 100}, "op": "c"}`,
+			expectedRowsAfterSchemaChange: []string{
+				`foo: {"a": 1, "b": "key1"}->{"after": {"a": 1, "b": "key1", "c": 100, "d": "new_col"}, "op": "u"}`,
+				`foo: {"a": 2, "b": "key2"}->{"after": {"a": 2, "b": "key2", "c": 200, "d": "new_value"}, "op": "c"}`,
+			},
 		},
 		{
-			name:            "avro",
-			format:          "avro",
-			expectedMessage: `foo: {"i":{"long":0}}->{"after": {"foo": {"i": {"long": 0}}}, "op": {"string": "c"}}`,
-			supportedSinks:  []string{"kafka"},
+			name:        "avro",
+			format:      "avro",
+			expectedRow: `foo: {"a":{"long":1},"b":{"string":"key1"}}->{"after": {"foo": {"a": {"long": 1}, "b": {"string": "key1"}, "c": {"long": 100}}}, "op": {"string": "c"}}`,
+			expectedRowsAfterSchemaChange: []string{
+				`foo: {"a":{"long":1},"b":{"string":"key1"}}->{"after": {"foo": {"a": {"long": 1}, "b": {"string": "key1"}, "c": {"long": 100}, "d": {"string": "new_col"}}}, "op": {"string": "u"}}`,
+				`foo: {"a":{"long":2},"b":{"string":"key2"}}->{"after": {"foo": {"a": {"long": 2}, "b": {"string": "key2"}, "c": {"long": 200}, "d": {"string": "new_value"}}}, "op": {"string": "c"}}`,
+			},
 		},
 	}
 
 	for _, testCase := range cases {
-		testutils.RunTrueAndFalse(t, "ts_{ns,hlc}", func(t *testing.T, withUpdated bool) {
-			testutils.RunTrueAndFalse(t, "mvcc_ts", func(t *testing.T, withMVCCTS bool) {
-				clusterName := "clusterName123"
-				dbVersion := "v999.0.0"
-				defer build.TestingOverrideVersion(dbVersion)()
-				mkTestFn := func(sink string) func(t *testing.T, s TestServer, f cdctest.TestFeedFactory) {
-					return func(t *testing.T, s TestServer, f cdctest.TestFeedFactory) {
-						clusterID := s.Server.ExecutorConfig().(sql.ExecutorConfig).NodeInfo.LogicalClusterID().String()
+		t.Run(testCase.name, func(t *testing.T) {
+			testFn := func(t *testing.T, s TestServer, f cdctest.TestFeedFactory) {
+				sqlDB := sqlutils.MakeSQLRunner(s.DB)
 
-						sqlDB := sqlutils.MakeSQLRunner(s.DB)
+				sqlDB.Exec(t, `CREATE TABLE foo (a INT, b STRING, c INT, PRIMARY KEY (a, b))`)
+				sqlDB.Exec(t, `INSERT INTO foo VALUES (1, 'key1', 100)`)
 
-						sqlDB.Exec(t, `CREATE TABLE foo (i INT PRIMARY KEY)`)
-						sqlDB.Exec(t, `INSERT INTO foo values (0)`)
-						stmt := fmt.Sprintf(`CREATE CHANGEFEED FOR foo WITH envelope=enriched, enriched_properties='source', format=%s`, testCase.format)
-						if withMVCCTS {
-							stmt += ", mvcc_timestamp"
-						}
-						if withUpdated {
-							stmt += ", updated"
-						}
-						testFeed := feed(t, f, stmt)
-						defer closeFeed(t, testFeed)
-
-						var jobID int64
-						var nodeName string
-						var sourceAssertion func(actualSource map[string]any)
-						if ef, ok := testFeed.(cdctest.EnterpriseTestFeed); ok {
-							jobID = int64(ef.JobID())
-						}
-						sqlDB.QueryRow(t, `SELECT value FROM crdb_internal.node_runtime_info where component = 'DB' and field = 'Host'`).Scan(&nodeName)
-
-						sourceAssertion = func(actualSource map[string]any) {
-							var nodeID any
-							if testCase.format == "avro" {
-								actualSourceValue := actualSource["source"].(map[string]any)
-								nodeID = actualSourceValue["node_id"].(map[string]any)["string"]
-							} else {
-								nodeID = actualSource["node_id"]
-							}
-							require.NotNil(t, nodeID)
+				stmt := fmt.Sprintf(`CREATE CHANGEFEED FOR foo WITH envelope=enriched, enriched_properties='source', format=%s`, testCase.format)
+				foo := feed(t, f, stmt)
+				defer closeFeed(t, foo)
 
-							sourceNodeLocality := fmt.Sprintf(`region=%s`, testServerRegion)
+				sourceAssertion := func(actualSource map[string]any) {
+					if testCase.format == "avro" {
+						actualSourceValue := actualSource["source"].(map[string]any)
+						require.Equal(t, map[string]any{"string": "foo"}, actualSourceValue["table_name"])
+						require.Equal(t, map[string]any{"string": "public"}, actualSourceValue["schema_name"])
+						require.Equal(t, map[string]any{"string": "d"}, actualSourceValue["database_name"])
+						require.Equal(t, map[string]any{"array": []any{"a", "b"}}, actualSourceValue["primary_keys"])
+					} else {
+						require.Equal(t, "foo", actualSource["table_name"])
+						require.Equal(t, "public", actualSource["schema_name"])
+						require.Equal(t, "d", actualSource["database_name"])
+						require.Equal(t, []any{"a", "b"}, actualSource["primary_keys"])
+					}
+				}
+				assertPayloadsEnriched(t, foo, []string{testCase.expectedRow}, sourceAssertion)
+
+				sqlDB.Exec(t, `ALTER TABLE foo ADD COLUMN d STRING DEFAULT 'new_col'`)
+				sqlDB.Exec(t, `INSERT INTO foo VALUES (2, 'key2', 200, 'new_value')`)
+
+				sourceAssertionAfterSchemaChange := func(actualSource map[string]any) {
+					if testCase.format == "avro" {
+						actualSourceValue := actualSource["source"].(map[string]any)
+						require.Equal(t, map[string]any{"string": "foo"}, actualSourceValue["table_name"])
+						require.Equal(t, map[string]any{"string": "public"}, actualSourceValue["schema_name"])
+						require.Equal(t, map[string]any{"string": "d"}, actualSourceValue["database_name"])
+						require.Equal(t, map[string]any{"array": []any{"a", "b"}}, actualSourceValue["primary_keys"])
+					} else {
+						require.Equal(t, "foo", actualSource["table_name"])
+						require.Equal(t, "public", actualSource["schema_name"])
+						require.Equal(t, "d", actualSource["database_name"])
+						require.Equal(t, []any{"a", "b"}, actualSource["primary_keys"])
+					}
+				}
+				assertPayloadsEnriched(t, foo, testCase.expectedRowsAfterSchemaChange, sourceAssertionAfterSchemaChange)
+			}
 
-							// There are some differences between how we specify sinks here and their actual names.
-							if sink == "sinkless" {
-								sink = sinkTypeSinklessBuffer.String()
-							}
+			cdcTest(t, testFn, feedTestForceSink("kafka"))
+		})
+	}
+}
 
-							const dummyMvccTimestamp = "1234567890.0001"
-							jobIDStr := strconv.FormatInt(jobID, 10)
-
-							dummyUpdatedTSNS := 12345678900001000
-							dummyUpdatedTSHLC :=
-								hlc.Timestamp{WallTime: int64(dummyUpdatedTSNS), Logical: 0}.AsOfSystemTime()
-
-							var assertion string
-							if testCase.format == "avro" {
-								assertionMap := map[string]any{
-									"source": map[string]any{
-										"cluster_id":   map[string]any{"string": clusterID},
-										"cluster_name": map[string]any{"string": clusterName},
-										"db_version":   map[string]any{"string": dbVersion},
-										"job_id":       map[string]any{"string": jobIDStr},
-										// Note that the field is still present in the avro schema, so it appears here as nil.
-										"mvcc_timestamp":       nil,
-										"ts_ns":                nil,
-										"ts_hlc":               nil,
-										"node_id":              map[string]any{"string": nodeID},
-										"node_name":            map[string]any{"string": nodeName},
-										"changefeed_sink":      map[string]any{"string": sink},
-										"source_node_locality": map[string]any{"string": sourceNodeLocality},
-										"origin":               map[string]any{"string": "cockroachdb"},
-									},
-								}
-								if withMVCCTS {
-									mvccTsMap := actualSource["source"].(map[string]any)["mvcc_timestamp"].(map[string]any)
-									assertReasonableMVCCTimestamp(t, mvccTsMap["string"].(string))
+func TestChangefeedEnrichedSourceSchemaInfoOnPrimaryKeyChange(t *testing.T) {
+	defer leaktest.AfterTest(t)()
+	defer log.Scope(t).Close(t)
 
-									mvccTsMap["string"] = dummyMvccTimestamp
-									assertionMap["source"].(map[string]any)["mvcc_timestamp"] = map[string]any{"string": dummyMvccTimestamp}
-								}
-								if withUpdated {
-									tsnsMap := actualSource["source"].(map[string]any)["ts_ns"].(map[string]any)
-									tsns := tsnsMap["long"].(gojson.Number)
-									tsnsInt, err := tsns.Int64()
-									require.NoError(t, err)
-									tsnsString := tsns.String()
-									assertReasonableMVCCTimestamp(t, tsnsString)
-									tsnsMap["long"] = dummyUpdatedTSNS
-									assertionMap["source"].(map[string]any)["ts_ns"] = map[string]any{"long": dummyUpdatedTSNS}
-
-									tshlcMap := actualSource["source"].(map[string]any)["ts_hlc"].(map[string]any)
-									assertEqualTSNSHLCWalltime(t, tsnsInt, tshlcMap["string"].(string))
-
-									tshlcMap["string"] = dummyUpdatedTSHLC
-									assertionMap["source"].(map[string]any)["ts_hlc"] = map[string]any{"string": dummyUpdatedTSHLC}
-								}
-								assertion = toJSON(t, assertionMap)
-							} else {
-								assertionMap := map[string]any{
-									"cluster_id":           clusterID,
-									"cluster_name":         clusterName,
-									"db_version":           dbVersion,
-									"job_id":               jobIDStr,
-									"node_id":              nodeID,
-									"node_name":            nodeName,
-									"changefeed_sink":      sink,
-									"source_node_locality": sourceNodeLocality,
-									"origin":               "cockroachdb",
-								}
-								if withMVCCTS {
-									assertReasonableMVCCTimestamp(t, actualSource["mvcc_timestamp"].(string))
-									actualSource["mvcc_timestamp"] = dummyMvccTimestamp
-									assertionMap["mvcc_timestamp"] = dummyMvccTimestamp
-								}
-								if withUpdated {
-									tsns := actualSource["ts_ns"].(gojson.Number)
-									tsnsInt, err := tsns.Int64()
-									require.NoError(t, err)
-									assertReasonableMVCCTimestamp(t, tsns.String())
-									actualSource["ts_ns"] = dummyUpdatedTSNS
-									assertionMap["ts_ns"] = dummyUpdatedTSNS
-									assertEqualTSNSHLCWalltime(t, tsnsInt, actualSource["ts_hlc"].(string))
-									actualSource["ts_hlc"] = dummyUpdatedTSHLC
-									assertionMap["ts_hlc"] = dummyUpdatedTSHLC
-								}
-								assertion = toJSON(t, assertionMap)
-							}
+	cases := []struct {
+		name             string
+		format           string
+		expectedRow      string
+		expectedRowAfter string
+	}{
+		{
+			name:             "json",
+			format:           "json",
+			expectedRow:      `foo: {"a": 1}->{"after": {"a": 1, "b": "initial"}, "op": "c"}`,
+			expectedRowAfter: `foo: {"b": "new_key"}->{"after": {"a": 2, "b": "new_key"}, "op": "c"}`,
+		},
+		{
+			name:             "avro",
+			format:           "avro",
+			expectedRow:      `foo: {"a":{"long":1}}->{"after": {"foo": {"a": {"long": 1}, "b": {"string": "initial"}}}, "op": {"string": "c"}}`,
+			expectedRowAfter: `foo: {"b":{"string":"new_key"}}->{"after": {"foo": {"a": {"long": 2}, "b": {"string": "new_key"}}}, "op": {"string": "c"}}`,
+		},
+	}
 
-							value, err := reformatJSON(actualSource)
-							require.NoError(t, err)
-							require.JSONEq(t, assertion, string(value))
-						}
+	for _, testCase := range cases {
+		t.Run(testCase.name, func(t *testing.T) {
+			testFn := func(t *testing.T, s TestServer, f cdctest.TestFeedFactory) {
+				sqlDB := sqlutils.MakeSQLRunner(s.DB)
 
-						assertPayloadsEnriched(t, testFeed, []string{testCase.expectedMessage}, sourceAssertion)
+				sqlDB.Exec(t, `CREATE TABLE foo (a INT PRIMARY KEY, b STRING)`)
+				sqlDB.Exec(t, `INSERT INTO foo VALUES (1, 'initial')`)
+
+				stmt := fmt.Sprintf(`CREATE CHANGEFEED FOR foo WITH envelope=enriched, enriched_properties='source', format=%s`, testCase.format)
+				foo := feed(t, f, stmt)
+				defer closeFeed(t, foo)
+
+				sourceAssertion := func(actualSource map[string]any) {
+					if testCase.format == "avro" {
+						actualSourceValue := actualSource["source"].(map[string]any)
+						require.Equal(t, map[string]any{"string": "foo"}, actualSourceValue["table_name"])
+						require.Equal(t, map[string]any{"string": "public"}, actualSourceValue["schema_name"])
+						require.Equal(t, map[string]any{"string": "d"}, actualSourceValue["database_name"])
+						require.Equal(t, map[string]any{"array": []any{"a"}}, actualSourceValue["primary_keys"])
+					} else {
+						require.Equal(t, "foo", actualSource["table_name"])
+						require.Equal(t, "public", actualSource["schema_name"])
+						require.Equal(t, "d", actualSource["database_name"])
+						require.Equal(t, []any{"a"}, actualSource["primary_keys"])
 					}
 				}
-				for _, sink := range testCase.supportedSinks {
-					testLocality := roachpb.Locality{
-						Tiers: []roachpb.Tier{{
-							Key:   "region",
-							Value: testServerRegion,
-						}}}
-					cdcTest(t, mkTestFn(sink), feedTestForceSink(sink), feedTestUseClusterName(clusterName),
-						feedTestUseLocality(testLocality))
+				if testCase.format == "json" {
+					assertPayloadsEnriched(t, foo, []string{testCase.expectedRow}, sourceAssertion)
+				} else {
+					assertPayloadsEnriched(t, foo, []string{testCase.expectedRow}, sourceAssertion)
 				}
-			})
+
+				sqlDB.Exec(t, `ALTER TABLE foo ALTER COLUMN b SET NOT NULL`)
+				sqlDB.Exec(t, `ALTER TABLE foo ALTER PRIMARY KEY USING COLUMNS (b)`)
+				sqlDB.Exec(t, `INSERT INTO foo VALUES (2, 'new_key')`)
+
+				sourceAssertionAfterPKChange := func(actualSource map[string]any) {
+					if testCase.format == "avro" {
+						actualSourceValue := actualSource["source"].(map[string]any)
+						require.Equal(t, map[string]any{"string": "foo"}, actualSourceValue["table_name"])
+						require.Equal(t, map[string]any{"string": "public"}, actualSourceValue["schema_name"])
+						require.Equal(t, map[string]any{"string": "d"}, actualSourceValue["database_name"])
+						require.Equal(t, map[string]any{"array": []any{"b"}}, actualSourceValue["primary_keys"])
+					} else {
+						require.Equal(t, "foo", actualSource["table_name"])
+						require.Equal(t, "public", actualSource["schema_name"])
+						require.Equal(t, "d", actualSource["database_name"])
+						require.Equal(t, []any{"b"}, actualSource["primary_keys"])
+					}
+				}
+				assertPayloadsEnriched(t, foo, []string{testCase.expectedRowAfter}, sourceAssertionAfterPKChange)
+			}
+
+			cdcTest(t, testFn, feedTestForceSink("kafka"))
 		})
 	}
 }
diff --git a/pkg/ccl/changefeedccl/enriched_source_provider.go b/pkg/ccl/changefeedccl/enriched_source_provider.go
index daa568b12cef..e15588ace3d0 100644
--- a/pkg/ccl/changefeedccl/enriched_source_provider.go
+++ b/pkg/ccl/changefeedccl/enriched_source_provider.go
@@ -18,9 +18,15 @@ import (
 	"github.com/cockroachdb/cockroach/pkg/ccl/changefeedccl/kcjsonschema"
 	"github.com/cockroachdb/cockroach/pkg/security/username"
 	"github.com/cockroachdb/cockroach/pkg/sql"
+	"github.com/cockroachdb/cockroach/pkg/sql/catalog"
+	"github.com/cockroachdb/cockroach/pkg/sql/catalog/descpb"
+	"github.com/cockroachdb/cockroach/pkg/sql/catalog/descs"
 	"github.com/cockroachdb/cockroach/pkg/sql/execinfra"
 	"github.com/cockroachdb/cockroach/pkg/sql/execinfrapb"
+	"github.com/cockroachdb/cockroach/pkg/sql/isql"
+	"github.com/cockroachdb/cockroach/pkg/util/admission/admissionpb"
 	"github.com/cockroachdb/cockroach/pkg/util/json"
+	"github.com/cockroachdb/errors"
 	"github.com/linkedin/goavro/v2"
 )
 
@@ -30,9 +36,14 @@ type enrichedSourceProviderOpts struct {
 type enrichedSourceData struct {
 	jobID, sink,
 	dbVersion, clusterName, sourceNodeLocality, nodeName, nodeID, clusterID string
-	// TODO(#139692): Add schema info support.
-	// TODO(#139691): Add job info support.
-	// TODO(#139690): Add node/cluster info support.
+	tableSchemaInfo map[descpb.ID]tableSchemaInfo
+}
+type tableSchemaInfo struct {
+	tableName       string
+	dbName          string
+	schemaName      string
+	primaryKeys     []string
+	primaryKeysJSON json.JSON
 }
 type enrichedSourceProvider struct {
 	opts              enrichedSourceProviderOpts
@@ -42,11 +53,47 @@ type enrichedSourceProvider struct {
 	jsonNonFixedData map[string]json.JSON
 }
 
+func GetTableSchemaInfo(
+	ctx context.Context, cfg *execinfra.ServerConfig, targets changefeedbase.Targets,
+) (map[descpb.ID]tableSchemaInfo, error) {
+	schemaInfo := make(map[descpb.ID]tableSchemaInfo)
+	execCfg := cfg.ExecutorConfig.(*sql.ExecutorConfig)
+	err := targets.EachTarget(func(target changefeedbase.Target) error {
+		id := target.TableID
+		td, dbd, sd, err := getDescriptors(ctx, execCfg, id)
+		if err != nil {
+			return err
+		}
+
+		primaryKeys := td.GetPrimaryIndex().IndexDesc().KeyColumnNames
+
+		primaryKeysBuilder := json.NewArrayBuilder(len(primaryKeys))
+		for _, key := range primaryKeys {
+			primaryKeysBuilder.Add(json.FromString(key))
+		}
+
+		schemaInfo[id] = tableSchemaInfo{
+			tableName:       td.GetName(),
+			dbName:          dbd.GetName(),
+			schemaName:      sd.GetName(),
+			primaryKeys:     primaryKeys,
+			primaryKeysJSON: primaryKeysBuilder.Build(),
+		}
+		return nil
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	return schemaInfo, nil
+}
+
 func newEnrichedSourceData(
 	ctx context.Context,
 	cfg *execinfra.ServerConfig,
 	spec execinfrapb.ChangeAggregatorSpec,
 	sink sinkType,
+	schemaInfo map[descpb.ID]tableSchemaInfo,
 ) (enrichedSourceData, error) {
 	var sourceNodeLocality, nodeName, nodeID string
 	tiers := cfg.Locality.Tiers
@@ -85,6 +132,7 @@ func newEnrichedSourceData(
 		sourceNodeLocality: sourceNodeLocality,
 		nodeName:           nodeName,
 		nodeID:             nodeID,
+		tableSchemaInfo:    schemaInfo,
 	}, nil
 }
 
@@ -110,6 +158,11 @@ func newEnrichedSourceProvider(
 		nonFixedDataIdx[fieldName] = len(nonFixedJSONFields) - 1
 	}
 
+	addNonFixedJSONfield(fieldNameDatabaseName)
+	addNonFixedJSONfield(fieldNameSchemaName)
+	addNonFixedJSONfield(fieldNameTableName)
+	addNonFixedJSONfield(fieldNamePrimaryKeys)
+
 	if opts.MVCCTimestamps {
 		addNonFixedJSONfield(fieldNameMVCCTimestamp)
 	}
@@ -143,6 +196,19 @@ func (p *enrichedSourceProvider) GetJSON(
 	updated cdcevent.Row, evCtx eventContext,
 ) (json.JSON, error) {
 	clear(p.jsonNonFixedData)
+
+	metadata := updated.Metadata
+	tableID := metadata.TableID
+	tableInfo, ok := p.sourceData.tableSchemaInfo[tableID]
+	if !ok {
+		return nil, errors.AssertionFailedf("table %d not found in tableSchemaInfo", tableID)
+	}
+
+	p.jsonNonFixedData[fieldNameDatabaseName] = json.FromString(tableInfo.dbName)
+	p.jsonNonFixedData[fieldNameSchemaName] = json.FromString(tableInfo.schemaName)
+	p.jsonNonFixedData[fieldNameTableName] = json.FromString(tableInfo.tableName)
+	p.jsonNonFixedData[fieldNamePrimaryKeys] = tableInfo.primaryKeysJSON
+
 	if p.opts.mvccTimestamp {
 		p.jsonNonFixedData[fieldNameMVCCTimestamp] = json.FromString(evCtx.mvcc.AsOfSystemTime())
 	}
@@ -157,6 +223,12 @@ func (p *enrichedSourceProvider) GetJSON(
 func (p *enrichedSourceProvider) GetAvro(
 	row cdcevent.Row, schemaPrefix string, evCtx eventContext,
 ) (*avro.FunctionalRecord, error) {
+	tableID := row.EventDescriptor.TableDescriptor().GetID()
+	tableInfo, ok := p.sourceData.tableSchemaInfo[tableID]
+	if !ok {
+		return nil, errors.AssertionFailedf("table %d not found in tableSchemaInfo", tableID)
+	}
+
 	fromRow := func(row cdcevent.Row, dest map[string]any) {
 		// If this is the first use of the avro record (ie the first row the encoder processed), set the fixed fields.
 		if len(dest) == 0 {
@@ -171,6 +243,11 @@ func (p *enrichedSourceProvider) GetAvro(
 			dest[fieldNameOrigin] = goavro.Union(avro.SchemaTypeString, originCockroachDB)
 		}
 
+		dest[fieldNameDatabaseName] = goavro.Union(avro.SchemaTypeString, tableInfo.dbName)
+		dest[fieldNameSchemaName] = goavro.Union(avro.SchemaTypeString, tableInfo.schemaName)
+		dest[fieldNameTableName] = goavro.Union(avro.SchemaTypeString, tableInfo.tableName)
+		dest[fieldNamePrimaryKeys] = goavro.Union(avro.SchemaTypeArray, tableInfo.primaryKeys)
+
 		if p.opts.mvccTimestamp {
 			dest[fieldNameMVCCTimestamp] = goavro.Union(avro.SchemaTypeString, evCtx.mvcc.AsOfSystemTime())
 		}
@@ -199,6 +276,10 @@ const (
 	fieldNameUpdatedTSNS        = "ts_ns"
 	fieldNameUpdatedTSHLC       = "ts_hlc"
 	fieldNameOrigin             = "origin"
+	fieldNameDatabaseName       = "database_name"
+	fieldNameSchemaName         = "schema_name"
+	fieldNameTableName          = "table_name"
+	fieldNamePrimaryKeys        = "primary_keys"
 )
 
 type fieldInfo struct {
@@ -343,6 +424,54 @@ var allFieldInfo = map[string]fieldInfo{
 			Optional: true,
 		},
 	},
+	fieldNameDatabaseName: {
+		avroSchemaField: avro.SchemaField{
+			Name:       fieldNameDatabaseName,
+			SchemaType: []avro.SchemaType{avro.SchemaTypeNull, avro.SchemaTypeString},
+		},
+		kafkaConnectSchema: kcjsonschema.Schema{
+			Field:    fieldNameDatabaseName,
+			TypeName: kcjsonschema.SchemaTypeString,
+			Optional: false,
+		},
+	},
+	fieldNameSchemaName: {
+		avroSchemaField: avro.SchemaField{
+			Name:       fieldNameSchemaName,
+			SchemaType: []avro.SchemaType{avro.SchemaTypeNull, avro.SchemaTypeString},
+		},
+		kafkaConnectSchema: kcjsonschema.Schema{
+			Field:    fieldNameSchemaName,
+			TypeName: kcjsonschema.SchemaTypeString,
+			Optional: false,
+		},
+	},
+	fieldNameTableName: {
+		avroSchemaField: avro.SchemaField{
+			Name:       fieldNameTableName,
+			SchemaType: []avro.SchemaType{avro.SchemaTypeNull, avro.SchemaTypeString},
+		},
+		kafkaConnectSchema: kcjsonschema.Schema{
+			Field:    fieldNameTableName,
+			TypeName: kcjsonschema.SchemaTypeString,
+			Optional: false,
+		},
+	},
+	fieldNamePrimaryKeys: {
+		avroSchemaField: avro.SchemaField{
+			Name: fieldNamePrimaryKeys,
+			SchemaType: []avro.SchemaType{
+				avro.SchemaTypeNull,
+				avro.ArrayType{SchemaType: avro.SchemaTypeArray, Items: avro.SchemaTypeString},
+			},
+		},
+		kafkaConnectSchema: kcjsonschema.Schema{
+			Field:    fieldNamePrimaryKeys,
+			TypeName: kcjsonschema.SchemaTypeArray,
+			Optional: false,
+			Items:    &kcjsonschema.Schema{TypeName: kcjsonschema.SchemaTypeString},
+		},
+	},
 }
 
 // filled in by init() using allFieldInfo
@@ -371,3 +500,32 @@ func init() {
 }
 
 const originCockroachDB = "cockroachdb"
+
+func getDescriptors(
+	ctx context.Context, execCfg *sql.ExecutorConfig, tableID descpb.ID,
+) (catalog.TableDescriptor, catalog.DatabaseDescriptor, catalog.SchemaDescriptor, error) {
+	var tableDescriptor catalog.TableDescriptor
+	var dbDescriptor catalog.DatabaseDescriptor
+	var schemaDescriptor catalog.SchemaDescriptor
+	var err error
+	f := func(ctx context.Context, txn descs.Txn) error {
+		byIDGetter := txn.Descriptors().ByIDWithoutLeased(txn.KV()).WithoutNonPublic().Get()
+		tableDescriptor, err = byIDGetter.Table(ctx, tableID)
+		if err != nil {
+			return err
+		}
+		dbDescriptor, err = byIDGetter.Database(ctx, tableDescriptor.GetParentID())
+		if err != nil {
+			return err
+		}
+		schemaDescriptor, err = byIDGetter.Schema(ctx, tableDescriptor.GetParentSchemaID())
+		if err != nil {
+			return err
+		}
+		return nil
+	}
+	if err := execCfg.InternalDB.DescsTxn(ctx, f, isql.WithPriority(admissionpb.NormalPri)); err != nil {
+		return nil, nil, nil, err
+	}
+	return tableDescriptor, dbDescriptor, schemaDescriptor, nil
+}
diff --git a/pkg/ccl/changefeedccl/event_processing.go b/pkg/ccl/changefeedccl/event_processing.go
index 1421feff475b..bf6730aba59b 100644
--- a/pkg/ccl/changefeedccl/event_processing.go
+++ b/pkg/ccl/changefeedccl/event_processing.go
@@ -19,6 +19,7 @@ import (
 	"github.com/cockroachdb/cockroach/pkg/roachpb"
 	"github.com/cockroachdb/cockroach/pkg/settings"
 	"github.com/cockroachdb/cockroach/pkg/sql"
+	"github.com/cockroachdb/cockroach/pkg/sql/catalog/descpb"
 	"github.com/cockroachdb/cockroach/pkg/sql/execinfra"
 	"github.com/cockroachdb/cockroach/pkg/sql/execinfrapb"
 	"github.com/cockroachdb/cockroach/pkg/sql/sem/tree"
@@ -104,7 +105,14 @@ func newEventConsumer(
 	makeConsumer := func(s EventSink, frontier frontier) (eventConsumer, error) {
 		sourceData := enrichedSourceData{}
 		if encodingOpts.Envelope == changefeedbase.OptEnvelopeEnriched {
-			sourceData, err = newEnrichedSourceData(ctx, cfg, spec, sink.getConcreteType())
+			var schemaInfo map[descpb.ID]tableSchemaInfo
+			if inSet(changefeedbase.EnrichedPropertySource, encodingOpts.EnrichedProperties) {
+				schemaInfo, err = GetTableSchemaInfo(ctx, cfg, feed.Targets)
+				if err != nil {
+					return nil, err
+				}
+			}
+			sourceData, err = newEnrichedSourceData(ctx, cfg, spec, sink.getConcreteType(), schemaInfo)
 			if err != nil {
 				return nil, err
 			}
diff --git a/pkg/ccl/changefeedccl/helpers_test.go b/pkg/ccl/changefeedccl/helpers_test.go
index d6d26ef87965..fe97d3371547 100644
--- a/pkg/ccl/changefeedccl/helpers_test.go
+++ b/pkg/ccl/changefeedccl/helpers_test.go
@@ -42,6 +42,7 @@ import (
 	"github.com/cockroachdb/cockroach/pkg/server"
 	"github.com/cockroachdb/cockroach/pkg/server/serverpb"
 	"github.com/cockroachdb/cockroach/pkg/settings/cluster"
+	"github.com/cockroachdb/cockroach/pkg/sql/catalog/descpb"
 	"github.com/cockroachdb/cockroach/pkg/sql/execinfra"
 	"github.com/cockroachdb/cockroach/pkg/testutils"
 	"github.com/cockroachdb/cockroach/pkg/testutils/pgurlutils"
@@ -50,6 +51,7 @@ import (
 	"github.com/cockroachdb/cockroach/pkg/testutils/sqlutils"
 	"github.com/cockroachdb/cockroach/pkg/util"
 	"github.com/cockroachdb/cockroach/pkg/util/hlc"
+	"github.com/cockroachdb/cockroach/pkg/util/json"
 	"github.com/cockroachdb/cockroach/pkg/util/log"
 	"github.com/cockroachdb/cockroach/pkg/util/log/eventpb"
 	"github.com/cockroachdb/cockroach/pkg/util/retry"
@@ -1577,6 +1579,9 @@ func ChangefeedJobPermissionsTestSetup(t *testing.T, s TestServer) {
 // getTestingEnrichedSourceData creates an enrichedSourceData
 // for use in tests.
 func getTestingEnrichedSourceData() enrichedSourceData {
+	primaryKeysBuilder := json.NewArrayBuilder(1)
+	primaryKeysBuilder.Add(json.FromString("test_primary_key"))
+
 	return enrichedSourceData{
 		jobID:              "test_id",
 		dbVersion:          "test_db_version",
@@ -1585,6 +1590,17 @@ func getTestingEnrichedSourceData() enrichedSourceData {
 		sourceNodeLocality: "test_source_node_locality",
 		nodeName:           "test_node_name",
 		nodeID:             "test_node_id",
+		tableSchemaInfo: map[descpb.ID]tableSchemaInfo{
+			// We use 42 here since that is compatible with the tableID in
+			// cdcevent.TestingMakeEventRowFromEncDatums
+			42: {
+				tableName:       "test_table_name",
+				dbName:          "test_db_name",
+				schemaName:      "test_schema_name",
+				primaryKeys:     []string{"test_primary_key"},
+				primaryKeysJSON: primaryKeysBuilder.Build(),
+			},
+		},
 	}
 }
 
diff --git a/pkg/ccl/changefeedccl/testfeed_test.go b/pkg/ccl/changefeedccl/testfeed_test.go
index 8634012ba974..e65d7007a06b 100644
--- a/pkg/ccl/changefeedccl/testfeed_test.go
+++ b/pkg/ccl/changefeedccl/testfeed_test.go
@@ -2558,6 +2558,7 @@ func (p *pubsubFeedFactory) Feed(create string, args ...interface{}) (cdctest.Te
 	mockServer := makeFakePubsubServer()
 
 	ss := &sinkSynchronizer{}
+	var startedConn *grpc.ClientConn
 	var mu syncutil.Mutex
 	wrapSink := func(s Sink) Sink {
 		mu.Lock() // Called concurrently due to getEventSink and getResolvedTimestampSink
@@ -2565,6 +2566,7 @@ func (p *pubsubFeedFactory) Feed(create string, args ...interface{}) (cdctest.Te
 		if batchingSink, ok := s.(*batchingSink); ok {
 			if sinkClient, ok := batchingSink.client.(*pubsubSinkClient); ok {
 				conn, _ := mockServer.Dial()
+				startedConn = conn
 				mockClient, _ := pubsubv1.NewPublisherClient(context.Background(), option.WithGRPCConn(conn))
 				sinkClient.client = mockClient
 			}
@@ -2578,9 +2580,13 @@ func (p *pubsubFeedFactory) Feed(create string, args ...interface{}) (cdctest.Te
 		seenTrackerMap: make(map[string]struct{}),
 		ss:             ss,
 		mockServer:     mockServer,
+		startedConn:    startedConn,
 	}
 
 	if err := p.startFeedJob(c.jobFeed, tree.AsStringWithFlags(createStmt, tree.FmtShowPasswords), args...); err != nil {
+		if startedConn != nil {
+			_ = startedConn.Close() // nolint:grpcconnclose
+		}
 		_ = mockServer.Close()
 		return nil, err
 	}
@@ -2595,8 +2601,9 @@ func (p *pubsubFeedFactory) Server() serverutils.ApplicationLayerInterface {
 type pubsubFeed struct {
 	*jobFeed
 	seenTrackerMap
-	ss         *sinkSynchronizer
-	mockServer *fakePubsubServer
+	ss          *sinkSynchronizer
+	mockServer  *fakePubsubServer
+	startedConn *grpc.ClientConn
 }
 
 var _ cdctest.TestFeed = (*pubsubFeed)(nil)
@@ -2694,13 +2701,13 @@ func (p *pubsubFeed) waitForMessage() error {
 }
 
 // Close implements TestFeed
-func (p *pubsubFeed) Close() error {
-	err := p.jobFeed.Close()
-	if err != nil {
-		return err
+func (p *pubsubFeed) Close() (err error) {
+	err = errors.Join(err, p.jobFeed.Close())
+	if p.startedConn != nil {
+		err = errors.Join(err, p.startedConn.Close()) // nolint:grpcconnclose
 	}
-	_ = p.mockServer.Close()
-	return nil
+	err = errors.Join(err, p.mockServer.Close())
+	return err
 }
 
 type mockPulsarServer struct {


Reviews:
user: login: asg0451
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T19:16:48Z
user: login: aerfrei
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T16:06:24Z
user: login: aerfrei
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T16:07:12Z
user: login: asg0451
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T21:56:43Z
user: login: aerfrei
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T22:40:52Z
user: login: asg0451
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T01:30:36Z

Review comments:
diff_hunk: @@ -30,7 +32,12 @@ type enrichedSourceProviderOpts struct {
 type enrichedSourceData struct {
 	jobID, sink,
 	dbVersion, clusterName, sourceNodeLocality, nodeName, nodeID, clusterID string
-	// TODO(#139692): Add schema info support.
+	tableSchemaInfo map[descpb.ID]struct {
path: pkg/ccl/changefeedccl/enriched_source_provider.go
user: login: asg0451
body: nit: make this struct its own type
created_at: 2025-03-26T18:24:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -47,7 +54,44 @@ func newEnrichedSourceData(
 	cfg *execinfra.ServerConfig,
 	spec execinfrapb.ChangeAggregatorSpec,
 	sink sinkType,
+	targets changefeedbase.Targets,
 ) (enrichedSourceData, error) {
+	tableSchemaInfo := make(map[descpb.ID]struct {
+		tableName   string
+		dbName      string
+		schemaName  string
+		primaryKeys []string
+	})
+	err := targets.EachTarget(func(target changefeedbase.Target) error {
+		id := target.TableID
+		td, err := getTableDesc(ctx, cfg.ExecutorConfig.(*sql.ExecutorConfig), id)
path: pkg/ccl/changefeedccl/enriched_source_provider.go
user: login: asg0451
body: nit do the cast once
created_at: 2025-03-26T18:57:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -104,6 +149,16 @@ func newEnrichedSourceProvider(
 
 	var nonFixedJSONFields []string
 	nonFixedDataIdx := map[string]int{}
+
+	nonFixedJSONFields = append(nonFixedJSONFields, fieldNameDatabaseName)
+	nonFixedDataIdx[fieldNameDatabaseName] = len(nonFixedJSONFields) - 1
+	nonFixedJSONFields = append(nonFixedJSONFields, fieldNameSchemaName)
+	nonFixedDataIdx[fieldNameSchemaName] = len(nonFixedJSONFields) - 1
+	nonFixedJSONFields = append(nonFixedJSONFields, fieldNameTableName)
+	nonFixedDataIdx[fieldNameTableName] = len(nonFixedJSONFields) - 1
+	nonFixedJSONFields = append(nonFixedJSONFields, fieldNamePrimaryKeys)
+	nonFixedDataIdx[fieldNamePrimaryKeys] = len(nonFixedJSONFields) - 1
path: pkg/ccl/changefeedccl/enriched_source_provider.go
user: login: asg0451
body: nit: i like what it's doing but can you make this visually less jarring? maybe make a helper fn or something?
created_at: 2025-03-26T18:59:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -134,6 +189,24 @@ func (p *enrichedSourceProvider) KafkaConnectJSONSchema() kcjsonschema.Schema {
 func (p *enrichedSourceProvider) GetJSON(updated cdcevent.Row) (json.JSON, error) {
 	// TODO(#139661): Add other non fixed fields.
 	clear(p.jsonNonFixedData)
+
+	metadata := updated.Metadata
+	tableID := metadata.TableID
+	tableInfo, ok := p.sourceData.tableSchemaInfo[tableID]
+	if !ok {
+		return nil, fmt.Errorf("table %d not found in tableSchemaInfo", tableID)
path: pkg/ccl/changefeedccl/enriched_source_provider.go
user: login: asg0451
body: nit: can you make this an AssertionFailedf
created_at: 2025-03-26T19:04:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -134,6 +189,24 @@ func (p *enrichedSourceProvider) KafkaConnectJSONSchema() kcjsonschema.Schema {
 func (p *enrichedSourceProvider) GetJSON(updated cdcevent.Row) (json.JSON, error) {
 	// TODO(#139661): Add other non fixed fields.
 	clear(p.jsonNonFixedData)
+
+	metadata := updated.Metadata
+	tableID := metadata.TableID
+	tableInfo, ok := p.sourceData.tableSchemaInfo[tableID]
+	if !ok {
+		return nil, fmt.Errorf("table %d not found in tableSchemaInfo", tableID)
+	}
+
+	p.jsonNonFixedData[fieldNameDatabaseName] = json.FromString(tableInfo.dbName)
+	p.jsonNonFixedData[fieldNameSchemaName] = json.FromString(tableInfo.schemaName)
+	p.jsonNonFixedData[fieldNameTableName] = json.FromString(tableInfo.tableName)
+
+	primaryKeysBuilder := json.NewArrayBuilder(len(tableInfo.primaryKeys))
path: pkg/ccl/changefeedccl/enriched_source_provider.go
user: login: asg0451
body: nit: might be worth storing this info as json ahead of time. ditto the above, but `json.FromString` is basically a no-op so i dont think that's necessary
created_at: 2025-03-26T19:05:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -144,6 +217,12 @@ func (p *enrichedSourceProvider) GetJSON(updated cdcevent.Row) (json.JSON, error
 func (p *enrichedSourceProvider) GetAvro(
 	row cdcevent.Row, schemaPrefix string,
 ) (*avro.FunctionalRecord, error) {
+	tableID := row.EventDescriptor.TableDescriptor().GetID()
+	tableInfo, ok := p.sourceData.tableSchemaInfo[tableID]
+	if !ok {
+		return nil, fmt.Errorf("table %d not found in tableSchemaInfo", tableID)
path: pkg/ccl/changefeedccl/enriched_source_provider.go
user: login: asg0451
body: ditto
created_at: 2025-03-26T19:05:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -290,6 +381,52 @@ var allFieldInfo = map[string]fieldInfo{
 			Optional: true,
 		},
 	},
+	fieldNameDatabaseName: {
+		avroSchemaField: avro.SchemaField{
+			Name:       fieldNameDatabaseName,
+			SchemaType: []avro.SchemaType{avro.SchemaTypeNull, avro.SchemaTypeString},
+		},
+		kafkaConnectSchema: kcjsonschema.Schema{
+			Field:    fieldNameDatabaseName,
+			TypeName: kcjsonschema.SchemaTypeString,
+			Optional: false,
+		},
+	},
+	fieldNameSchemaName: {
+		avroSchemaField: avro.SchemaField{
+			Name:       fieldNameSchemaName,
+			SchemaType: []avro.SchemaType{avro.SchemaTypeNull, avro.SchemaTypeString},
+		},
+		kafkaConnectSchema: kcjsonschema.Schema{
+			Field:    fieldNameSchemaName,
+			TypeName: kcjsonschema.SchemaTypeString,
+			Optional: false,
+		},
+	},
+	fieldNameTableName: {
+		avroSchemaField: avro.SchemaField{
+			Name:       fieldNameTableName,
+			SchemaType: []avro.SchemaType{avro.SchemaTypeNull, avro.SchemaTypeString},
+		},
+		kafkaConnectSchema: kcjsonschema.Schema{
+			Field:    fieldNameTableName,
+			TypeName: kcjsonschema.SchemaTypeString,
+			Optional: false,
+		},
+	},
+	fieldNamePrimaryKeys: {
+		avroSchemaField: avro.SchemaField{
+			// TODO:(aerin) how do I use this right?
+			Name:       fieldNamePrimaryKeys,
+			SchemaType: []avro.SchemaType{avro.SchemaTypeNull, avro.SchemaTypeString},
path: pkg/ccl/changefeedccl/enriched_source_provider.go
user: login: asg0451
body: try:
```
[]avro.SchemaType{avro.SchemaTypeNull, avro.ArrayType{SchemaType: avro.SchemaTypeArray, Items: avro.SchemaTypeString}},
```
created_at: 2025-03-26T19:09:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -157,6 +236,14 @@ func (p *enrichedSourceProvider) GetAvro(
 			dest[fieldNameNodeID] = goavro.Union(avro.SchemaTypeString, p.sourceData.nodeID)
 		}
 
+		dest[fieldNameDatabaseName] = goavro.Union(avro.SchemaTypeString, tableInfo.dbName)
+		dest[fieldNameSchemaName] = goavro.Union(avro.SchemaTypeString, tableInfo.schemaName)
+		dest[fieldNameTableName] = goavro.Union(avro.SchemaTypeString, tableInfo.tableName)
+		// TODO:(aerin) I'd like this to be an array and not a string, byt this causes an error on
+		// the call to newFunctionalRecord saying it's missing Items. I think I'm not using the schema
+		// correctly. Trying to figure out the right way to specify this.
+		dest[fieldNamePrimaryKeys] = goavro.Union(avro.SchemaTypeString, fmt.Sprintf(`[ "%s" ]`, strings.Join(tableInfo.primaryKeys, `", "`)))
path: pkg/ccl/changefeedccl/enriched_source_provider.go
user: login: asg0451
body: try: 

```
goavro.Union(avro.SchemaTypeArray, tableInfo.primaryKeys)
```
created_at: 2025-03-26T19:10:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -107,6 +107,42 @@ func getTableDesc(
 	return desc, nil
 }
 
+func getDBDesc(
path: pkg/ccl/changefeedccl/fetch_table_bytes.go
user: login: asg0451
body: this file is for a pretty specific feature, maybe put these helpers somewhere else despite the naming similarity
created_at: 2025-03-26T19:11:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -4433,6 +4442,89 @@ func TestChangefeedEnrichedSourceWithData(t *testing.T) {
 	}
 }
 
+func TestChangefeedEnrichedSourceSchemaInfo(t *testing.T) {
+	defer leaktest.AfterTest(t)()
+	defer log.Scope(t).Close(t)
+
+	cases := []struct {
+		name                          string
+		format                        string
+		expectedRow                   string
+		expectedRowsAfterSchemaChange []string
+	}{
+		{
+			name:        "json",
+			format:      "json",
+			expectedRow: `foo: {"a": 1, "b": "key1"}->{"after": {"a": 1, "b": "key1", "c": 100}, "op": "c"}`,
+			expectedRowsAfterSchemaChange: []string{
+				`foo: {"a": 1, "b": "key1"}->{"after": {"a": 1, "b": "key1", "c": 100, "d": "new_col"}, "op": "u"}`,
+				`foo: {"a": 2, "b": "key2"}->{"after": {"a": 2, "b": "key2", "c": 200, "d": "new_value"}, "op": "c"}`,
+			},
+		},
+		{
+			name:        "avro",
+			format:      "avro",
+			expectedRow: `foo: {"a":{"long":1},"b":{"string":"key1"}}->{"after": {"foo": {"a": {"long": 1}, "b": {"string": "key1"}, "c": {"long": 100}}}, "op": {"string": "c"}}`,
+			expectedRowsAfterSchemaChange: []string{
+				`foo: {"a":{"long":1},"b":{"string":"key1"}}->{"after": {"foo": {"a": {"long": 1}, "b": {"string": "key1"}, "c": {"long": 100}, "d": {"string": "new_col"}}}, "op": {"string": "u"}}`,
+				`foo: {"a":{"long":2},"b":{"string":"key2"}}->{"after": {"foo": {"a": {"long": 2}, "b": {"string": "key2"}, "c": {"long": 200}, "d": {"string": "new_value"}}}, "op": {"string": "c"}}`,
+			},
+		},
+	}
+
+	for _, testCase := range cases {
+		t.Run(testCase.name, func(t *testing.T) {
+			testFn := func(t *testing.T, s TestServer, f cdctest.TestFeedFactory) {
+				sqlDB := sqlutils.MakeSQLRunner(s.DB)
+
+				sqlDB.Exec(t, `CREATE TABLE foo (a INT, b STRING, c INT, PRIMARY KEY (a, b))`)
+				sqlDB.Exec(t, `INSERT INTO foo VALUES (1, 'key1', 100)`)
+
+				stmt := fmt.Sprintf(`CREATE CHANGEFEED FOR foo WITH envelope=enriched, enriched_properties='source', format=%s`, testCase.format)
+				foo := feed(t, f, stmt)
+				defer closeFeed(t, foo)
+
+				sourceAssertion := func(actualSource map[string]any) {
+					if testCase.format == "avro" {
+						actualSourceValue := actualSource["source"].(map[string]any)
+						require.Equal(t, map[string]any{"string": "foo"}, actualSourceValue["table_name"])
+						require.Equal(t, map[string]any{"string": "public"}, actualSourceValue["schema_name"])
+						require.Equal(t, map[string]any{"string": "d"}, actualSourceValue["database_name"])
+						require.Equal(t, map[string]any{"string": "[ \"a\", \"b\" ]"}, actualSourceValue["primary_keys"])
+					} else {
+						require.Equal(t, "foo", actualSource["table_name"])
+						require.Equal(t, "public", actualSource["schema_name"])
+						require.Equal(t, "d", actualSource["database_name"])
+						require.Equal(t, []any{"a", "b"}, actualSource["primary_keys"])
+					}
+				}
+				assertPayloadsEnriched(t, foo, []string{testCase.expectedRow}, sourceAssertion)
+
+				sqlDB.Exec(t, `ALTER TABLE foo ADD COLUMN d STRING DEFAULT 'new_col'`)
+				sqlDB.Exec(t, `INSERT INTO foo VALUES (2, 'key2', 200, 'new_value')`)
+
+				sourceAssertionAfterSchemaChange := func(actualSource map[string]any) {
+					if testCase.format == "avro" {
+						actualSourceValue := actualSource["source"].(map[string]any)
+						require.Equal(t, map[string]any{"string": "foo"}, actualSourceValue["table_name"])
+						require.Equal(t, map[string]any{"string": "public"}, actualSourceValue["schema_name"])
+						require.Equal(t, map[string]any{"string": "d"}, actualSourceValue["database_name"])
+						require.Equal(t, map[string]any{"string": "[ \"a\", \"b\" ]"}, actualSourceValue["primary_keys"])
+					} else {
+						require.Equal(t, "foo", actualSource["table_name"])
+						require.Equal(t, "public", actualSource["schema_name"])
+						require.Equal(t, "d", actualSource["database_name"])
+						require.Equal(t, []any{"a", "b"}, actualSource["primary_keys"])
+					}
+				}
+				assertPayloadsEnriched(t, foo, testCase.expectedRowsAfterSchemaChange, sourceAssertionAfterSchemaChange)
+			}
+
+			cdcTest(t, testFn, feedTestForceSink("kafka"))
path: pkg/ccl/changefeedccl/changefeed_test.go
user: login: asg0451
body: can you test that it does the right thing for primary key changes too?
created_at: 2025-03-26T19:16:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -107,6 +107,42 @@ func getTableDesc(
 	return desc, nil
 }
 
+func getDBDesc(
path: pkg/ccl/changefeedccl/fetch_table_bytes.go
user: login: aerfrei
body: I put them in this file (except for getTableDesc which I'm still using from here). Seems like other functions that fetch the table descriptors aren't in helper files either. I figure if we build up enough helpers we can put them in a file and until then it's alright to have these two here.
created_at: 2025-03-27T16:06:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -290,6 +381,52 @@ var allFieldInfo = map[string]fieldInfo{
 			Optional: true,
 		},
 	},
+	fieldNameDatabaseName: {
+		avroSchemaField: avro.SchemaField{
+			Name:       fieldNameDatabaseName,
+			SchemaType: []avro.SchemaType{avro.SchemaTypeNull, avro.SchemaTypeString},
+		},
+		kafkaConnectSchema: kcjsonschema.Schema{
+			Field:    fieldNameDatabaseName,
+			TypeName: kcjsonschema.SchemaTypeString,
+			Optional: false,
+		},
+	},
+	fieldNameSchemaName: {
+		avroSchemaField: avro.SchemaField{
+			Name:       fieldNameSchemaName,
+			SchemaType: []avro.SchemaType{avro.SchemaTypeNull, avro.SchemaTypeString},
+		},
+		kafkaConnectSchema: kcjsonschema.Schema{
+			Field:    fieldNameSchemaName,
+			TypeName: kcjsonschema.SchemaTypeString,
+			Optional: false,
+		},
+	},
+	fieldNameTableName: {
+		avroSchemaField: avro.SchemaField{
+			Name:       fieldNameTableName,
+			SchemaType: []avro.SchemaType{avro.SchemaTypeNull, avro.SchemaTypeString},
+		},
+		kafkaConnectSchema: kcjsonschema.Schema{
+			Field:    fieldNameTableName,
+			TypeName: kcjsonschema.SchemaTypeString,
+			Optional: false,
+		},
+	},
+	fieldNamePrimaryKeys: {
+		avroSchemaField: avro.SchemaField{
+			// TODO:(aerin) how do I use this right?
+			Name:       fieldNamePrimaryKeys,
+			SchemaType: []avro.SchemaType{avro.SchemaTypeNull, avro.SchemaTypeString},
path: pkg/ccl/changefeedccl/enriched_source_provider.go
user: login: aerfrei
body: Thanks so much! This is the part I was missing. 
created_at: 2025-03-27T16:07:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -42,11 +53,47 @@ type enrichedSourceProvider struct {
 	jsonNonFixedData map[string]json.JSON
 }
 
+func GetTableSchemaInfo(
+	ctx context.Context, cfg *execinfra.ServerConfig, targets changefeedbase.Targets,
+) (map[descpb.ID]tableSchemaInfo, error) {
+	schemaInfo := make(map[descpb.ID]tableSchemaInfo)
+	execCfg := cfg.ExecutorConfig.(*sql.ExecutorConfig)
+	err := targets.EachTarget(func(target changefeedbase.Target) error {
+		id := target.TableID
+		td, dbd, sd, err := getDescriptors(ctx, execCfg, id)
+		if err != nil {
+			return err
+		}
+
+		primaryKeys := td.GetPrimaryIndex().IndexDesc().KeyColumnNames
+
+		primaryKeysBuilder := json.NewArrayBuilder(len(primaryKeys))
+		for _, key := range primaryKeys {
+			primaryKeysBuilder.Add(json.FromString(key))
+		}
+
+		schemaInfo[id] = tableSchemaInfo{
+			tableName:       td.GetName(),
+			dbName:          dbd.GetName(),
+			schemaName:      sd.GetName(),
+			primaryKeys:     primaryKeys,
+			primaryKeysJSON: primaryKeysBuilder.Build(),
+		}
+		return nil
+	})
+	if err != nil {
+		return make(map[descpb.ID]tableSchemaInfo), err
path: pkg/ccl/changefeedccl/enriched_source_provider.go
user: login: asg0451
body: why not return nil, err?
created_at: 2025-04-08T21:45:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -371,3 +500,34 @@ func init() {
 }
 
 const originCockroachDB = "cockroachdb"
+
+func getDescriptors(
+	ctx context.Context, execCfg *sql.ExecutorConfig, tableID descpb.ID,
+) (catalog.TableDescriptor, catalog.DatabaseDescriptor, catalog.SchemaDescriptor, error) {
+	var tableDescriptor catalog.TableDescriptor
+	var dbDescriptor catalog.DatabaseDescriptor
+	var schemaDescriptor catalog.SchemaDescriptor
+	f := func(ctx context.Context, txn descs.Txn) error {
+		byIDGetter := txn.Descriptors().ByIDWithoutLeased(txn.KV()).WithoutNonPublic().Get()
path: pkg/ccl/changefeedccl/enriched_source_provider.go
user: login: asg0451
body: what does WithoutNonPublic mean here? are there some tables this would fail to see?
created_at: 2025-04-08T21:51:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -371,3 +500,34 @@ func init() {
 }
 
 const originCockroachDB = "cockroachdb"
+
+func getDescriptors(
+	ctx context.Context, execCfg *sql.ExecutorConfig, tableID descpb.ID,
+) (catalog.TableDescriptor, catalog.DatabaseDescriptor, catalog.SchemaDescriptor, error) {
+	var tableDescriptor catalog.TableDescriptor
+	var dbDescriptor catalog.DatabaseDescriptor
+	var schemaDescriptor catalog.SchemaDescriptor
+	f := func(ctx context.Context, txn descs.Txn) error {
+		byIDGetter := txn.Descriptors().ByIDWithoutLeased(txn.KV()).WithoutNonPublic().Get()
+		tableDesc, err := byIDGetter.Table(ctx, tableID)
+		if err != nil {
+			return err
+		}
+		dbDesc, err := byIDGetter.Database(ctx, tableDesc.GetParentID())
+		if err != nil {
+			return err
+		}
+		schemaDesc, err := byIDGetter.Schema(ctx, tableDesc.GetParentSchemaID())
+		if err != nil {
+			return err
+		}
+		tableDescriptor = tableDesc
path: pkg/ccl/changefeedccl/enriched_source_provider.go
user: login: asg0451
body: nit: predeclare err so you can set these three directly without an extra var
created_at: 2025-04-08T21:53:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -371,3 +500,34 @@ func init() {
 }
 
 const originCockroachDB = "cockroachdb"
+
+func getDescriptors(
+	ctx context.Context, execCfg *sql.ExecutorConfig, tableID descpb.ID,
+) (catalog.TableDescriptor, catalog.DatabaseDescriptor, catalog.SchemaDescriptor, error) {
+	var tableDescriptor catalog.TableDescriptor
+	var dbDescriptor catalog.DatabaseDescriptor
+	var schemaDescriptor catalog.SchemaDescriptor
+	f := func(ctx context.Context, txn descs.Txn) error {
+		byIDGetter := txn.Descriptors().ByIDWithoutLeased(txn.KV()).WithoutNonPublic().Get()
+		tableDesc, err := byIDGetter.Table(ctx, tableID)
+		if err != nil {
+			return err
+		}
+		dbDesc, err := byIDGetter.Database(ctx, tableDesc.GetParentID())
+		if err != nil {
+			return err
+		}
+		schemaDesc, err := byIDGetter.Schema(ctx, tableDesc.GetParentSchemaID())
+		if err != nil {
+			return err
+		}
+		tableDescriptor = tableDesc
+		dbDescriptor = dbDesc
+		schemaDescriptor = schemaDesc
+		return nil
+	}
+	if err := execCfg.InternalDB.DescsTxn(ctx, f, isql.WithPriority(admissionpb.LowPri)); err != nil {
path: pkg/ccl/changefeedccl/enriched_source_provider.go
user: login: asg0451
body: is this low priority? since this is required for the feed to start up and only run once, maybe it should be normal priority 
created_at: 2025-04-08T21:54:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -104,7 +105,14 @@ func newEventConsumer(
 	makeConsumer := func(s EventSink, frontier frontier) (eventConsumer, error) {
 		sourceData := enrichedSourceData{}
 		if encodingOpts.Envelope == changefeedbase.OptEnvelopeEnriched {
-			sourceData, err = newEnrichedSourceData(ctx, cfg, spec, sink.getConcreteType())
+			schemaInfo := make(map[descpb.ID]tableSchemaInfo)
path: pkg/ccl/changefeedccl/event_processing.go
user: login: asg0451
body: not: prefer var declaration here, unless empty vs nil is meaningful to your code, which it shouldn't be ideally  
created_at: 2025-04-08T21:55:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1577,14 +1579,26 @@ func ChangefeedJobPermissionsTestSetup(t *testing.T, s TestServer) {
 // getTestingEnrichedSourceData creates an enrichedSourceData
 // for use in tests.
 func getTestingEnrichedSourceData() enrichedSourceData {
+	primaryKeysBuilder := json.NewArrayBuilder(1)
+	primaryKeysBuilder.Add(json.FromString("test_primary_key"))
+
 	return enrichedSourceData{
 		jobID:              "test_id",
 		dbVersion:          "test_db_version",
-		clusterName:        "test_cluster_name",
path: pkg/ccl/changefeedccl/helpers_test.go
user: login: asg0451
body: why remove these?
created_at: 2025-04-08T21:56:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -371,3 +500,34 @@ func init() {
 }
 
 const originCockroachDB = "cockroachdb"
+
+func getDescriptors(
+	ctx context.Context, execCfg *sql.ExecutorConfig, tableID descpb.ID,
+) (catalog.TableDescriptor, catalog.DatabaseDescriptor, catalog.SchemaDescriptor, error) {
+	var tableDescriptor catalog.TableDescriptor
+	var dbDescriptor catalog.DatabaseDescriptor
+	var schemaDescriptor catalog.SchemaDescriptor
+	f := func(ctx context.Context, txn descs.Txn) error {
+		byIDGetter := txn.Descriptors().ByIDWithoutLeased(txn.KV()).WithoutNonPublic().Get()
path: pkg/ccl/changefeedccl/enriched_source_provider.go
user: login: aerfrei
body: The comment said "WithoutNonPublic configures the ByIDGetterBuilder to error on descriptors which are not in a public state: dropped, offline, etc." I thought it made sense that we would not want to see dropped or offline tables. If we try to start a changefeed against a dropped table, I'd think we wouldn't even successfully start the changefeed anyway. What do you think?
created_at: 2025-04-08T22:40:52Z
author_association: CONTRIBUTOR

PR comments:
user: login: cockroach-teamcity
created_at: 2025-03-25T15:02:22Z
body: 

<!-- Reviewable:start -->
This change is [<img src="https://reviewable.io/review_button.svg" height="34" align="absmiddle" alt="Reviewable"/>](https://reviewable.io/reviews/cockroachdb/cockroach/143422)
<!-- Reviewable:end -->

author_association: MEMBER
user: login: aerfrei
created_at: 2025-03-26T15:09:20Z
body: Extended CI failure seems related. Seems like leaktest.AfterTest(t)() is finding something. Probably a leaking goroutine. Not sure how this would be introduced only in TestChangefeedEnriched but not TestChangefeedEnrichedSourceWithData. Trying to investigate. Any ideas?
author_association: CONTRIBUTOR
user: login: blathers-crl[bot]
created_at: 2025-04-08T03:45:19Z
body: Your pull request contains more than 1000 changes. It is strongly encouraged to split big PRs into smaller chunks.

<sub>:owl: Hoot! I am a [Blathers](https://github.com/apps/blathers-crl), a bot for [CockroachDB](https://github.com/cockroachdb). My owner is [dev-inf](https://github.com/orgs/cockroachdb/teams/dev-inf).</sub>
author_association: NONE
user: login: aerfrei
created_at: 2025-04-08T21:02:31Z
body: After identifying the extended CI failure as stemming from the pubsub test feed, we filed this issue: https://github.com/cockroachdb/cockroach/issues/144102. Failure seems test only so we can merge anyway. PR should be ready to merge.
author_association: CONTRIBUTOR
user: login: aerfrei
created_at: 2025-04-08T21:05:35Z
body: Note that we're keeping in the changes to (p *pubsubFeed).Close() since they seem like improvements. 
I also left in splitting the huge test that verifies the source data (TestChangefeedEnrichedSourceWithData) since it will make it clearer if that fails on non-pubsub sinks.
author_association: CONTRIBUTOR
user: login: aerfrei
created_at: 2025-04-09T01:33:39Z
body: bors r=asg0451
author_association: CONTRIBUTOR
user: login: craig[bot]
created_at: 2025-04-09T02:07:41Z
body: Build failed (retrying...):
  * [check_generated_code](https://github.com/cockroachdb/cockroach/actions/runs/14347343153/job/40219536737)
author_association: CONTRIBUTOR
user: login: craig[bot]
created_at: 2025-04-09T02:38:22Z
body: Build succeeded:
  * [acceptance](https://github.com/cockroachdb/cockroach/actions/runs/14347398397/job/40219683054)
  * [docker_image_amd64](https://github.com/cockroachdb/cockroach/actions/runs/14347398397/job/40219682812)
  * [linux_amd64_build](https://github.com/cockroachdb/cockroach/actions/runs/14347398397/job/40219683894)
  * [examples_orms](https://github.com/cockroachdb/cockroach/actions/runs/14347398397/job/40219683327)
  * [check_generated_code](https://github.com/cockroachdb/cockroach/actions/runs/14347398397/job/40219683552)
  * [unit_tests](https://github.com/cockroachdb/cockroach/actions/runs/14347398397/job/40219684559)
  * [linux_amd64_fips_build](https://github.com/cockroachdb/cockroach/actions/runs/14347398397/job/40219684071)
  * [local_roachtest_fips](https://github.com/cockroachdb/cockroach/actions/runs/14347398397/job/40219684406)
  * [local_roachtest](https://github.com/cockroachdb/cockroach/actions/runs/14347398397/job/40219684233)
  * [lint](https://github.com/cockroachdb/cockroach/actions/runs/14347398397/job/40219683723)
author_association: CONTRIBUTOR