fix(perforce): truncate on max_bytes instead of failing, and size the P-class bounds for real changelists - #12
Merged
Conversation
…and size the P-class bounds for real changelists PROTOCOL.md, "Ordering, replay, and size": "Every verb has a max_bytes default; the connector truncates and sets truncated: true rather than streaming unbounded." run() did not - it short-circuited with "response exceeded max_bytes" before any record was read. That is why changelist 118 on project 108 (503 files, an ordinary asset reorganisation for a game studio) never synced at all rather than syncing partially. The bounds were also metadata-sized, and they decided which changelists were allowed to be CORRECT rather than protecting anything: p4.describe carries no file contents at all (-s), and ButterStack already stores the full file list for every changelist under the limit. - max_bytes now shapes the response where records are assembled. A budget that runs out truncates and sets truncated:true; it never fails the command. Each record is charged before it is appended, so the bound holds rather than being discovered one record after it was crossed. - A separate memory ceiling replaces it in run(): 64 MiB of raw p4 output per command, enforced by a capped writer rather than by measuring a buffer that has already been allocated. This is a bound on what the daemon will hold, not on what the caller asked for, and no caller can raise it. - p4.describe's default max_bytes 65536 -> 4 MiB (~30,000 file records) and max_files' schema bound 1000 -> 100000. p4.changes 65536 -> 1 MiB. - Describe gains depot_file_count: the changelist's REAL file count, counted before either bound. file_count is what was RETURNED. Without both, a truncated record cannot be told from a complete one by size, and the consumer cannot know how much it is missing. - describeFromRecord is split out of describe() so the bounds can be exercised without a live p4 server. teamcity.go was checked in the same pass and left alone: it shares the 64 << 10 default but already truncates correctly rather than erroring. Found in passing: run() decoded straight from the stdout buffer, which CONSUMES it, so the stdout.Len() it returned afterwards was always 0 - every successful command reported bytes: 0 to the broker and into the audit log. It decodes from a reader over the bytes now and reports the real length. The max_bytes check removed above was the only caller that read the length before the decode drained it, which is why it went unnoticed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Steps 3 and 4 of ButterStack/butter_stack#1904, the connector half. With #11 (already merged) this is the content of v0.3.0.
max_bytes contradicted the protocol
PROTOCOL.md, "Ordering, replay, and size":
run()did not. It short-circuited withperforce: response exceeded max_bytesbefore a single record was read. The intent was clearly truncation - themaxFilespath immediately below did exactly that - but the byte check fired first.That is why changelist 118 on project 108 never synced at all rather than syncing partially: 503 files, an ordinary asset reorganisation for a game studio, which is precisely the kind of change this daemon exists to observe.
The bounds were sizing the wrong thing
64 KiB is roughly 500 file records. A cap that small does not protect anything here:
p4.describe -scarries no file contents - depot paths, actions, revisions, types - and ButterStack already stores the full file list for every changelist under the limit. The cap never stopped the data existing in the product. It only decided which changelists were allowed to be correct.What changed
max_bytesshapes the response instead of failing the command. Enforcement moved to where records are assembled. A budget that runs out truncates and setstruncated: true. Each record is charged before it is appended, so the bound holds rather than being discovered one record after it was crossed.A real memory ceiling replaces it in
run(). 64 MiB of raw p4 output per command, enforced by a capped writer rather than by measuring a buffer that has already been allocated - measuring afterwards would already have made the allocation the ceiling exists to prevent. This is a bound on what the daemon is willing to hold, not on what the caller asked for, and no caller can raise it. This is the "megabytes, as a sanity bound" the issue asks for.Defaults sized for the work:
p4.describe65536 → 4 MiB (~30,000 file records), itsmax_filesschema bound 1000 → 100000,p4.changes65536 → 1 MiB.Describegainsdepot_file_count- the changelist's real file count, counted before either bound is applied.file_countis what was returned; this is what exists. Without both, a truncated record cannot be told from a complete one by size, and a consumer has no way to know how much it is missing. This is also what lets butter_stack#1904's acceptance criterion 6 become the strict version: a test that fails when a stored file list is shorter than the changelist's own file count.describeFromRecordis split out ofdescribe()so the bounds - the entire subject of this change - can be exercised without a live p4 server.Checked and deliberately not changed
internal/tools/teamcity.go:150shares the64 << 10default the issue flagged, but it already truncates correctly: it readsmaxBytes+1, compares, slices, and setstruncated. Only the default size matched the pattern, not the hard-fail. Left alone rather than changed for symmetry.Found in passing
run()decoded straight from the stdoutbytes.Buffer, which consumes it - so thestdout.Len()it returned afterwards was always 0. Every successful p4 command has been reportingbytes: 0to the broker and into the audit log. It now decodes from a reader over the bytes and reports the real length.Worth noting why this survived: the
max_bytescheck removed above was the only caller that read the length before the decode drained it. Removing it is what exposed the bug.Rails side, before rollout
Integrations::Perforce::ConnectorSource::MAX_BYTESstill sends 65536. With this change that no longer fails a large changelist, but it would still truncate one - so changelist 118 would re-sync as a flagged partial rather than a complete record. That one-line raise belongs with butter_stack PR #1909 and is called out there; it must land before #1904 step 5 (the re-sync of changes 3, 7, 113 and 118) is attempted.Tests
go test ./...green,go vetclean,gofmtclean.Seven new cases: truncation on
max_filesand onmax_bytes(withdepot_file_countintact in both), a 503-file changelist coming back whole at the default budget - the case that used to fail outright - a complete list not being marked truncated, the returned records actually fitting the budget they were given,max_bytes: 0meaning "no preference" rather than "return nothing", and the capped buffer stopping at its limit while still reporting the full write length.One existing vocab case moved from
max_files: 100000to100001: it asserts that a bound exists and is enforced at the frame boundary, and the bound moved.