fix(@angular/build): preserve binary values in the SQLite cache store - #33947
Merged
Merged
Conversation
There was a problem hiding this comment.
Code Review
This pull request updates the SqliteCacheStore to use V8 structured clone serialization (serialize and deserialize from node:v8) instead of JSON stringification. This preserves binary data, such as Uint8Array outputs, which would otherwise be corrupted by JSON round-trips. The SQLite schema is updated to store values as BLOB instead of TEXT, and a fallback is implemented to treat legacy JSON-serialized entries as cache misses. Comprehensive unit tests have been added to verify binary preservation and legacy fallback behavior. There are no review comments, and I have no additional feedback to provide.
alan-agius4
reviewed
Aug 27, 2026
The SQLite cache store serialized values with `JSON.stringify` and read them back
with `JSON.parse`. Several cached values contain binary data: the JavaScript
transformer stores its worker output as a `Uint8Array` (`Cache<Uint8Array>`), and
`CachedLoadResultEntry.contents` is typed as `string | Uint8Array`. A JSON round
trip cannot represent typed arrays, so those values came back from disk as plain
objects (`{"0":105,"1":109,...}`) and were handed to esbuild as load result
contents, failing the build with `"contents" must be a string or a Uint8Array`.
The failure only appeared from the second build onwards, because the first build
serves the value from the in-memory cache layer and the value is only corrupted
once it is read back from disk.
Values are now persisted using the V8 structured clone serialization API
(`node:v8`), which supports typed arrays natively and matches the behavior of the
LMDB store. The `value` column is declared as `BLOB` accordingly. SQLite column
types are dynamic, so a stored value is checked at runtime before it is
deserialized; values that are not binary, or whose payload is corrupt, are
treated as a cache miss and recreated.
The store is only reached when LMDB fails to load, which is why this went
unnoticed on most systems. A common trigger is a prebuilt `@lmdb/lmdb-linux-x64`
binary requiring a newer glibc than the host provides, for example on Ubuntu
20.04, Debian 11, or RHEL/CentOS 8. The fallback can also be selected explicitly
with `NG_BUILD_CACHE_STORE=sqlite`.
Closes angular#33841
manInit
force-pushed
the
fix-sqlite-cache-binary-values
branch
from
August 27, 2026 09:43
ea5f8d0 to
2954209
Compare
Collaborator
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.
PR Checklist
Please check to confirm your PR fulfills the following requirements:
PR Type
What kind of change does this PR introduce?
What is the current behavior?
Issue Number: #33841
SqliteCacheStorepersists values withJSON.stringify/JSON.parse. Some cached values are binary:JavaScriptTransformeris constructed withCache<Uint8Array>and stores the raw worker output under thejstransformernamespace.CachedLoadResultEntry.contentsis typedstring | Uint8Array.A JSON round trip cannot represent a typed array, so those values come back from disk as plain objects:
createCachedLoad()then returns that object as the result ofbuild.onLoad({ filter: /\.[cm]?js$/ }, …)inangular/compiler-plugin.ts, and esbuild fails the build:The first build always succeeds — the value is served from the in-memory cache layer and is only corrupted once it has been read back from disk — so the failure starts on the second build against a warm cache.
createPersistentCacheStore()only reaches the SQLite store when LMDB fails to load, which is why this is not hit on most machines. The trigger I ran into is the prebuilt@lmdb/lmdb-linux-x64@3.5.6binary requiringGLIBC_2.33on a host with glibc 2.31 (Ubuntu 20.04); Debian 11 and RHEL/CentOS 8 have the same glibc. That matches the reports in #33841, which was closed as not reproducible — the team could not reproduce it because LMDB loads fine on newer distributions, and the reporter's "fix" was upgrading the build agent from Ubuntu 20.04 to Ubuntu 24.04, i.e. moving back onto the LMDB path.Reproduction independent of the host, forcing the store through the existing environment option:
What is the new behavior?
Values are serialized with the V8 structured clone API (
serialize/deserializefromnode:v8), which supports typed arrays natively and matches how the LMDB store already behaves.node:sqliteaccepts aUint8Arrayparameter as a BLOB and returns aUint8Array, so the round trip is lossless, and thevaluecolumn is declaredBLOBaccordingly.Old cache entries need no migration. They contain JSON text,
deserializethrows on them, and the existingcatchinget()already reports that as a cache miss, so those entries are recreated and overwritten. The cache path is also namespaced by package version (.angular/cache/<VERSION>/).Added regression coverage in
sqlite-cache-store_spec.ts: aUint8Arrayround trip, aUint8Arraynested in an object (the shape ofCachedLoadResultEntry), a round trip across two store instances (the on-disk path, which is where the bug actually shows), and a check that an entry which cannot be deserialized is treated as a cache miss. Binary round tripping was not covered before. All four fail onmainand pass with this change.The existing size-pruning test still passes unchanged —
length()on a BLOB column returns the byte length, and the serialized payloads stay within the same 25 byte limit. I updated its comment to match the new byte counts.Does this PR introduce a breaking change?
Other information
Considered and rejected: keeping JSON with a replacer/reviver and base64. It inflates the cache by roughly a third and is easy to get wrong, since
Buffer.prototype.toJSONruns before the replacer.Separately, and not part of this PR:
createPersistentCacheStore()swallows the LMDB load error entirely before falling back to SQLite. Logging the reason for the fallback would have made this much easier to diagnose — the build just fails later in an unrelated place. Happy to open a follow-up issue or PR for that if it is wanted.