Skip to content

Commit 97204f8

Browse files
committed
Use Buf to guard protobuf compatibility
1 parent 0d0fcbf commit 97204f8

9 files changed

Lines changed: 447 additions & 1 deletion

File tree

.github/workflows/ci.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,13 @@ jobs:
9191
ruby-version: ${{ matrix.ruby }}
9292
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
9393

94+
- name: Setup Buf
95+
if: matrix.build_part == 'run_misc_checks'
96+
uses: bufbuild/buf-action@8c6a16e16f12ba20b6470afa9c2ba9b5ba8c97c3 # v1.5.0
97+
with:
98+
version: "1.72.0"
99+
setup_only: true
100+
94101
- name: Setup Docker Compose
95102
uses: KengoTODA/actions-setup-docker-compose@477353946803dd64eaa44008b865b6bfc88cab4e # v1.2.4
96103
env:

elasticgraph-proto_ingestion/README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,28 @@ end
7777
After running `bundle exec rake schema_artifacts:dump`, ElasticGraph will generate a `schema.proto`
7878
schema artifact, and will maintain a `proto_field_numbers.yaml` file alongside your schema definition.
7979

80+
### Protecting Protobuf Compatibility With Buf
81+
82+
Install the [Buf CLI](https://buf.build/docs/installation/) anywhere that dumps schema artifacts.
83+
Once `schema.proto` exists, ElasticGraph compiles the existing and proposed schemas with Buf whenever
84+
the artifact changes. It then applies Buf's strict [`FILE` breaking-change
85+
rules](https://buf.build/docs/breaking/rules/) so changes that break generated code, JSON, or the binary
86+
wire format cannot be dumped accidentally. Imported schemas are compiled using the project's normal
87+
Buf configuration and are excluded from the comparison itself, keeping this check scoped to the
88+
generated `schema.proto`.
89+
90+
A breaking change always fails the dump. Protobuf has no schema version to bump: consumers that
91+
already deserialize these messages would misread them, and you cannot update every consumer at the
92+
same moment. So there is no flag to accept a breaking change.
93+
94+
To get past the error, make the change compatible:
95+
96+
- Add a new field instead of a change to the type or the name of an existing field.
97+
- Reserve the number of every field you remove.
98+
- Keep enum value numbers stable, and reserve the numbers of values you remove.
99+
100+
Compatible additions dump normally and need no extra step.
101+
80102
## Schema Definition API
81103

82104
### Protobuf Syntax (`proto2` / `proto3`)
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
# Copyright 2024 - 2026 Block, Inc.
2+
#
3+
# Use of this source code is governed by an MIT-style
4+
# license that can be found in the LICENSE file or at
5+
# https://opensource.org/licenses/MIT.
6+
#
7+
# frozen_string_literal: true
8+
9+
require "elastic_graph/errors"
10+
require "elastic_graph/proto_ingestion"
11+
require "open3"
12+
require "tempfile"
13+
14+
module ElasticGraph
15+
module ProtoIngestion
16+
module SchemaDefinition
17+
# Uses the Buf CLI to detect breaking changes between two protobuf schemas.
18+
#
19+
# @private
20+
class BufBreakingChangeDetector
21+
BREAKING_CONFIG_JSON = %({"version":"v2","breaking":{"use":["FILE"]}})
22+
23+
def initialize(buf_command: "buf", temporary_directory: ::Dir.pwd)
24+
@buf_command = buf_command
25+
@temporary_directory = temporary_directory
26+
end
27+
28+
# Returns Buf's diagnostics if the current schema breaks the prior schema, or `nil` if it
29+
# is compatible.
30+
def breaking_changes(current_schema:, against_schema:)
31+
with_built_images(current_schema, against_schema) do |current_image_path, against_image_path, proto_path|
32+
output, status = run_buf(
33+
"breaking",
34+
current_image_path,
35+
"--against",
36+
against_image_path,
37+
"--exclude-imports",
38+
"--config",
39+
BREAKING_CONFIG_JSON
40+
)
41+
42+
return nil if status.success?
43+
return normalized_output(output, proto_path) if status.exitstatus == 100
44+
45+
raise Errors::SchemaError, buf_failure_message("compare the protobuf schemas", output)
46+
end
47+
rescue ::Errno::ENOENT
48+
raise Errors::SchemaError, <<~EOS.strip
49+
The Buf CLI is required to check `schema.proto` for breaking changes, but the `#{@buf_command}` command could not be found.
50+
Install Buf from https://buf.build/docs/installation and run `bundle exec rake schema_artifacts:dump` again.
51+
EOS
52+
end
53+
54+
private
55+
56+
def with_built_images(current_schema, against_schema)
57+
::Tempfile.create(["elasticgraph-schema", ".proto"], @temporary_directory) do |proto_file|
58+
::Tempfile.create(["elasticgraph-current-schema", ".binpb"], @temporary_directory) do |current_image_file|
59+
build_image(current_schema, "current", proto_file, current_image_file)
60+
61+
::Tempfile.create(["elasticgraph-against-schema", ".binpb"], @temporary_directory) do |against_image_file|
62+
build_image(against_schema, "against", proto_file, against_image_file)
63+
yield current_image_file.path, against_image_file.path, proto_file.path
64+
end
65+
end
66+
end
67+
end
68+
69+
def build_image(schema, label, proto_file, image_file)
70+
proto_file.rewind
71+
proto_file.truncate(0)
72+
proto_file.write(schema)
73+
proto_file.flush
74+
image_file.close
75+
76+
output, status = run_buf("build", proto_file.path, "--output", image_file.path)
77+
return if status.success?
78+
79+
raise Errors::SchemaError, buf_failure_message("compile the #{label} protobuf schema", output)
80+
end
81+
82+
def run_buf(*arguments)
83+
stdout, stderr, status = ::Open3.capture3(@buf_command, *arguments)
84+
output = [stdout, stderr].reject(&:empty?).join("\n").strip
85+
[output, status]
86+
end
87+
88+
def buf_failure_message(action, output)
89+
details = output.empty? ? "Buf did not provide any diagnostics." : output
90+
"Buf was unable to #{action}:\n\n#{details}"
91+
end
92+
93+
def normalized_output(output, proto_path)
94+
output.gsub(proto_path, PROTO_SCHEMA_FILE).gsub(::File.basename(proto_path), PROTO_SCHEMA_FILE)
95+
end
96+
end
97+
end
98+
end
99+
end

elasticgraph-proto_ingestion/lib/elastic_graph/proto_ingestion/schema_definition/schema_artifact_manager_extension.rb

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
require "elastic_graph/errors"
1010
require "elastic_graph/proto_ingestion"
11+
require "elastic_graph/proto_ingestion/schema_definition/buf_breaking_change_detector"
1112

1213
module ElasticGraph
1314
module ProtoIngestion
@@ -31,6 +32,24 @@ module SchemaArtifactManagerExtension
3132
"lost, and previously serialized protobuf messages would be misread."
3233
].freeze
3334

35+
# Overrides `dump_artifacts` to reject any change that breaks protobuf wire compatibility.
36+
#
37+
# A protobuf schema has no version to bump. Every consumer that already deserializes these
38+
# messages must keep reading them, and those consumers cannot be updated in lockstep. So a
39+
# breaking change is always an error, and the only fix is to make the change compatible.
40+
def dump_artifacts
41+
proto_schema = protobuf_schema_definition_results.proto_schema
42+
return super if proto_schema.empty?
43+
44+
artifact = proto_schema_artifact
45+
existing_schema = artifact.existing_dumped_contents
46+
return super unless artifact.out_of_date? && existing_schema
47+
48+
check_for_breaking_proto_changes(artifact.desired_contents, existing_schema)
49+
50+
super
51+
end
52+
3453
private
3554

3655
# Overrides the base `artifacts_from_schema_def` method to add proto artifacts.
@@ -41,10 +60,41 @@ def artifacts_from_schema_def
4160

4261
base_artifacts + [
4362
proto_field_numbers_artifact,
44-
new_raw_artifact(PROTO_SCHEMA_FILE, proto_schema.chomp, comment_prefix: "//")
63+
proto_schema_artifact
4564
]
4665
end
4766

67+
def proto_schema_artifact
68+
@proto_schema_artifact ||= new_raw_artifact(
69+
PROTO_SCHEMA_FILE,
70+
protobuf_schema_definition_results.proto_schema.chomp,
71+
comment_prefix: "//"
72+
)
73+
end
74+
75+
def check_for_breaking_proto_changes(current_schema, against_schema)
76+
changes = BufBreakingChangeDetector.new(
77+
temporary_directory: ::File.dirname(proto_schema_artifact.file_name)
78+
).breaking_changes(
79+
current_schema: current_schema,
80+
against_schema: against_schema
81+
)
82+
return unless changes
83+
84+
abort <<~EOS.strip
85+
Buf detected a breaking change to `schema.proto`:
86+
87+
#{changes}
88+
89+
Protobuf offers no way to version your way out of this. Consumers that already read
90+
these messages would misread them after this change. Make the change compatible
91+
instead: add a new field rather than retype or rename an existing one, and reserve the
92+
number of every field you remove.
93+
EOS
94+
rescue Errors::SchemaError => e
95+
abort e.message
96+
end
97+
4898
# Builds the `proto_field_numbers.yaml` artifact. The file is part of the schema definition
4999
# rather than a proper schema artifact--it's an input to `schema.proto` generation--so it
50100
# lives alongside `path_to_schema` instead of in the schema artifacts directory.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
module ElasticGraph
2+
module ProtoIngestion
3+
module SchemaDefinition
4+
class BufBreakingChangeDetector
5+
BREAKING_CONFIG_JSON: ::String
6+
7+
@buf_command: ::String
8+
@temporary_directory: ::String
9+
10+
def initialize: (?buf_command: ::String, ?temporary_directory: ::String) -> void
11+
def breaking_changes: (current_schema: ::String, against_schema: ::String) -> (::String | nil)
12+
13+
private
14+
15+
def with_built_images: (::String current_schema, ::String against_schema) { (::String, ::String, ::String) -> untyped } -> untyped
16+
def build_image: (::String schema, ::String label, ::File proto_file, ::File image_file) -> void
17+
def run_buf: (*::String arguments) -> [::String, ::Process::Status]
18+
def buf_failure_message: (::String action, ::String output) -> ::String
19+
def normalized_output: (::String, ::String) -> ::String
20+
end
21+
end
22+
end
23+
end

elasticgraph-proto_ingestion/sig/elastic_graph/proto_ingestion/schema_definition/schema_artifact_manager_extension.rbs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,15 @@ module ElasticGraph
44
module SchemaArtifactManagerExtension : ::ElasticGraph::SchemaDefinition::SchemaArtifactManager
55
PROTO_FIELD_NUMBERS_COMMENT_PREAMBLE_LINES: ::Array[::String]
66

7+
@proto_schema_artifact: (::ElasticGraph::SchemaDefinition::SchemaArtifact[::String] | nil)
8+
9+
def dump_artifacts: () -> void
10+
711
private
812

913
def artifacts_from_schema_def: () -> ::Array[::ElasticGraph::SchemaDefinition::SchemaArtifact[untyped]]
14+
def proto_schema_artifact: () -> ::ElasticGraph::SchemaDefinition::SchemaArtifact[::String]
15+
def check_for_breaking_proto_changes: (::String, ::String) -> void
1016
def proto_field_numbers_artifact: () -> ::ElasticGraph::SchemaDefinition::SchemaArtifact[::Hash[::String, untyped]]
1117
def proto_field_numbers_path: () -> ::String
1218
def protobuf_schema_definition_results: () -> (::ElasticGraph::SchemaDefinition::Results & ResultsExtension)

elasticgraph-proto_ingestion/spec/integration/elastic_graph/proto_ingestion/schema_definition/rake_tasks_spec.rb

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,25 @@ module SchemaDefinition
3838
)
3939
end
4040

41+
it "does not invoke Buf or dump a proto artifact when no indexed types are defined" do
42+
expect(BufBreakingChangeDetector).not_to receive(:new)
43+
write_proto_schema(table_defs: <<~EOS)
44+
s.object_type "Point" do |t|
45+
t.field "x", "Float"
46+
end
47+
48+
s.on_root_query_type do |t|
49+
t.field "point", "Point" do |f|
50+
f.resolve_with :object_without_lookahead
51+
end
52+
end
53+
EOS
54+
55+
run_rake_with_proto("schema_artifacts:dump")
56+
57+
expect(read_artifact(PROTO_SCHEMA_FILE)).to be_nil
58+
end
59+
4160
it "idempotently dumps proto artifacts" do
4261
write_proto_schema(table_defs: <<~EOS)
4362
s.object_type "Product" do |t|
@@ -55,6 +74,8 @@ module SchemaDefinition
5574
end
5675

5776
it "persists proto field-number mappings and reuses them on the next dump" do
77+
stub_buf_breaking_changes(nil)
78+
5879
write_proto_schema(table_defs: <<~EOS)
5980
s.object_type "Product" do |t|
6081
t.field "id", "ID"
@@ -96,6 +117,88 @@ module SchemaDefinition
96117

97118
expect(read_artifact(PROTO_SCHEMA_FILE)).to include("string id = 1;", "string name = 2;")
98119
end
120+
121+
it "dumps a compatible change that Buf reports as safe" do
122+
write_proto_schema(table_defs: <<~EOS)
123+
s.object_type "Product" do |t|
124+
t.field "id", "ID"
125+
t.index "products"
126+
end
127+
EOS
128+
run_rake_with_proto("schema_artifacts:dump")
129+
130+
detector = stub_buf_breaking_changes(nil)
131+
write_proto_schema(table_defs: <<~EOS)
132+
s.object_type "Product" do |t|
133+
t.field "id", "ID"
134+
t.field "name", "String"
135+
t.index "products"
136+
end
137+
EOS
138+
139+
expect {
140+
run_rake_with_proto("schema_artifacts:dump")
141+
}.to change { read_artifact(PROTO_SCHEMA_FILE) }
142+
143+
expect(detector).to have_received(:breaking_changes).with(
144+
current_schema: a_string_including("string name = 2;"),
145+
against_schema: a_string_excluding("string name = 2;")
146+
)
147+
end
148+
149+
it "refuses to dump a breaking change reported by Buf" do
150+
write_proto_schema(table_defs: <<~EOS)
151+
s.object_type "Product" do |t|
152+
t.field "id", "ID"
153+
t.index "products"
154+
end
155+
EOS
156+
run_rake_with_proto("schema_artifacts:dump")
157+
158+
original_proto = read_artifact(PROTO_SCHEMA_FILE)
159+
stub_buf_breaking_changes("schema.proto: Field changed type from string to int32.")
160+
write_proto_schema(table_defs: <<~EOS)
161+
s.object_type "Product" do |t|
162+
t.field "id", "Int"
163+
t.index "products"
164+
end
165+
EOS
166+
167+
expect {
168+
run_rake_with_proto("schema_artifacts:dump")
169+
}.to abort_with a_string_including(
170+
"Buf detected a breaking change",
171+
"Field changed type from string to int32",
172+
"Protobuf offers no way to version your way out of this",
173+
"reserve the"
174+
)
175+
expect(read_artifact(PROTO_SCHEMA_FILE)).to eq(original_proto)
176+
end
177+
178+
it "surfaces Buf failures without a stack trace" do
179+
write_proto_schema(table_defs: <<~EOS)
180+
s.object_type "Product" do |t|
181+
t.field "id", "ID"
182+
t.index "products"
183+
end
184+
EOS
185+
run_rake_with_proto("schema_artifacts:dump")
186+
187+
detector = instance_double(BufBreakingChangeDetector)
188+
allow(detector).to receive(:breaking_changes).and_raise(Errors::SchemaError, "Buf could not compile an import.")
189+
allow(BufBreakingChangeDetector).to receive(:new).and_return(detector)
190+
write_proto_schema(table_defs: <<~EOS)
191+
s.object_type "Product" do |t|
192+
t.field "id", "ID"
193+
t.field "name", "String"
194+
t.index "products"
195+
end
196+
EOS
197+
198+
expect {
199+
run_rake_with_proto("schema_artifacts:dump")
200+
}.to abort_with("Buf could not compile an import.")
201+
end
99202
end
100203

101204
describe "schema_artifacts:check" do
@@ -129,6 +232,12 @@ def write_proto_schema(table_defs:)
129232
EOS
130233
end
131234

235+
def stub_buf_breaking_changes(changes)
236+
detector = instance_double(BufBreakingChangeDetector, breaking_changes: changes)
237+
allow(BufBreakingChangeDetector).to receive(:new).and_return(detector)
238+
detector
239+
end
240+
132241
def run_rake_with_proto(*args)
133242
run_rake(*args) do |output|
134243
ElasticGraph::SchemaDefinition::RakeTasks.new(

0 commit comments

Comments
 (0)