TT Lab
Get started
Learn Learning paths Courses

I renumbered a field and the old client silently read the wrong value

Build the contract checker

Continue in TT Lab

Goal

You compare two .proto files and build protocheck.py, which prints wire-compatibility violations one line at a time. Adding the rules one by one, you get it to pass the 8 fixture pairs and the pairs the grader keeps hidden, and finally build check-all.sh, which runs per directory, finishing it in a form that can go into CI.

Why it matters

In the previous lab you saw that if you change one number, the old client reads a wrong value without error. That change does not stand out in code review — the diff shows only two numbers changed, and both compilation and tests pass. It is not the kind of mistake a person can catch every time. So contract checks must be done by a script, not by a reviewer. All the checker's rules come from the official documentation's list of "safe changes and unsafe changes", and what you build is a transcription of that list into a form a machine can read.

Steps

  1. Create /root/grpc/compat/protocheck.py so that python3 protocheck.py --dump <file.proto> prints, one per line, the fields of each message as <메시지> <번호> <타입> <이름> (message, number, type, name) and the reservations as <메시지> reserved <번호> and <메시지> reserved "<이름>" (message, reserved number, and reserved name). Ignore comments (//, /* */), whitespace, and options ([deprecated=true]), expand 9 to 11 into three numbers, and call nested messages Outer.Inner.
  2. In python3 protocheck.py <old.proto> <new.proto>, add the first rule — if an old number is not in the new file and is not reserved either, REMOVED_NOT_RESERVED <메시지>.<이름>=<번호> (message.name=number). If there are violations, exit 1; if none, print OK on the last line and exit 0.
  3. If the same name moved to a different number, RENUMBERED <메시지>.<이름> <옛번호>-><새번호> (message.name, old number -> new number).
  4. If the number and name are the same but the type differs, TYPE_CHANGED <메시지>.<이름>=<번호> <옛타입>-><새타입> (message.name=number, old type -> new type); if the same number has a different name and type, NUMBER_REUSED <메시지>#<번호> <옛이름>:<옛타입>-><새이름>:<새타입> (message#number, old name:old type -> new name:new type).
  5. If the new file uses as a field a number that the old file reserved (including ranges), REUSED_RESERVED <메시지>#<번호> <이름> (message#number, name).
  6. If only the name differs with the same number and type, print WARN RENAMED <메시지>#<번호> <옛이름>-><새이름> (message#number, old name -> new name) but do not affect the exit code (if there are only warnings, OK · exit 0).
  7. Run all 8 pairs in /opt/app/grpc/compat/ and, in /root/grpc/compat/07-report.txt, write <case> OK or <case> FAIL, one per line.
  8. Write /root/grpc/compat/check-all.sh <디렉터리> (directory) — for each subdirectory under it that has old.proto and new.proto, run the checker and print <case>: OK / <case>: FAIL, and exit 1 if there is even one FAIL.

Notes

Build a parser that reads .proto

Create /root/grpc/compat/protocheck.py so that --dump <file.proto> prints, one per line, the fields as <메시지> <번호> <타입> <이름> (message, number, type, name) and the reservations as <메시지> reserved <번호> / <메시지> reserved "<이름>" (message, reserved number / reserved name).

Erase the comments first (re.sub twice), find message 이름 { (message name), match braces by counting them, and then read the inside. If there is another message inside, recurse, but name it Outer.Inner. A field is caught by a single 타입 이름 = 번호; (type name = number;) regex, and the leading optional/repeated is discarded.

reserved 9 to 11, 15; is split by commas and, if there is a to, expanded as a range. reserved "foo"; is a name reservation.

The grader also feeds it files that mix comments, odd whitespace, range reservations, nesting, and options, besides the fixtures.

Catch numbers deleted without reservation

In python3 protocheck.py <old> <new>, add the rule REMOVED_NOT_RESERVED <메시지>.<이름>=<번호> (message.name=number). If there are violations, exit 1; if none, OK on the last line · exit 0.

For each message, look at the numbers of the old file one by one and check whether each is in the new file, and if not, whether it is in the new file's reserved. If neither, it is a violation. remove_no_reserved and nested (Customer.tier) must fail, and safe_add and safe_reserved must be OK.

Exit code: sys.exit(1) only when there is at least one violation; if none, 0 after print("OK").

Catch numbers that moved

Add the rule RENUMBERED <메시지>.<이름> <옛번호>-><새번호> (message.name, old number -> new number).

If you first build a name → number dictionary from the new file, it is one line: if an old name is in that dictionary but the number differs, it is a violation. In this case, skip the other checks for that number (REMOVED and so on) — one line per cause is good.

The renumber fixture must produce two lines, for id and qty.

Catch type changes and number reuse

Add the rules TYPE_CHANGED <메시지>.<이름>=<번호> <옛타입>-><새타입> and NUMBER_REUSED <메시지>#<번호> <옛이름>:<옛타입>-><새이름>:<새타입> (old and new name and type).

When the same number is on both sides and the type differs: if the name is the same, it is TYPE_CHANGED, and if the name differs too, it is NUMBER_REUSED. The number remains in the new file, so it is not REMOVED.

A type change inside a nested message (Order.Item) must also be caught — if the parser named it Outer.Inner, the per-message comparison works as is.

Catch reserved numbers being taken out and used

Add the rule REUSED_RESERVED <메시지>#<번호> <이름> (message#number, name). The old file's reserved ranges (10 to 12) are included too.

If a field number in the new file is in the old file's reserved set, it is a violation. If you expanded the ranges in step 1, a single in does it. The reuse_reserved fixture and the pair the grader makes, reserved 10 to 12 + memo = 11, must fail, and using 13 in the same file is OK.

A rename is only a warning

If only the name differs with the same number and type, print WARN RENAMED <메시지>#<번호> <옛이름>-><새이름> (message#number, old name -> new name) but do not affect the exit code. If there are only warnings, OK · exit 0.

Collect warnings and errors in different lists, and the exit code looks only at the error list. Print warnings before errors, and if there are no errors, print OK at the end. rename_only is one WARN line + OK, and the "rename + type change" pair the grader makes produces WARN and TYPE_CHANGED together and exit 1.

Run all 8 fixture pairs

Run all 8 pairs in /opt/app/grpc/compat/ and, in /root/grpc/compat/07-report.txt, write <case> OK or <case> FAIL, one per line.

Run them with for d in /opt/app/grpc/compat/*/; do ...; done and decide OK/FAIL by the exit code. Do not write it by hand — the grader runs your script again and compares it with the report, and also with the true answer. All three must be the same to pass.

A directory-level check script

Write /root/grpc/compat/check-all.sh <디렉터리> (directory) — for each subdirectory that has old.proto and new.proto, run the checker and print <case>: OK / <case>: FAIL, and exit 1 if there is even one FAIL.

Sweep the directory given as the argument, looking only at subdirectories that have both files (skip files and empty directories). Count the failures and exit at the end. The grader runs it with two temporary directories with different names besides the fixture directory — if you hard-code the path, you fail.

If you locate protocheck.py relative to the directory where this script is ($(dirname "$0")), you can call it from anywhere.