@@ -23,14 +23,35 @@ const ExporterVersion = "1.0.0"
2323
2424var systemDatabases = []string {"system" , "crdb_internal" , "postgres" }
2525
26+ // TenantScope indicates which virtual cluster a table or query should be routed to.
27+ // In non-virtualized clusters, all queries use the single connection regardless of scope.
28+ type TenantScope string
29+
30+ const (
31+ // TenantScopeMain routes the query to the main (application) virtual cluster.
32+ // This is the default when no scope is specified.
33+ TenantScopeMain TenantScope = "main"
34+ // TenantScopeSystem routes the query to the system virtual cluster.
35+ // Used for cluster-wide data not available in application virtual clusters,
36+ // such as gossip_nodes. Auto-detection occurs on first failure.
37+ TenantScopeSystem TenantScope = "system"
38+ // TenantScopeBoth routes the query to both virtual clusters.
39+ // Reserved for future use (e.g., cluster settings available in both tenants).
40+ TenantScopeBoth TenantScope = "both"
41+ )
42+
2643// Exporter handles the export of workload data from a CockroachDB cluster.
2744// It manages database connections and coordinates the export of statistics,
2845// schemas, and configurations into a zip file.
2946type Exporter struct {
3047 // Config contains the export configuration settings
3148 Config Config
32- // Db is the active database connection to the CockroachDB cluster
49+ // Db is the active database connection to the CockroachDB cluster (main virtual cluster)
3350 Db * pgx.Conn
51+ // SystemDb is a connection to the system virtual cluster, established lazily when a
52+ // TenantScopeSystem query fails against Db with a virtual cluster error. Nil in
53+ // non-virtualized clusters.
54+ SystemDb * pgx.Conn
3455 // CleanConnectionString is the connection string with password redacted
3556 CleanConnectionString string
3657}
@@ -65,6 +86,7 @@ type Metadata struct {
6586 Organization string `json:"organization"`
6687 SqlStatsAggregationInterval time.Duration `json:"sql.stats.aggregation.interval"`
6788 SqlStatsFlushInterval time.Duration `json:"sql.stats.flush.interval"`
89+ VirtualCluster bool `json:"virtual_cluster"`
6890}
6991
7092// Table represents a CockroachDB table to be exported with optional time-based filtering.
@@ -78,15 +100,18 @@ type Table struct {
78100 // Optional indicates that export failures should be logged as warnings rather than errors.
79101 // Use this for tables that may not be available in all cluster configurations (e.g. Cloud virtual clusters).
80102 Optional bool
103+ // Scope indicates which virtual cluster connection to use for this table.
104+ // Defaults to TenantScopeMain when unset.
105+ Scope TenantScope
81106}
82107
83108var exportTables = []Table {
84- Table {Database : "crdb_internal" , Name : "statement_statistics" , TimeColumn : "aggregated_ts" },
85- Table {Database : "crdb_internal" , Name : "transaction_statistics" , TimeColumn : "aggregated_ts" },
86- Table {Database : "crdb_internal" , Name : "transaction_contention_events" , TimeColumn : "collection_ts" },
87- Table {Database : "crdb_internal" , Name : "gossip_nodes" , TimeColumn : "" , Optional : true },
88- Table {Database : "" , Name : "crdb_internal.table_indexes" , TimeColumn : "" }, // Use "" to query across all databases
89- Table {Database : "system" , Name : "table_statistics" , TimeColumn : "" },
109+ {Database : "crdb_internal" , Name : "statement_statistics" , TimeColumn : "aggregated_ts" , Scope : TenantScopeMain },
110+ {Database : "crdb_internal" , Name : "transaction_statistics" , TimeColumn : "aggregated_ts" , Scope : TenantScopeMain },
111+ {Database : "crdb_internal" , Name : "transaction_contention_events" , TimeColumn : "collection_ts" , Scope : TenantScopeMain },
112+ {Database : "crdb_internal" , Name : "gossip_nodes" , TimeColumn : "" , Optional : true , Scope : TenantScopeSystem },
113+ {Database : "" , Name : "crdb_internal.table_indexes" , TimeColumn : "" , Scope : TenantScopeMain }, // Use "" to query across all databases
114+ {Database : "system" , Name : "table_statistics" , TimeColumn : "" , Scope : TenantScopeMain },
90115}
91116
92117// NewExporter creates a new Exporter instance with the given configuration.
@@ -148,6 +173,9 @@ func NewExporter(config Config) (*Exporter, error) {
148173// }
149174// defer exporter.Close()
150175func (exporter * Exporter ) Close () error {
176+ if exporter .SystemDb != nil {
177+ _ = exporter .SystemDb .Close (context .Background ())
178+ }
151179 if exporter .Db != nil {
152180 return exporter .Db .Close (context .Background ())
153181 }
@@ -263,6 +291,8 @@ func (exporter *Exporter) Export() error {
263291 }
264292 logrus .Info ("finished table export" )
265293
294+ metadata .VirtualCluster = exporter .SystemDb != nil
295+
266296 metadataFile := filepath .Join (tempDir , "metadata.json" )
267297 metadataJSON , err := json .MarshalIndent (metadata , "" , " " )
268298 if err != nil {
@@ -441,7 +471,34 @@ func (exporter *Exporter) userDatabases() ([]string, error) {
441471 return databases , nil
442472}
443473
474+ // exportTable routes the table export to the appropriate virtual cluster connection
475+ // based on the table's Scope. For TenantScopeSystem tables, it first attempts the
476+ // export using the main connection; if CockroachDB returns a virtual cluster error,
477+ // it establishes a system connection and retries automatically.
444478func (exporter * Exporter ) exportTable (ctx context.Context , dir string , table Table , aggregationInterval time.Duration ) error {
479+ scope := table .Scope
480+ if scope == "" {
481+ scope = TenantScopeMain
482+ }
483+
484+ conn := exporter .Db
485+ if scope == TenantScopeSystem && exporter .SystemDb != nil {
486+ conn = exporter .SystemDb
487+ }
488+
489+ err := exporter .doExportTable (ctx , dir , table , aggregationInterval , conn )
490+ if err != nil && scope == TenantScopeSystem && isVirtualClusterError (err ) {
491+ systemConn , connErr := exporter .ensureSystemConn (ctx )
492+ if connErr != nil {
493+ return fmt .Errorf ("failed to connect to system virtual cluster: %w" , connErr )
494+ }
495+ return exporter .doExportTable (ctx , dir , table , aggregationInterval , systemConn )
496+ }
497+ return err
498+ }
499+
500+ // doExportTable performs the actual table export using the provided connection.
501+ func (exporter * Exporter ) doExportTable (ctx context.Context , dir string , table Table , aggregationInterval time.Duration , conn * pgx.Conn ) error {
445502 // Create filename - if database is empty, just use table name
446503 var filename string
447504 if table .Database == "" {
@@ -463,7 +520,6 @@ func (exporter *Exporter) exportTable(ctx context.Context, dir string, table Tab
463520 }
464521 }(file )
465522
466- // Get column names
467523 // Construct table reference - handle empty database for cross-database queries
468524 var tableRef string
469525 if table .Database == "" {
@@ -473,7 +529,8 @@ func (exporter *Exporter) exportTable(ctx context.Context, dir string, table Tab
473529 tableRef = fmt .Sprintf ("%s.%s" , pgx.Identifier {table .Database }.Sanitize (), pgx.Identifier {table .Name }.Sanitize ())
474530 }
475531
476- rows , err := exporter .Db .Query (ctx , fmt .Sprintf ("SELECT * FROM %s LIMIT 0" , tableRef ))
532+ // Get column names
533+ rows , err := conn .Query (ctx , fmt .Sprintf ("SELECT * FROM %s LIMIT 0" , tableRef ))
477534 if err != nil {
478535 return err
479536 }
@@ -505,7 +562,7 @@ func (exporter *Exporter) exportTable(ctx context.Context, dir string, table Tab
505562 "COPY (SELECT * FROM %s %s) TO STDOUT WITH CSV" ,
506563 tableRef , where )
507564 logrus .Info (copyQuery )
508- _ , err = exporter . Db .PgConn ().CopyTo (ctx , file , copyQuery )
565+ _ , err = conn .PgConn ().CopyTo (ctx , file , copyQuery )
509566 if err != nil {
510567 return err
511568 }
@@ -625,6 +682,57 @@ func enableUnsafeInternalsIfNeeded(ctx context.Context, conn *pgx.Conn) error {
625682 return nil
626683}
627684
685+ // isVirtualClusterError returns true if the error indicates an operation is unsupported
686+ // within an application virtual cluster. When this occurs for a TenantScopeSystem table,
687+ // the exporter will retry the query against the system virtual cluster.
688+ //
689+ // The error string "operation is unsupported within a virtual cluster" is produced by
690+ // CockroachDB when an application tenant attempts to access system-only resources.
691+ func isVirtualClusterError (err error ) bool {
692+ return err != nil && strings .Contains (err .Error (), "operation is unsupported within a virtual cluster" )
693+ }
694+
695+ // buildSystemConnectionString derives a system virtual cluster connection string from
696+ // an existing connection string by appending options=-ccluster=system. If the connection
697+ // string already contains an options parameter, the cluster option is appended to it.
698+ func buildSystemConnectionString (connStr string ) (string , error ) {
699+ u , err := url .Parse (connStr )
700+ if err != nil {
701+ return "" , fmt .Errorf ("failed to parse connection string: %w" , err )
702+ }
703+ q := u .Query ()
704+ if existing := q .Get ("options" ); existing != "" {
705+ q .Set ("options" , existing + " -ccluster=system" )
706+ } else {
707+ q .Set ("options" , "-ccluster=system" )
708+ }
709+ u .RawQuery = q .Encode ()
710+ return u .String (), nil
711+ }
712+
713+ // ensureSystemConn returns the system virtual cluster connection, creating it if needed.
714+ // It is called lazily when a TenantScopeSystem query fails with a virtual cluster error.
715+ func (exporter * Exporter ) ensureSystemConn (ctx context.Context ) (* pgx.Conn , error ) {
716+ if exporter .SystemDb != nil {
717+ return exporter .SystemDb , nil
718+ }
719+ systemConnStr , err := buildSystemConnectionString (exporter .Config .ConnectionString )
720+ if err != nil {
721+ return nil , fmt .Errorf ("failed to build system connection string: %w" , err )
722+ }
723+ logrus .Info ("detected virtual cluster, connecting to system virtual cluster" )
724+ conn , err := pgx .Connect (ctx , systemConnStr )
725+ if err != nil {
726+ return nil , fmt .Errorf ("failed to connect to system virtual cluster: %w" , err )
727+ }
728+ if err := enableUnsafeInternalsIfNeeded (ctx , conn ); err != nil {
729+ _ = conn .Close (ctx )
730+ return nil , err
731+ }
732+ exporter .SystemDb = conn
733+ return conn , nil
734+ }
735+
628736// parseMajorVersion extracts the major version number from a CockroachDB version string.
629737// Example: "CockroachDB CCL v26.1.0-beta.3 ..." -> 26
630738func parseMajorVersion (versionStr string ) (int , error ) {
0 commit comments