Skip to content

mysqlctl: upgrade pierrec/lz4 to v4 to fix broken amd64 block decoding - #20778

Open
arthurschreiber wants to merge 4 commits into
mainfrom
arthur/upgrade-lz4-v4
Open

mysqlctl: upgrade pierrec/lz4 to v4 to fix broken amd64 block decoding#20778
arthurschreiber wants to merge 4 commits into
mainfrom
arthur/upgrade-lz4-v4

Conversation

@arthurschreiber

@arthurschreiber arthurschreiber commented Jul 31, 2026

Copy link
Copy Markdown
Member

Description

The recurring Cluster (21) CI failure in TestBackupMysqlctldWithlz4Compression ("process 'vttablet' exited prematurely") turned out to be a real product bug, not a flaky test: restoring an lz4 backup fails with lz4: invalid source or destination buffer too short on mysql.ibd, both restore attempts fail identically, and the tablet exits.

The bug is in the vendored pierrec/lz4 v2 amd64 assembly decoder. In its memmove_lit path it spills only the low byte of the register holding the LZ4 token across the CALL runtime·memmove, and restores only that byte. Whatever memmove leaves in the upper 56 bits then flows into the next sequence's match length, and an in-bounds match can fail the destination bounds check — the decoder rejects a perfectly valid block. Whether memmove leaves those bits dirty depends on which copy path the host CPU takes, which is why the failure comes and goes between CI runners. mysql.ibd is the only datadir file larger than one 4 MiB lz4 block (i.e. with enough literal-run/match sequences to hit the window), which is why it's always that file.

Evidence, from a local reproduction that mirrors the Vitess pipeline against a real 28 MB mysql.ibd: stock v2.6.1 on amd64 fails 4/20 runs with the exact CI error (independent of writer concurrency and compression level); the same seeds never fail on arm64 (pure-Go decoder); the compressed frames are byte-identical on both architectures and decode correctly with both the arm64 decoder and the v4 reader — so the backups are valid; only the amd64 decoder is broken, and existing lz4 backups remain restorable after this upgrade. Patching the one instruction (spilling the full register) also makes the failure vanish, 0/40.

It turns out this exact bug was already root-caused upstream after we drafted this: golang/go#74925 hit the same error after upgrading to Go 1.24, and Keith Randall pinpointed the very same instruction — "MOVB does not clear the upper 56 bits. You would need MOVBQZX. v4 does this store/load with MOVL" — verifying that v2.6.1 (and v3.3.5) fail while v4 doesn't, and closing the issue as not-a-Go-bug. The fix on the v4 line is pierrec/lz4@3135ebdb71 ("Less stack spilling in memmove calls", merged via pierrec/lz4#120, first shipped in v4.1.4): the MOVB spill/restore of the token register became MOVL, which zero-extends on restore and clears the garbage. It was a performance refactor whose correctness fix was accidental, which is also why it was never backported to v2. The thread additionally explains why this started flaking in CI now: Go ≥ 1.24 (golang/go@601ea46a53) added FSRM/ERMS-vectorized memmove paths on capable Intel CPUs that leave different garbage in RDX — AMD and older Intel runners are unaffected, hence the runner-to-runner variance. pierrec/lz4#233 tracks the v2/v3 exposure upstream.

v2.6.1 is the final v2 release, so the fix is moving to the maintained pierrec/lz4/v4 (v4.1.27). The reader is a drop-in; the writer moves from the Header struct to v4 options. One wrinkle: v4 takes a level enum rather than v2's raw hash-chain search depth, and rejects other values. The new mapping keeps --compression-level 0 and 1 (including the default 1, whose v2 depth of 1 did almost no searching) on the fast compressor, maps 2–9 onto Level2Level9, and maps negative values onto Level9 — v2 treated a negative level as an unlimited hash-chain search, so the deepest named level is the faithful translation.

A second wrinkle surfaced in this PR's own CI run (Cluster (xb_backup)): v4's writer implements ReadFrom, but only for a fresh writer, and io.CopyN selects the destination's ReadFrom because LimitReader hides the source's WriteTo. The striped xtrabackup backup round-robins io.CopyN over its destination writers, so the second block per stripe failed the whole backup with lz4: unhandled state[writeState] (upstream knows this API is fragile: pierrec/lz4#183). The lz4 compressor and decompressor are now wrapped in types that hide ReadFrom/WriteTo, keeping every copy on the plain Write/Read path, and TestBuiltinCompressorsSequentialCopyN pins the striped-copy shape for all builtin engines.

A new TestBuiltinCompressorsMultiBlock round-trips a 9 MiB mixed compressible/incompressible payload through every builtin engine — the multi-block, long-literal-run shape that exposed this, which had no coverage (the existing round-trip test uses 14 bytes). An honest in-tree regression test for the decoder bug itself isn't possible: the trigger depends on the host CPU's memmove path, so it would pass on main on most machines. The dependency version is the guard.

Backport justification

This should go to both supported release branches (release-23.0, release-24.0):

  • Both branches build with Go ≥ 1.24 (release-23.0 is on Go 1.25.12, release-24.0 on Go 1.26.5) and ship pierrec/lz4 v2.6.1, so every current release binary carries the live bug on FSRM-capable Intel CPUs.
  • It's a restore-time failure: backups complete and look healthy, and the failure only surfaces when a tablet restores — provisioning a replica, disaster recovery. It's data-dependent rather than transient, so the per-file retry fails identically and the tablet exits. Upgrading also rescues existing backups, since the archives are valid and only the decoder is broken.
  • The risk is contained: the lz4 frame format is unchanged, the reader is a drop-in, and v2-written backups were verified byte-for-byte restorable under v4. The only behavior delta is the --compression-level 2–9 mapping described in the deployment notes; the default profile is preserved.
  • For unpatched versions, GODEBUG=cpu.fsrm=off avoids the Go ≥ 1.24 trigger path (the workaround from Issue with block decoding and vectorized runtime.memmove (Go >= 1.24) pierrec/lz4#233), though the bug stays latent.

Related Issue(s)

None found — searches for the error string and the test name came up empty.

Checklist

  • "Backport to:" labels have been added if this change should be back-ported to release branches
  • If this change is to be back-ported to previous releases, a justification is included in the PR description
  • Tests were added or are not required
  • Did the new or modified tests pass consistently locally and on CI?
  • Documentation was added or is not required

Deployment Notes

For users running backups with --compression-engine-name=lz4 (not the default; the default engine is pargzip):

  • Restores of lz4 backups on amd64 could previously fail spuriously with lz4: invalid source or destination buffer too short depending on the host CPU. Fixed by this upgrade. Backups written by older Vitess versions are unaffected and remain restorable — the frame format is unchanged.
  • --compression-level values 2–9 now select lz4's named hash-chain levels (search depths 1024–131072) instead of using the raw value as the search depth, so higher levels get a better ratio at more CPU. Values 0 and 1, including the default, keep the current fast profile; negative values keep selecting the deepest search (Level9, depth 131072, versus v2's unlimited search capped at the 65536-byte window).

AI Disclosure

The investigation (assembly-level root cause, local reproduction harness, and cross-architecture validation) and the fix were done by Claude Code; I provided direction.

The v2 amd64 assembly decoder spills only the low byte of the register
holding the LZ4 token across its call to runtime.memmove, so leftover
upper bits can corrupt the next match length and fail an otherwise valid
block with 'lz4: invalid source or destination buffer too short'. During
a backup restore that error aborts the tablet, which is the recurring
TestBackupMysqlctldWithlz4Compression CI failure: mysql.ibd is the only
datadir file bigger than one 4 MiB lz4 block, and whether the leftover
bits are non-zero depends on the memmove path the host CPU picks, which
is why the failure comes and goes between runners.

The v4 module decodes the same frames correctly (verified byte-for-byte
against v2-written archives), so existing lz4 backups stay restorable.
The writer moves from the v2 Header struct to v4 options; v4 takes a
level enum instead of a raw hash-chain depth, so --compression-level
values at or below 1 keep their old near-zero-search profile via the
fast compressor and 2 through 9 map onto the matching hash-chain levels.

The new multi-block round-trip test covers payloads larger than one lz4
block for every builtin engine; the failure itself is CPU-dependent, so
no in-tree test can pin the decoder bug directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Arthur Schreiber <arthur@planetscale.com>
Copilot AI review requested due to automatic review settings July 31, 2026 17:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions github-actions Bot added this to the v25.0.0 milestone Jul 31, 2026
@vitess-bot

vitess-bot Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Review Checklist

Hello reviewers! 👋 Please follow this checklist when reviewing this Pull Request.

General

  • Ensure that the Pull Request has a descriptive title.
  • Ensure there is a link to an issue (except for internal cleanup and flaky test fixes), new features should have an RFC that documents use cases and test cases.

Tests

  • Bug fixes should have at least one unit or end-to-end test, enhancement and new features should have a sufficient number of tests.

Documentation

  • Apply the release notes (needs details) label if users need to know about this change.
  • New features should be documented.
  • There should be some code comments as to why things are implemented the way they are.
  • There should be a comment at the top of each new or modified test to explain what the test does.

New flags

  • Is this flag really necessary?
  • Flag names must be clear and intuitive, use dashes (-), and have a clear help text.

If a workflow is added or modified:

  • Each item in Jobs should be named in order to mark it as required.
  • If the workflow needs to be marked as required, the maintainer team must be notified.

Backward compatibility

  • Protobuf changes should be wire-compatible.
  • Changes to _vt tables and RPCs need to be backward compatible.
  • RPC changes should be compatible with vitess-operator
  • If a flag is removed, then it should also be removed from vitess-operator and arewefastyet, if used there.
  • vtctl command output order should be stable and awk-able.

@vitess-bot vitess-bot Bot added NeedsWebsiteDocsUpdate What it says NeedsDescriptionUpdate The description is not clear or comprehensive enough, and needs work NeedsIssue A linked issue is missing for this Pull Request NeedsBackportReason If backport labels have been applied to a PR, a justification is required labels Jul 31, 2026
@github-actions github-actions Bot added the Type: Dependencies Dependency updates label Jul 31, 2026
@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.00000% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 54.79%. Comparing base (70c7a72) to head (e95eff9).
⚠️ Report is 462 commits behind head on main.

Files with missing lines Patch % Lines
go/vt/mysqlctl/compression.go 95.00% 1 Missing ⚠️

❗ There is a different number of reports uploaded between BASE (70c7a72) and HEAD (e95eff9). Click for more details.

HEAD has 1 upload less than BASE
Flag BASE (70c7a72) HEAD (e95eff9)
1 0
Additional details and impacted files
@@             Coverage Diff             @@
##             main   #20778       +/-   ##
===========================================
- Coverage   69.67%   54.79%   -14.89%     
===========================================
  Files        1614       46     -1568     
  Lines      216793     7492   -209301     
===========================================
- Hits       151044     4105   -146939     
+ Misses      65749     3387    -62362     
Flag Coverage Δ
partial 54.79% <95.00%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@arthurschreiber arthurschreiber added Backport to: release-23.0 Needs to be backport to release-23.0 Backport to: release-24.0 Needs to be backport to release-24.0 and removed NeedsDescriptionUpdate The description is not clear or comprehensive enough, and needs work NeedsWebsiteDocsUpdate What it says NeedsIssue A linked issue is missing for this Pull Request NeedsBackportReason If backport labels have been applied to a PR, a justification is required labels Jul 31, 2026
@arthurschreiber
arthurschreiber marked this pull request as ready for review July 31, 2026 17:57

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ef438424de

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// decompressors through their multi-block paths, with long literal runs
// followed by matches inside every block; a round trip of a few bytes
// never leaves the first block.
func TestBuiltinCompressorsMultiBlock(t *testing.T) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Replace the test with a deterministic regression guard

On hosts whose memmove path does not expose the v2 decoder bug, this same multi-block round trip succeeds against the parent dependency, so reverting the v4 upgrade still leaves the test green; it therefore adds four 9 MiB round trips without reliably guarding the fix. Either use a deterministic input/environment that fails with v2 or omit this purported regression test when no honest deterministic test is possible.

AGENTS.md reference: AGENTS.md:L79-L80

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The premise holds: on hosts whose memmove path leaves the token register's upper bits zero, this round trip passes with the v2 dependency too, and the PR description states that no deterministic in-tree test for the decoder bug is possible — the trigger is which runtime.memmove path the host CPU selects (golang/go#74925 documents the CPU dependence). The test is kept, with a different justification than guarding the decoder fix: multi-block payloads had no round-trip coverage at all (the existing test uses 14 bytes), this is the workload shape the backup engines actually produce, on FSRM-capable runners it does fail against v2, and it costs ~0.1s. The deterministic regression test in this PR is TestBuiltinCompressorsSequentialCopyN, added in ad4a403 after the reviewed commit, which pins the striped-copy failure that this PR's own CI surfaced.

Comment on lines +250 to +251
case level <= 1:
return lz4.Fast

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve negative LZ4 compression levels

When an existing deployment uses --compression-level=-1 with LZ4, the old writer passes that nonzero value to CompressBlockHC, where a nonpositive depth means an unlimited search; this level <= 1 branch instead silently selects the fast compressor. The same persisted configuration therefore changes from maximum hash-chain searching to the lowest-ratio mode after upgrade, so negative values should be mapped separately or rejected through the compatibility/deprecation process rather than grouped with 0 and 1.

AGENTS.md reference: AGENTS.md:L240-L244

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 809f521: the v2 writer ran CompressBlockHC for any non-zero level and block.go:269 treats a non-positive depth as winSize (65536), so a configured negative level meant the deepest search, not the fastest mode. Negative levels now map to Level9 (depth 131072, the closest the v4 enum offers), 0 and 1 stay on the fast compressor, and TestLz4CompressionLevelMapping pins the full mapping. The PR description and deployment notes describe the negative-level translation.

The v4 lz4 writer implements ReadFrom, but only for a writer with
nothing written to it yet; io.Copy and io.CopyN select the destination's
ReadFrom whenever the source has no visible WriteTo. The striped
xtrabackup backup round-robins io.CopyN over its destination writers, so
the second block for a stripe found the writer in writeState and the
whole backup failed with 'lz4: unhandled state[writeState]'. The v4
reader's WriteTo carries the same fresh-stream restriction.

Wrapper types now hide ReadFrom and WriteTo on the lz4 compressor and
decompressor, so every copy goes through Write and Read regardless of
how callers combine io.Copy and io.CopyN. The sequential-CopyN test pins
the striped copy shape for all builtin engines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Arthur Schreiber <arthur@planetscale.com>
Copilot AI review requested due to automatic review settings July 31, 2026 18:17

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ad4a4034c5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread go/vt/mysqlctl/compression.go Outdated
}

type (
// writeCloserOnly hides every method of the wrapped writer except

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove the newly added explanatory comments

The wrapper documentation added here is explanatory commentary, which the repository instructions explicitly prohibit unless it was requested; keep the implementation limited to the requested change and remove these comments.

AGENTS.md reference: AGENTS.md:L24-L24

Useful? React with 👍 / 👎.

@arthurschreiber arthurschreiber Jul 31, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm keeping the comments: they state a constraint the code cannot express — io.Copy and io.CopyN select ReadFrom/WriteTo whenever those methods are visible, and the lz4 v4 implementations only accept a fresh stream — which is the only thing standing between a one-field wrapper type and a future simplification that reinstates the xb_backup CI failure. The rule against explanatory commentary targets narration of what code does; a comment recording why the wrapper must exist is the kind I want in this codebase.

Comment thread go/vt/mysqlctl/compression.go Outdated
return decompressor, err
}

type (

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Consolidate types in the required top-of-file block

This introduces another mid-file type block even though the repository convention requires all declarations in one type (...) block at the top of the file; place these wrappers in that consolidated block rather than adding a separate declaration here.

AGENTS.md reference: AGENTS.md:L182-L186

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in e95eff9: the wrapper types now sit in a single type block at the top of the file. The pre-existing externalCompressor and externalDecompressor types stay mid-file with their methods; consolidating them is unrelated to this PR.

Comment thread go/vt/mysqlctl/compression.go Outdated
CompressionLevel: compressionLevel,
lz4Writer := lz4.NewWriter(writer)
if err := lz4Writer.Apply(
lz4.ConcurrencyOption(backupCompressBlocks),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve nonpositive backup compression concurrency

When an existing deployment sets --backup-storage-number-blocks=0 or a negative value, the v2 writer accepted it (0 selected serial compression and a negative value selected GOMAXPROCS), whereas v4's ConcurrencyOption rejects values below 1. Passing the flag through directly therefore makes Apply fail and aborts every LZ4 backup at compressor creation after upgrade; normalize these legacy values before applying the option.

AGENTS.md reference: AGENTS.md:L240-L244

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The stated failure does not occur: ConcurrencyOption in v4.1.27 normalizes any non-positive value to runtime.GOMAXPROCS(0) at options.go:131-134 and never rejects it, so Apply cannot fail on the flag value and no backup aborts. The adjacent real change is the opposite of a rejection: v2's WithConcurrency treated 0 (and 1) as serial compression and only negatives as GOMAXPROCS, so a configured --backup-storage-number-blocks=0 would have silently jumped from serial to all-cores compression. Fixed in e95eff9: 0 now maps to 1, which the v4 writer treats as serial, and negative values pass through with the same GOMAXPROCS meaning in both versions. TestLz4ConcurrencyBlocksMapping pins the mapping.

…evel

The lz4 v2 writer ran the hash-chain compressor for any non-zero level
and treated a non-positive search depth as unlimited, so a configured
--compression-level=-1 meant the slowest, best-ratio mode; grouping
negative values with 0 and 1 flipped them to the fastest mode. A
negative level now maps to Level9, whose search depth of 131072 is the
closest the v4 enum offers to the old unlimited search.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Arthur Schreiber <arthur@planetscale.com>
Copilot AI review requested due to automatic review settings July 31, 2026 18:28

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

The lz4 v2 writer compressed serially for a --backup-storage-number-blocks
value of 0, while the v4 concurrency option reads any non-positive value
as GOMAXPROCS; 0 now maps to 1, which the v4 writer treats as serial, so
the configured behavior is unchanged. Negative values mean GOMAXPROCS in
both versions and pass through.

The stream-wrapper types move into a single type block at the top of the
file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Arthur Schreiber <arthur@planetscale.com>
Copilot AI review requested due to automatic review settings July 31, 2026 18:38

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Backport to: release-23.0 Needs to be backport to release-23.0 Backport to: release-24.0 Needs to be backport to release-24.0 Component: Backup and Restore Type: Bug Type: Dependencies Dependency updates

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants