Replacing a Redis Serializer Is Not a One-Line Configuration Change: Object Trust and Rollback-Safe Migration
ENGINEERING NOTES
Published / DavidHLPL
Changing a Redis serializer may appear to require editing one Bean. But Redis stores bytes, not Java objects.
If old cache entries use a JDK serializer or GenericJackson2JsonRedisSerializer and the new serializer uses another envelope, switching directly creates two separate questions:
- Can the new code safely interpret type information in Redis?
- How can existing bytes be migrated and rolled back without overwriting concurrent writes?
Deserialization is dangerous before the object is created
Jackson polymorphic data often carries a type identifier, for example:
{
"version": 2,
"payload": {
"@class": "com.example.User"
}
}
If the type identifier came from Redis, and Redis content may have been written by another application, an old program, or an attacker, then “let Jackson create the object by type and check it in the business layer” is already too late.
The required boundary is:
read bytes
-> check whether the type identifier is allowed
-> only then let Jackson instantiate the object
This is a trust boundary, not ordinary DTO conversion.
ResiCache’s serializer applies three constraints
The current SecureJacksonRedisSerializer has three related mechanisms.
Versioned envelope
Every non-null cached value is wrapped as:
{
"version": 2,
"payload": "..."
}
This version is the serialization-format version. It is not the business-object version and not a cache-value CAS token.
Allowlist policy
The default allowed business-package prefix is:
io.github.davidhlp
Some java.lang, java.time, and java.math types, along with explicitly enumerated collection types, are also allowed.
An allowlist is not simply “polymorphism on or off.” It must answer:
Is this exact fully qualified class name allowed to appear in the cache bytes?
One configuration boundary in the current implementation deserves attention:
com.example.* -> match by package boundary
com.example -> match by startsWith
Thus com.example may also match com.exampleX.SomeType. For strict package boundaries, use a prefix with .* or a sufficiently precise prefix.
Streaming preflight
Before reading the envelope, the serializer uses a streaming parser to inspect:
- the configured type property;
- @class;
- wrapper-array type IDs;
- type identifiers appearing in polymorphic payloads.
Only after these checks pass does it enter EnvelopeCodec.read.
The core flow is:
try (JsonParser parser = objectMapper.createParser(bytes)) {
validateTypeIdsStreaming(parser);
}
Object envelope = EnvelopeCodec.read(objectMapper, bytes);
if (EnvelopeCodec.version(envelope)
!= EnvelopeCodec.currentVersion()) {
// reject when failOnUnknownType=true
}
This is not a complete deserialization security audit, but it places the most important type check before object instantiation.
See SecureJacksonRedisSerializer.java and WhitelistPolicy.java.
Why support migration of old cache entries
Even if the new serializer is safe, old entries may not use the current envelope.
The migration configuration explicitly separates four phases:
| Phase | Behavior | Modifies source key? |
|---|---|---|
| SHADOW_READ | decode and validate the old value | no |
| DUAL_WRITE | keep the old value and write the new envelope to a sidecar | no |
| CUTOVER | back up the old value, then replace the source value | yes |
| ROLLBACK | restore the old value from the backup | yes |
The default phase is SHADOW_READ, and migration does not run automatically at application startup.
SHADOW_READ: learn what the existing data is
SHADOW_READ is intentionally conservative:
scan candidate keys
-> identify whether the current envelope is already present
-> try to decode with the configured legacy serializer
-> record success or failure
-> write nothing to Redis
It solves visibility before migration.
Without knowing the old format and the decodable ratio first, a direct cutover can disguise compatibility failures as ordinary cache misses.
DUAL_WRITE: write through a sidecar first
DUAL_WRITE keeps the old source key and writes the new envelope to a sidecar:
user:42
user:42:__resicache_envelope
The sidecar reuses the source key’s TTL. If migration runs again and the sidecar content is already identical, it does not write again.
This provides two benefits:
- old consumers can continue reading the old source key;
- the new format can be validated before the main data is replaced.
CUTOVER: back up, compare, and replace
The cutover order is:
read old source
-> write a legacy backup sidecar
-> atomically compare the source with the old bytes
-> replace with the new envelope only on equality
-> use KEEPTTL to preserve the lifetime
The essential Lua semantics are:
if redis.call('get', KEYS[1]) == ARGV[1] then
redis.call('set', KEYS[1], ARGV[2], 'KEEPTTL')
return 1
else
return 0
end
If business code writes a new value after migration reads the old value, the comparison fails:
migration reads old
business writes new
migration tries to replace old -> envelope(old)
comparison fails; new is protected
The goal is not to guarantee that migration always completes. It is to prevent the migration tool from becoming another data overwriter.
ROLLBACK must not overwrite blindly either
Rollback does not simply write the backup back to the source.
It first checks whether the current source is still the expected envelope:
current value is still the value produced by this cutover
-> restore the old value
current value was written again by business code
-> reject rollback and protect the new write
Rollback is therefore a conditional recovery, not an unconditional overwrite.
Why not clear Redis and switch
If the cache is truly disposable and the business can accept one full source reload, clearing the old cache before switching serializers may be simpler.
That requires all of the following to be true:
- the cache contains no state that must not be lost;
- the full source-load pressure is acceptable;
- every instance can switch at the same time;
- no old consumer continues writing the old format;
- there is no need for a canary, sidecar validation, or rollback.
The current migration engine is for cases where the cache cannot simply be cleared, the existing format needs to be observed, or concurrent writes need protection. Its cost includes:
- multiple migration phases;
- sidecar and backup keys;
- operator control;
- a legacy decoder;
- migration reports and failure handling;
- additional maintenance of the Redis wire format.
This is not “more advanced” by itself. It exchanges operational complexity for control during migration.
Type safety and migration safety must both hold
The legacy decoder must not bypass the allowlist.
The malicious old JSON used in tests is:
{
"@class": "com.attacker.Gadget"
}
It is rejected during migration too, rather than being trusted merely because it came from an old format.
Migration is not a temporary script outside the security boundary. It reads the same untrusted or uncertain data, so it must reuse the type constraints.
What the tests verify
Current serializer tests cover:
- rejection of an @class outside the allowlist;
- rejection of wrapper-array type identifiers;
- rejection of a custom type property;
- round-trip for an allowed type;
- correct configuration propagation by the serializer factory;
- reading an old CachedValue with missing refresh metadata.
In the real Redis integration test, SerializationMigrationIntegrationTest has eight passing tests covering:
- SHADOW_READ does not write the source key;
- DUAL_WRITE preserves the old value and is idempotent;
- the sidecar preserves TTL;
- CUTOVER backs up the old bytes;
- ROLLBACK restores the old value;
- concurrent business writes are not overwritten;
- non-allowlisted types in a mixed dataset are rejected.
See SerializationMigrationIntegrationTest.java.
Cost and failure boundaries
The boundaries of this design also need to be explicit.
- envelope version is only a format version; it does not automatically migrate arbitrary historical formats;
- the current legacy decoder supports a defined set of formats; other serializers need their own decoder;
- VersionEnvelope.payload uses a fixed @class @JsonTypeInfo property; changing the type property requires annotation and configuration changes together;
- the default allowlist favors the project’s own package names; custom business types must be explicitly configured;
- preflight scans samples and cannot prove that the entire Redis dataset has no old format;
- migration scans are limited by maxKeys, pattern, and batch size, not one whole-database transaction;
- dryRun reduces risk but cannot replace a real cutover test;
- an allowlist and streaming preflight are not a complete supply-chain security audit;
- this round measured no serialization CPU, GC, or Redis-network overhead, so no performance-improvement ratio can be claimed.
A reusable decision method
When changing a persistence format, confirm at least:
- How is the new format identified?
- What old formats exist, and can each be decoded?
- Where are untrusted types rejected?
- Must old consumers keep reading?
- How does cutover avoid overwriting concurrent writes?
- Are TTL, expiry, and sidecars kept consistent?
- How does rollback decide that the current value still belongs to this migration?
- Does one migration failure stop the whole flow, or is it isolated per key?
If these questions have no answers, changing a serializer configuration is not the same as completing a migration.
Sources and related documents
Research notes
- Original document: src/content/note/en/resicache-serialization-migration.md
- Source revision: f49953fb0d4bd52a39bf81167b0c4b7dd7d78a64
- Evidence status: provisional