[TreeStatus] Add proto + set up BigQuery Client
I also moved the Client creation function to LUCI Common and updated LUCI Analysis, ResultDB and Bisection accordingly.
Change-Id: I4345fcb756c3df4e2186b589b6374555199fe84c
Reviewed-on: https://chromium-review.googlesource.com/c/infra/luci/luci-go/+/5767753
Commit-Queue: Tuan Nguyen <nqmtuan@google.com>
Reviewed-by: Patrick Meiring <meiring@google.com>
diff --git a/analysis/internal/analysis/client.go b/analysis/internal/analysis/client.go
index bd479f8..81b3fe0 100644
--- a/analysis/internal/analysis/client.go
+++ b/analysis/internal/analysis/client.go
@@ -22,6 +22,7 @@
"cloud.google.com/go/bigquery"
"google.golang.org/api/iterator"
+ "go.chromium.org/luci/common/bq"
"go.chromium.org/luci/common/errors"
"go.chromium.org/luci/analysis/internal/bqutil"
@@ -34,7 +35,7 @@
// NewClient creates a new client for reading clusters. Close() MUST
// be called after you have finished using this client.
func NewClient(ctx context.Context, gcpProject string) (*Client, error) {
- client, err := bqutil.Client(ctx, gcpProject)
+ client, err := bq.NewClient(ctx, gcpProject)
if err != nil {
return nil, err
}
diff --git a/analysis/internal/analysis/clusteredfailures/client.go b/analysis/internal/analysis/clusteredfailures/client.go
index 7a16781..50c5031 100644
--- a/analysis/internal/analysis/clusteredfailures/client.go
+++ b/analysis/internal/analysis/clusteredfailures/client.go
@@ -36,7 +36,7 @@
return nil, errors.New("GCP Project must be specified")
}
- bqClient, err := bqutil.Client(ctx, projectID)
+ bqClient, err := bq.NewClient(ctx, projectID)
if err != nil {
return nil, errors.Annotate(err, "creating BQ client").Err()
}
diff --git a/analysis/internal/bqutil/insert.go b/analysis/internal/bqutil/insert.go
index 4f66ef3..ff9e2e0 100644
--- a/analysis/internal/bqutil/insert.go
+++ b/analysis/internal/bqutil/insert.go
@@ -20,32 +20,13 @@
"cloud.google.com/go/bigquery"
"google.golang.org/api/googleapi"
- "google.golang.org/api/option"
"go.chromium.org/luci/common/bq"
"go.chromium.org/luci/common/errors"
"go.chromium.org/luci/common/retry"
"go.chromium.org/luci/common/retry/transient"
- "go.chromium.org/luci/server/auth"
)
-// Client returns a new BigQuery client for use with the given GCP project,
-// that authenticates as LUCI Analysis itself. Only use this method if the
-// specification of the BigQuery dataset to access is not under the
-// control of the project (e.g. via configuration).
-func Client(ctx context.Context, gcpProject string) (*bigquery.Client, error) {
- if gcpProject == "" {
- return nil, errors.New("GCP Project must be specified")
- }
- tr, err := auth.GetRPCTransport(ctx, auth.AsSelf, auth.WithScopes(bigquery.Scope))
- if err != nil {
- return nil, err
- }
- return bigquery.NewClient(ctx, gcpProject, option.WithHTTPClient(&http.Client{
- Transport: tr,
- }))
-}
-
// Inserter provides methods to insert rows into a BigQuery table.
type Inserter struct {
table *bigquery.Table
diff --git a/analysis/internal/changepoints/bqclient.go b/analysis/internal/changepoints/bqclient.go
index 5e40190..a17d402 100644
--- a/analysis/internal/changepoints/bqclient.go
+++ b/analysis/internal/changepoints/bqclient.go
@@ -22,15 +22,15 @@
"go.opentelemetry.io/otel/attribute"
"google.golang.org/api/iterator"
+ "go.chromium.org/luci/common/bq"
"go.chromium.org/luci/common/errors"
- "go.chromium.org/luci/analysis/internal/bqutil"
"go.chromium.org/luci/analysis/internal/tracing"
)
// NewClient creates a new client for reading changepints.
func NewClient(ctx context.Context, gcpProject string) (*Client, error) {
- client, err := bqutil.Client(ctx, gcpProject)
+ client, err := bq.NewClient(ctx, gcpProject)
if err != nil {
return nil, err
}
diff --git a/analysis/internal/changepoints/bqexporter/client.go b/analysis/internal/changepoints/bqexporter/client.go
index ada468f..5d0f0bb 100644
--- a/analysis/internal/changepoints/bqexporter/client.go
+++ b/analysis/internal/changepoints/bqexporter/client.go
@@ -37,7 +37,7 @@
return nil, errors.New("GCP Project must be specified")
}
- bqClient, err := bqutil.Client(ctx, projectID)
+ bqClient, err := bq.NewClient(ctx, projectID)
if err != nil {
return nil, errors.Annotate(err, "creating BQ client").Err()
}
diff --git a/analysis/internal/changepoints/bqexporter/merge_table.go b/analysis/internal/changepoints/bqexporter/merge_table.go
index 51bcc41..24b610e 100644
--- a/analysis/internal/changepoints/bqexporter/merge_table.go
+++ b/analysis/internal/changepoints/bqexporter/merge_table.go
@@ -41,7 +41,7 @@
return nil
}
- client, err := bqutil.Client(ctx, gcpProject)
+ client, err := bq.NewClient(ctx, gcpProject)
if err != nil {
return errors.Annotate(err, "create bq client").Err()
}
diff --git a/analysis/internal/changepoints/bqupdater/update_changepoints_table.go b/analysis/internal/changepoints/bqupdater/update_changepoints_table.go
index 2673c2e..af2b99b 100644
--- a/analysis/internal/changepoints/bqupdater/update_changepoints_table.go
+++ b/analysis/internal/changepoints/bqupdater/update_changepoints_table.go
@@ -30,7 +30,7 @@
// UpdateChangepointTable is the entry point of the update-changepoint-table cron job.
// It runs DDL to create or replace the test_variant_changepoints table.
func UpdateChangepointTable(ctx context.Context, gcpProject string) (retErr error) {
- client, err := bqutil.Client(ctx, gcpProject)
+ client, err := bq.NewClient(ctx, gcpProject)
if err != nil {
return errors.Annotate(err, "create bq client").Err()
}
diff --git a/analysis/internal/clustering/rules/exporter/client.go b/analysis/internal/clustering/rules/exporter/client.go
index a42499f..6f47807 100644
--- a/analysis/internal/clustering/rules/exporter/client.go
+++ b/analysis/internal/clustering/rules/exporter/client.go
@@ -46,7 +46,7 @@
if projectID == "" {
return nil, errors.New("GCP Project must be specified")
}
- bqClient, err := bqutil.Client(ctx, projectID)
+ bqClient, err := bq.NewClient(ctx, projectID)
if err != nil {
return nil, errors.Annotate(err, "creating BQ client").Err()
}
diff --git a/analysis/internal/failureattributes/client.go b/analysis/internal/failureattributes/client.go
index 5b204ea..7e60978 100644
--- a/analysis/internal/failureattributes/client.go
+++ b/analysis/internal/failureattributes/client.go
@@ -19,6 +19,7 @@
"cloud.google.com/go/bigquery"
+ "go.chromium.org/luci/common/bq"
"go.chromium.org/luci/common/errors"
"go.chromium.org/luci/analysis/internal/bqutil"
@@ -31,7 +32,7 @@
return nil, errors.New("GCP Project must be specified")
}
- bqClient, err := bqutil.Client(ctx, projectID)
+ bqClient, err := bq.NewClient(ctx, projectID)
if err != nil {
return nil, errors.Annotate(err, "creating BQ client").Err()
}
diff --git a/analysis/internal/services/backfill/backfill.go b/analysis/internal/services/backfill/backfill.go
index 5a0c6ab..de83529 100644
--- a/analysis/internal/services/backfill/backfill.go
+++ b/analysis/internal/services/backfill/backfill.go
@@ -34,7 +34,6 @@
"go.chromium.org/luci/server"
"go.chromium.org/luci/server/tq"
- "go.chromium.org/luci/analysis/internal/bqutil"
"go.chromium.org/luci/analysis/internal/tasks/taskspb"
)
@@ -58,7 +57,7 @@
// RegisterTaskHandler registers the handler for backfill tasks.
func RegisterTaskHandler(srv *server.Server) error {
- client, err := bqutil.Client(srv.Context, srv.Options.CloudProject)
+ client, err := bq.NewClient(srv.Context, srv.Options.CloudProject)
if err != nil {
return err
}
diff --git a/analysis/internal/testresults/exporter/client.go b/analysis/internal/testresults/exporter/client.go
index f536e47..79f0fe2 100644
--- a/analysis/internal/testresults/exporter/client.go
+++ b/analysis/internal/testresults/exporter/client.go
@@ -36,7 +36,7 @@
return nil, errors.New("GCP Project must be specified")
}
- bqClient, err := bqutil.Client(ctx, projectID)
+ bqClient, err := bq.NewClient(ctx, projectID)
if err != nil {
return nil, errors.Annotate(err, "creating BQ client").Err()
}
diff --git a/analysis/internal/testresults/read_client.go b/analysis/internal/testresults/read_client.go
index 14a5098..66e9631 100644
--- a/analysis/internal/testresults/read_client.go
+++ b/analysis/internal/testresults/read_client.go
@@ -21,15 +21,14 @@
"cloud.google.com/go/bigquery"
"google.golang.org/api/iterator"
+ "go.chromium.org/luci/common/bq"
"go.chromium.org/luci/common/errors"
"go.chromium.org/luci/server/auth/realms"
-
- "go.chromium.org/luci/analysis/internal/bqutil"
)
// NewReadClient creates a new client for reading test results BigQuery table.
func NewReadClient(ctx context.Context, gcpProject string) (*ReadClient, error) {
- client, err := bqutil.Client(ctx, gcpProject)
+ client, err := bq.NewClient(ctx, gcpProject)
if err != nil {
return nil, err
}
diff --git a/analysis/internal/testverdicts/client.go b/analysis/internal/testverdicts/client.go
index 71ec321..8bcae5c 100644
--- a/analysis/internal/testverdicts/client.go
+++ b/analysis/internal/testverdicts/client.go
@@ -36,7 +36,7 @@
return nil, errors.New("GCP Project must be specified")
}
- bqClient, err := bqutil.Client(ctx, projectID)
+ bqClient, err := bq.NewClient(ctx, projectID)
if err != nil {
return nil, errors.Annotate(err, "creating BQ client").Err()
}
diff --git a/analysis/internal/testverdicts/read_verdict_after_position.go b/analysis/internal/testverdicts/read_verdict_after_position.go
index ff944f4..5daa262 100644
--- a/analysis/internal/testverdicts/read_verdict_after_position.go
+++ b/analysis/internal/testverdicts/read_verdict_after_position.go
@@ -21,14 +21,13 @@
"cloud.google.com/go/bigquery"
"google.golang.org/api/iterator"
+ "go.chromium.org/luci/common/bq"
"go.chromium.org/luci/common/errors"
-
- "go.chromium.org/luci/analysis/internal/bqutil"
)
// NewReadClient creates a new client for reading from test verdicts BigQuery table.
func NewReadClient(ctx context.Context, gcpProject string) (*ReadClient, error) {
- client, err := bqutil.Client(ctx, gcpProject)
+ client, err := bq.NewClient(ctx, gcpProject)
if err != nil {
return nil, err
}
diff --git a/analysis/internal/views/ensure_views_cron.go b/analysis/internal/views/ensure_views_cron.go
index d36c0dc..2f4e847 100644
--- a/analysis/internal/views/ensure_views_cron.go
+++ b/analysis/internal/views/ensure_views_cron.go
@@ -199,7 +199,7 @@
// CronHandler is then entry-point for the ensure views cron job.
func CronHandler(ctx context.Context, gcpProject string) (retErr error) {
- client, err := bqutil.Client(ctx, gcpProject)
+ client, err := bq.NewClient(ctx, gcpProject)
if err != nil {
return errors.Annotate(err, "create bq client").Err()
}
diff --git a/bisection/bqexporter/client.go b/bisection/bqexporter/client.go
index 65b62e8..55e1161 100644
--- a/bisection/bqexporter/client.go
+++ b/bisection/bqexporter/client.go
@@ -39,7 +39,7 @@
return nil, errors.New("GCP Project must be specified")
}
- bqClient, err := bqutil.Client(ctx, projectID)
+ bqClient, err := bq.NewClient(ctx, projectID)
if err != nil {
return nil, errors.Annotate(err, "creating BQ client").Err()
}
diff --git a/bisection/bqexporter/ensure_views.go b/bisection/bqexporter/ensure_views.go
index 722939a..8bb91b2 100644
--- a/bisection/bqexporter/ensure_views.go
+++ b/bisection/bqexporter/ensure_views.go
@@ -46,7 +46,7 @@
logging.Warningf(ctx, "ensure view is not enabled")
}
- client, err := bqutil.Client(ctx, info.AppID(ctx))
+ client, err := bq.NewClient(ctx, info.AppID(ctx))
if err != nil {
return errors.Annotate(err, "create bq client").Err()
}
diff --git a/bisection/util/bqutil/client.go b/common/bq/client.go
similarity index 79%
rename from bisection/util/bqutil/client.go
rename to common/bq/client.go
index 497b815..f221767 100644
--- a/bisection/util/bqutil/client.go
+++ b/common/bq/client.go
@@ -1,4 +1,4 @@
-// Copyright 2023 The LUCI Authors.
+// Copyright 2024 The LUCI Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -12,22 +12,21 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-package bqutil
+package bq
import (
"context"
"net/http"
"cloud.google.com/go/bigquery"
- "google.golang.org/api/option"
-
"go.chromium.org/luci/common/errors"
"go.chromium.org/luci/server/auth"
+ "google.golang.org/api/option"
)
-// Client returns a new BigQuery client for use with the given GCP project,
-// that authenticates as LUCI Bisection.
-func Client(ctx context.Context, gcpProject string) (*bigquery.Client, error) {
+// NewClient returns a new BigQuery client for use with the given GCP project,
+// that authenticates as the LUCI service itself.
+func NewClient(ctx context.Context, gcpProject string) (*bigquery.Client, error) {
if gcpProject == "" {
return nil, errors.New("GCP Project must be specified")
}
diff --git a/resultdb/bqutil/client.go b/resultdb/bqutil/client.go
deleted file mode 100644
index d166cf1..0000000
--- a/resultdb/bqutil/client.go
+++ /dev/null
@@ -1,43 +0,0 @@
-// Copyright 2024 The LUCI Authors.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-// Package bqutil contains utility functions for BigQuery.
-package bqutil
-
-import (
- "context"
- "net/http"
-
- "cloud.google.com/go/bigquery"
- "google.golang.org/api/option"
-
- "go.chromium.org/luci/common/errors"
- "go.chromium.org/luci/server/auth"
-)
-
-// TODO (nqmtuan): Share the code with LUCI Analysis.
-// Client returns a new BigQuery client for use with the given GCP project,
-// that authenticates as ResultDB.
-func Client(ctx context.Context, gcpProject string) (*bigquery.Client, error) {
- if gcpProject == "" {
- return nil, errors.New("GCP Project must be specified")
- }
- tr, err := auth.GetRPCTransport(ctx, auth.AsSelf, auth.WithScopes(bigquery.Scope))
- if err != nil {
- return nil, err
- }
- return bigquery.NewClient(ctx, gcpProject, option.WithHTTPClient(&http.Client{
- Transport: tr,
- }))
-}
diff --git a/resultdb/internal/artifacts/bqclient.go b/resultdb/internal/artifacts/bqclient.go
index f85fcb5..0881fd3 100644
--- a/resultdb/internal/artifacts/bqclient.go
+++ b/resultdb/internal/artifacts/bqclient.go
@@ -26,9 +26,9 @@
"cloud.google.com/go/bigquery"
"google.golang.org/api/iterator"
+ "go.chromium.org/luci/common/bq"
"go.chromium.org/luci/common/errors"
- "go.chromium.org/luci/resultdb/bqutil"
"go.chromium.org/luci/resultdb/internal/pagination"
pb "go.chromium.org/luci/resultdb/proto/v1"
)
@@ -40,7 +40,7 @@
// NewClient creates a new client for reading text_artifacts table.
func NewClient(ctx context.Context, gcpProject string) (*Client, error) {
- client, err := bqutil.Client(ctx, gcpProject)
+ client, err := bq.NewClient(ctx, gcpProject)
if err != nil {
return nil, err
}
diff --git a/resultdb/internal/ensureviews/ensure_views.go b/resultdb/internal/ensureviews/ensure_views.go
index 7dc8c5c..e5ddae6 100644
--- a/resultdb/internal/ensureviews/ensure_views.go
+++ b/resultdb/internal/ensureviews/ensure_views.go
@@ -53,7 +53,7 @@
// CronHandler is then entry-point for the ensure views cron job.
func CronHandler(ctx context.Context, gcpProject string) error {
- client, err := bqutil.Client(ctx, gcpProject)
+ client, err := bq.NewClient(ctx, gcpProject)
if err != nil {
return errors.Annotate(err, "create bq client").Err()
}
diff --git a/resultdb/internal/services/artifactexporter/client.go b/resultdb/internal/services/artifactexporter/client.go
index ce89c62..ecc28c5 100644
--- a/resultdb/internal/services/artifactexporter/client.go
+++ b/resultdb/internal/services/artifactexporter/client.go
@@ -37,7 +37,7 @@
return nil, errors.New("GCP Project must be specified")
}
- bqClient, err := bqutil.Client(ctx, projectID)
+ bqClient, err := bq.NewClient(ctx, projectID)
if err != nil {
return nil, errors.Annotate(err, "creating BQ client").Err()
}
diff --git a/resultdb/internal/services/bqexporter/invocation_client.go b/resultdb/internal/services/bqexporter/invocation_client.go
index b8072d3..e767743 100644
--- a/resultdb/internal/services/bqexporter/invocation_client.go
+++ b/resultdb/internal/services/bqexporter/invocation_client.go
@@ -101,7 +101,7 @@
return nil, errors.New("GCP project must be specified")
}
- bqClient, err := bqutil.Client(ctx, projectID)
+ bqClient, err := bq.NewClient(ctx, projectID)
if err != nil {
return nil, errors.Annotate(err, "creating BQ client").Err()
}
diff --git a/resultdb/internal/services/resultdb/resultdb.go b/resultdb/internal/services/resultdb/resultdb.go
index f2e5024..7235c8f 100644
--- a/resultdb/internal/services/resultdb/resultdb.go
+++ b/resultdb/internal/services/resultdb/resultdb.go
@@ -23,6 +23,7 @@
sppb "cloud.google.com/go/spanner/apiv1/spannerpb"
"google.golang.org/genproto/googleapis/bytestream"
+ "go.chromium.org/luci/common/bq"
"go.chromium.org/luci/common/data/stringset"
"go.chromium.org/luci/common/errors"
"go.chromium.org/luci/grpc/prpc"
@@ -30,7 +31,6 @@
"go.chromium.org/luci/server/cron"
"go.chromium.org/luci/server/gerritauth"
- "go.chromium.org/luci/resultdb/bqutil"
"go.chromium.org/luci/resultdb/internal"
"go.chromium.org/luci/resultdb/internal/artifactcontent"
"go.chromium.org/luci/resultdb/internal/artifacts"
@@ -84,7 +84,7 @@
contentServer.InstallHandlers(srv.VirtualHost(host))
}
- bqClient, err := bqutil.Client(srv.Context, srv.Options.CloudProject)
+ bqClient, err := bq.NewClient(srv.Context, srv.Options.CloudProject)
if err != nil {
return errors.Annotate(err, "creating BQ client").Err()
}
diff --git a/tree_status/bqutil/dataset.go b/tree_status/bqutil/dataset.go
new file mode 100644
index 0000000..eed4209
--- /dev/null
+++ b/tree_status/bqutil/dataset.go
@@ -0,0 +1,20 @@
+// Copyright 2024 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package bqutil provides utility functions to interact with BigQuery.
+package bqutil
+
+// InternalDatasetID is the name of the BigQuery dataset which is intended
+// for internal service use only.
+const InternalDatasetID = "internal"
diff --git a/tree_status/internal/bqexporter/client.go b/tree_status/internal/bqexporter/client.go
new file mode 100644
index 0000000..86c3b6d
--- /dev/null
+++ b/tree_status/internal/bqexporter/client.go
@@ -0,0 +1,111 @@
+// Copyright 2024 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package bqexporter
+
+import (
+ "context"
+ "fmt"
+
+ "cloud.google.com/go/bigquery"
+ "cloud.google.com/go/bigquery/storage/managedwriter"
+ "google.golang.org/protobuf/proto"
+
+ "go.chromium.org/luci/common/bq"
+ "go.chromium.org/luci/common/errors"
+
+ "go.chromium.org/luci/tree_status/bqutil"
+ bqpb "go.chromium.org/luci/tree_status/proto/bq"
+)
+
+// NewClient creates a new client for exporting statuses
+// via the BigQuery Write API.
+func NewClient(ctx context.Context, projectID string) (s *Client, reterr error) {
+ if projectID == "" {
+ return nil, errors.New("GCP Project must be specified")
+ }
+
+ bqClient, err := bq.NewClient(ctx, projectID)
+ if err != nil {
+ return nil, errors.Annotate(err, "creating BQ client").Err()
+ }
+ defer func() {
+ if reterr != nil {
+ // This method failed for some reason, clean up the
+ // BigQuery client. Swallow any error returned by the Close()
+ // call.
+ bqClient.Close()
+ }
+ }()
+
+ mwClient, err := bq.NewWriterClient(ctx, projectID)
+ if err != nil {
+ return nil, errors.Annotate(err, "creating managed writer client").Err()
+ }
+ return &Client{
+ projectID: projectID,
+ bqClient: bqClient,
+ mwClient: mwClient,
+ }, nil
+}
+
+// Close releases resources held by the client.
+func (c *Client) Close() (reterr error) {
+ // Ensure both bqClient and mwClient Close() methods
+ // are called, even if one panics or fails.
+ defer func() {
+ err := c.mwClient.Close()
+ if reterr == nil {
+ reterr = err
+ }
+ }()
+ return c.bqClient.Close()
+}
+
+// Client provides methods to export statuses to BigQuery
+// via the BigQuery Write API.
+type Client struct {
+ // projectID is the name of the GCP project that contains Tree Status
+ // BigQuery datasets.
+ projectID string
+ bqClient *bigquery.Client
+ mwClient *managedwriter.Client
+}
+
+// schemaApplier ensures BQ schema matches the row proto definitions.
+var schemaApplyer = bq.NewSchemaApplyer(bq.RegisterSchemaApplyerCache(1))
+
+func (c *Client) EnsureSchema(ctx context.Context) error {
+ table := c.bqClient.Dataset(bqutil.InternalDatasetID).Table(tableName)
+ if err := schemaApplyer.EnsureTable(ctx, table, tableMetadata, bq.UpdateMetadata()); err != nil {
+ return errors.Annotate(err, "ensuring statuses table").Err()
+ }
+ return nil
+}
+
+// InsertStatusRows inserts the given rows in BigQuery.
+func (c *Client) InsertStatusRows(ctx context.Context, rows []*bqpb.StatusRow) error {
+ if err := c.EnsureSchema(ctx); err != nil {
+ return errors.Annotate(err, "ensure schema").Err()
+ }
+ tableName := fmt.Sprintf("projects/%s/datasets/%s/tables/%s", c.projectID, bqutil.InternalDatasetID, tableName)
+ writer := bq.NewWriter(c.mwClient, tableName, tableSchemaDescriptor)
+ payload := make([]proto.Message, len(rows))
+ for i, r := range rows {
+ payload[i] = r
+ }
+ // TODO (nqmtuan): Consider using commit stream with offset if we really want
+ // exactly-once semantic.
+ return writer.AppendRowsWithDefaultStream(ctx, payload)
+}
diff --git a/tree_status/internal/bqexporter/schema.go b/tree_status/internal/bqexporter/schema.go
new file mode 100644
index 0000000..dc824d0
--- /dev/null
+++ b/tree_status/internal/bqexporter/schema.go
@@ -0,0 +1,79 @@
+// Copyright 2024 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package bqexporter
+
+import (
+ "time"
+
+ "cloud.google.com/go/bigquery"
+ "cloud.google.com/go/bigquery/storage/managedwriter/adapt"
+ "github.com/golang/protobuf/descriptor"
+ desc "github.com/golang/protobuf/protoc-gen-go/descriptor"
+ "google.golang.org/protobuf/types/descriptorpb"
+
+ "go.chromium.org/luci/common/bq"
+
+ bqpb "go.chromium.org/luci/tree_status/proto/bq"
+)
+
+// The table containing tree statuses.
+const tableName = "statuses"
+
+const partitionExpirationTime = 140 * 24 * time.Hour // 140 days.
+
+const rowMessage = "luci.tree_status.bq.StatusRow"
+
+var tableMetadata *bigquery.TableMetadata
+
+// tableSchemaDescriptor is a self-contained DescriptorProto for describing
+// row protocol buffers sent to the BigQuery Write API.
+var tableSchemaDescriptor *descriptorpb.DescriptorProto
+
+func init() {
+ var err error
+ var schema bigquery.Schema
+ if schema, err = generateRowSchema(); err != nil {
+ panic(err)
+ }
+ if tableSchemaDescriptor, err = generateRowSchemaDescriptor(); err != nil {
+ panic(err)
+ }
+
+ tableMetadata = &bigquery.TableMetadata{
+ TimePartitioning: &bigquery.TimePartitioning{
+ Type: bigquery.DayPartitioningType,
+ Expiration: partitionExpirationTime,
+ Field: "create_time",
+ },
+ // Relax ensures no fields are marked "required".
+ Schema: schema.Relax(),
+ Labels: map[string]string{bq.MetadataVersionKey: "1"},
+ }
+}
+
+func generateRowSchema() (schema bigquery.Schema, err error) {
+ fd, _ := descriptor.MessageDescriptorProto(&bqpb.StatusRow{})
+ fdset := &desc.FileDescriptorSet{File: []*desc.FileDescriptorProto{fd}}
+ return bq.GenerateSchema(fdset, rowMessage)
+}
+
+func generateRowSchemaDescriptor() (*desc.DescriptorProto, error) {
+ m := &bqpb.StatusRow{}
+ descriptorProto, err := adapt.NormalizeDescriptor(m.ProtoReflect().Descriptor())
+ if err != nil {
+ return nil, err
+ }
+ return descriptorProto, nil
+}
diff --git a/tree_status/internal/bqexporter/schema_test.go b/tree_status/internal/bqexporter/schema_test.go
new file mode 100644
index 0000000..31ab910
--- /dev/null
+++ b/tree_status/internal/bqexporter/schema_test.go
@@ -0,0 +1,35 @@
+// Copyright 2024 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package bqexporter
+
+import (
+ "testing"
+
+ . "github.com/smartystreets/goconvey/convey"
+)
+
+func TestSchema(t *testing.T) {
+ t.Parallel()
+ Convey(`With Schema`, t, func() {
+ var fieldNames []string
+ for _, field := range tableMetadata.Schema {
+ fieldNames = append(fieldNames, field.Name)
+ }
+ Convey(`Time partitioning field is defined`, func() {
+ partitioningField := tableMetadata.TimePartitioning.Field
+ So(partitioningField, ShouldBeIn, fieldNames)
+ })
+ })
+}
diff --git a/tree_status/proto/bq/gen.go b/tree_status/proto/bq/gen.go
new file mode 100644
index 0000000..b076409
--- /dev/null
+++ b/tree_status/proto/bq/gen.go
@@ -0,0 +1,17 @@
+// Copyright 2024 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package bqpb
+
+//go:generate cproto
diff --git a/tree_status/proto/bq/status_row.pb.go b/tree_status/proto/bq/status_row.pb.go
new file mode 100644
index 0000000..11b6495
--- /dev/null
+++ b/tree_status/proto/bq/status_row.pb.go
@@ -0,0 +1,313 @@
+// Copyright 2024 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// protoc-gen-go v1.33.0
+// protoc v5.26.1
+// source: go.chromium.org/luci/tree_status/proto/bq/status_row.proto
+
+package bqpb
+
+import (
+ protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+ protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+ timestamppb "google.golang.org/protobuf/types/known/timestamppb"
+ reflect "reflect"
+ sync "sync"
+)
+
+const (
+ // Verify that this generated code is sufficiently up-to-date.
+ _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+ // Verify that runtime/protoimpl is sufficiently up-to-date.
+ _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+// Represents a row in the table `luci-tree-status.internal.statuses`.
+type StatusRow struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ // The name of the tree, e.g. chromium.
+ TreeName string `protobuf:"bytes,1,opt,name=tree_name,json=treeName,proto3" json:"tree_name,omitempty"`
+ // Possible values: 'open', 'closed', 'throttled' or 'maintenance'.
+ Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"`
+ // The message provided with the status update.
+ Message string `protobuf:"bytes,3,opt,name=message,proto3" json:"message,omitempty"`
+ // If the status was created by a bot (service account), it will contain the service account.
+ // Otherwise, it will just contain "user", as we don't want BigQuery to contain PII.
+ CreateUser string `protobuf:"bytes,4,opt,name=create_user,json=createUser,proto3" json:"create_user,omitempty"`
+ // The timestamp when this status was posted.
+ CreateTime *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=create_time,json=createTime,proto3" json:"create_time,omitempty"`
+ // The LUCI builder name that caused the tree to close.
+ // Only applicable if the status is 'closed'.
+ ClosingBuilder *Builder `protobuf:"bytes,6,opt,name=closing_builder,json=closingBuilder,proto3" json:"closing_builder,omitempty"`
+}
+
+func (x *StatusRow) Reset() {
+ *x = StatusRow{}
+ if protoimpl.UnsafeEnabled {
+ mi := &file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_msgTypes[0]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+ }
+}
+
+func (x *StatusRow) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*StatusRow) ProtoMessage() {}
+
+func (x *StatusRow) ProtoReflect() protoreflect.Message {
+ mi := &file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_msgTypes[0]
+ if protoimpl.UnsafeEnabled && x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use StatusRow.ProtoReflect.Descriptor instead.
+func (*StatusRow) Descriptor() ([]byte, []int) {
+ return file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *StatusRow) GetTreeName() string {
+ if x != nil {
+ return x.TreeName
+ }
+ return ""
+}
+
+func (x *StatusRow) GetStatus() string {
+ if x != nil {
+ return x.Status
+ }
+ return ""
+}
+
+func (x *StatusRow) GetMessage() string {
+ if x != nil {
+ return x.Message
+ }
+ return ""
+}
+
+func (x *StatusRow) GetCreateUser() string {
+ if x != nil {
+ return x.CreateUser
+ }
+ return ""
+}
+
+func (x *StatusRow) GetCreateTime() *timestamppb.Timestamp {
+ if x != nil {
+ return x.CreateTime
+ }
+ return nil
+}
+
+func (x *StatusRow) GetClosingBuilder() *Builder {
+ if x != nil {
+ return x.ClosingBuilder
+ }
+ return nil
+}
+
+// Represents a LUCI builder.
+type Builder struct {
+ state protoimpl.MessageState
+ sizeCache protoimpl.SizeCache
+ unknownFields protoimpl.UnknownFields
+
+ Project string `protobuf:"bytes,1,opt,name=project,proto3" json:"project,omitempty"`
+ Bucket string `protobuf:"bytes,2,opt,name=bucket,proto3" json:"bucket,omitempty"`
+ Builder string `protobuf:"bytes,3,opt,name=builder,proto3" json:"builder,omitempty"`
+}
+
+func (x *Builder) Reset() {
+ *x = Builder{}
+ if protoimpl.UnsafeEnabled {
+ mi := &file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_msgTypes[1]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+ }
+}
+
+func (x *Builder) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Builder) ProtoMessage() {}
+
+func (x *Builder) ProtoReflect() protoreflect.Message {
+ mi := &file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_msgTypes[1]
+ if protoimpl.UnsafeEnabled && x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use Builder.ProtoReflect.Descriptor instead.
+func (*Builder) Descriptor() ([]byte, []int) {
+ return file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *Builder) GetProject() string {
+ if x != nil {
+ return x.Project
+ }
+ return ""
+}
+
+func (x *Builder) GetBucket() string {
+ if x != nil {
+ return x.Bucket
+ }
+ return ""
+}
+
+func (x *Builder) GetBuilder() string {
+ if x != nil {
+ return x.Builder
+ }
+ return ""
+}
+
+var File_go_chromium_org_luci_tree_status_proto_bq_status_row_proto protoreflect.FileDescriptor
+
+var file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_rawDesc = []byte{
+ 0x0a, 0x3a, 0x67, 0x6f, 0x2e, 0x63, 0x68, 0x72, 0x6f, 0x6d, 0x69, 0x75, 0x6d, 0x2e, 0x6f, 0x72,
+ 0x67, 0x2f, 0x6c, 0x75, 0x63, 0x69, 0x2f, 0x74, 0x72, 0x65, 0x65, 0x5f, 0x73, 0x74, 0x61, 0x74,
+ 0x75, 0x73, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x62, 0x71, 0x2f, 0x73, 0x74, 0x61, 0x74,
+ 0x75, 0x73, 0x5f, 0x72, 0x6f, 0x77, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x13, 0x6c, 0x75,
+ 0x63, 0x69, 0x2e, 0x74, 0x72, 0x65, 0x65, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x2e, 0x62,
+ 0x71, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62,
+ 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f,
+ 0x74, 0x6f, 0x22, 0xff, 0x01, 0x0a, 0x09, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x6f, 0x77,
+ 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x72, 0x65, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20,
+ 0x01, 0x28, 0x09, 0x52, 0x08, 0x74, 0x72, 0x65, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x16, 0x0a,
+ 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73,
+ 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65,
+ 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12,
+ 0x1f, 0x0a, 0x0b, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x18, 0x04,
+ 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72,
+ 0x12, 0x3b, 0x0a, 0x0b, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18,
+ 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70,
+ 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d,
+ 0x70, 0x52, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x54, 0x69, 0x6d, 0x65, 0x12, 0x45, 0x0a,
+ 0x0f, 0x63, 0x6c, 0x6f, 0x73, 0x69, 0x6e, 0x67, 0x5f, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x65, 0x72,
+ 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6c, 0x75, 0x63, 0x69, 0x2e, 0x74, 0x72,
+ 0x65, 0x65, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x2e, 0x62, 0x71, 0x2e, 0x42, 0x75, 0x69,
+ 0x6c, 0x64, 0x65, 0x72, 0x52, 0x0e, 0x63, 0x6c, 0x6f, 0x73, 0x69, 0x6e, 0x67, 0x42, 0x75, 0x69,
+ 0x6c, 0x64, 0x65, 0x72, 0x22, 0x55, 0x0a, 0x07, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x65, 0x72, 0x12,
+ 0x18, 0x0a, 0x07, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
+ 0x52, 0x07, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x62, 0x75, 0x63,
+ 0x6b, 0x65, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x62, 0x75, 0x63, 0x6b, 0x65,
+ 0x74, 0x12, 0x18, 0x0a, 0x07, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x65, 0x72, 0x18, 0x03, 0x20, 0x01,
+ 0x28, 0x09, 0x52, 0x07, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x65, 0x72, 0x42, 0x30, 0x5a, 0x2e, 0x67,
+ 0x6f, 0x2e, 0x63, 0x68, 0x72, 0x6f, 0x6d, 0x69, 0x75, 0x6d, 0x2e, 0x6f, 0x72, 0x67, 0x2f, 0x6c,
+ 0x75, 0x63, 0x69, 0x2f, 0x74, 0x72, 0x65, 0x65, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x2f,
+ 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x62, 0x71, 0x3b, 0x62, 0x71, 0x70, 0x62, 0x62, 0x06, 0x70,
+ 0x72, 0x6f, 0x74, 0x6f, 0x33,
+}
+
+var (
+ file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_rawDescOnce sync.Once
+ file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_rawDescData = file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_rawDesc
+)
+
+func file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_rawDescGZIP() []byte {
+ file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_rawDescOnce.Do(func() {
+ file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_rawDescData = protoimpl.X.CompressGZIP(file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_rawDescData)
+ })
+ return file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_rawDescData
+}
+
+var file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_msgTypes = make([]protoimpl.MessageInfo, 2)
+var file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_goTypes = []interface{}{
+ (*StatusRow)(nil), // 0: luci.tree_status.bq.StatusRow
+ (*Builder)(nil), // 1: luci.tree_status.bq.Builder
+ (*timestamppb.Timestamp)(nil), // 2: google.protobuf.Timestamp
+}
+var file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_depIdxs = []int32{
+ 2, // 0: luci.tree_status.bq.StatusRow.create_time:type_name -> google.protobuf.Timestamp
+ 1, // 1: luci.tree_status.bq.StatusRow.closing_builder:type_name -> luci.tree_status.bq.Builder
+ 2, // [2:2] is the sub-list for method output_type
+ 2, // [2:2] is the sub-list for method input_type
+ 2, // [2:2] is the sub-list for extension type_name
+ 2, // [2:2] is the sub-list for extension extendee
+ 0, // [0:2] is the sub-list for field type_name
+}
+
+func init() { file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_init() }
+func file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_init() {
+ if File_go_chromium_org_luci_tree_status_proto_bq_status_row_proto != nil {
+ return
+ }
+ if !protoimpl.UnsafeEnabled {
+ file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+ switch v := v.(*StatusRow); i {
+ case 0:
+ return &v.state
+ case 1:
+ return &v.sizeCache
+ case 2:
+ return &v.unknownFields
+ default:
+ return nil
+ }
+ }
+ file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
+ switch v := v.(*Builder); i {
+ case 0:
+ return &v.state
+ case 1:
+ return &v.sizeCache
+ case 2:
+ return &v.unknownFields
+ default:
+ return nil
+ }
+ }
+ }
+ type x struct{}
+ out := protoimpl.TypeBuilder{
+ File: protoimpl.DescBuilder{
+ GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+ RawDescriptor: file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_rawDesc,
+ NumEnums: 0,
+ NumMessages: 2,
+ NumExtensions: 0,
+ NumServices: 0,
+ },
+ GoTypes: file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_goTypes,
+ DependencyIndexes: file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_depIdxs,
+ MessageInfos: file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_msgTypes,
+ }.Build()
+ File_go_chromium_org_luci_tree_status_proto_bq_status_row_proto = out.File
+ file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_rawDesc = nil
+ file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_goTypes = nil
+ file_go_chromium_org_luci_tree_status_proto_bq_status_row_proto_depIdxs = nil
+}
diff --git a/tree_status/proto/bq/status_row.proto b/tree_status/proto/bq/status_row.proto
new file mode 100644
index 0000000..a356aa2
--- /dev/null
+++ b/tree_status/proto/bq/status_row.proto
@@ -0,0 +1,51 @@
+// Copyright 2024 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+syntax = "proto3";
+
+package luci.tree_status.bq;
+
+import "google/protobuf/timestamp.proto";
+
+option go_package = "go.chromium.org/luci/tree_status/proto/bq;bqpb";
+
+// Represents a row in the table `luci-tree-status.internal.statuses`.
+message StatusRow {
+ // The name of the tree, e.g. chromium.
+ string tree_name = 1;
+
+ // Possible values: 'open', 'closed', 'throttled' or 'maintenance'.
+ string status = 2;
+
+ // The message provided with the status update.
+ string message = 3;
+
+ // If the status was created by a bot (service account), it will contain the service account.
+ // Otherwise, it will just contain "user", as we don't want BigQuery to contain PII.
+ string create_user = 4;
+
+ // The timestamp when this status was posted.
+ google.protobuf.Timestamp create_time = 5;
+
+ // The LUCI builder name that caused the tree to close.
+ // Only applicable if the status is 'closed'.
+ Builder closing_builder = 6;
+}
+
+// Represents a LUCI builder.
+message Builder {
+ string project = 1;
+ string bucket = 2;
+ string builder = 3;
+}