A welcome email across three versions
Keep the task kindemail:welcome stable while the payload evolves:
Version 2 renames the wire field
address to email. Version 3 adds locale.
This application deliberately chooses English for historical jobs; Headgate does not
choose that default.
Renaming a Go field while retaining its
json:"address" tag does not change the wire
format. The same applies to retaining a Rust field’s serialized name with Serde.
A task’s schema version is also independent of the Headgate package version.What dispatch does
There is one registered handler per task kind, not one handler per kind/version pair.- A producer writes an envelope with the correct
schema_version. - A worker claims the job through normal admission and finds the handler by kind.
- The handler’s decoder reads that stored version and returns the current task type.
- Only a successful decode reaches application handler code.
undecodable, not an ordinary
retryable handler failure. A successfully upcast job uses normal handler outcome rules:
returning a handler error can still retry it.
Conversion runs again on each later execution of the original job. Keep upcasters
deterministic and free of external side effects. There is no automatic v1 → v2 → v3
conversion registry: the current implementation below handles v1 and v2 directly.
Go: implement Versioned
Go’sVersioned interface requires Kind(), Version(), and
Upcast(version, payload) (headgate.Args, error).
For a nonzero stored version different from Version(), Headgate calls Upcast.
For the current version it uses normal JSON decoding instead. The returned value from
Upcast must have exactly the registered type: this example returns a WelcomeEmail
value, not a *WelcomeEmail.
The custom UnmarshalJSON below checks required current-version fields because plain
Go JSON decoding accepts absent fields as zero values. Historical decoders apply the
same validation after mapping their fields.
Go
type wire WelcomeEmail avoids recursively calling UnmarshalJSON. In this
example, an actual v3 payload missing locale is rejected; only v1/v2 get the historical
default. Adapt this validation to your application’s requirements.
Rust: implement Task manually
The current#[derive(Task)] generates identity and a JSON codec. Its default upcaster
accepts only the declared current version. It does not offer an attribute for a custom
upcaster, and you cannot add a second impl Task to override the derived one.
Keep the Serde derives, remove the Task derive and its #[task(...)] attribute, and
write the complete implementation:
Rust
serde_json = "1" alongside headgate and Serde in the application’s Cargo
dependencies. Unlike the Go dispatch path, Rust calls Task::upcast for the current
version too, so the Self::VERSION => Self::decode(bytes) arm is necessary.
Serde rejects missing required String fields. The explicit validation also rejects
empty values. #[serde(alias = "address")], optional fields, and #[serde(default)]
can help design a compatible decoder, but do not bypass Headgate’s version check.
Enqueue v3 explicitly
These snippets belong inside an application function with a configured store. Go needscontext and encoding/json; Rust needs Task in scope.
Use an application-generated job ID for each intended email, and select a retention
period appropriate to the application.
Register one current handler
The handler receivesemail and locale regardless of which supported version was
stored. These registration fragments assume registry exists. Replace the diagnostic
message with your actual cancellable, idempotent delivery operation.
mail queue with the ordinary Go runner or
Rust worker. Do not register the v1, v2, and v3 types separately
under email:welcome; duplicate kind registration is rejected.
Adding a field without breaking old jobs
An added optional field may be wire-compatible if its absence has a defined meaning. A required field needs an explicit policy:- derive it deterministically from the historical payload;
- choose a documented, semantically correct default;
- or decline the conversion when no safe value exists.
locale does not justify accepting a malformed v3 producer that forgot it.
Deploy readers before writers
Admission does not select workers by the payload versions they understand. An old worker can claim a new-version job if it serves that queue. A version-aware old worker may park it as undecodable; an unversioned Go worker may decode it incorrectly.- Deploy the new worker with v1, v2, and v3 decoding while producers still emit the old version.
- Wait until all incompatible workers serving those queues have drained and stopped.
- Switch producers and any stored periodic job definitions to explicitly emit v3.
- Retain old upcasters while old scheduled, retryable, or redrivable jobs can still run.
Stored jobs, redrive, and other identities
Upcasting does not replace the original payload, schema version, ID, or fingerprint in storage. The console still shows the originally stored data. Retrying that job decodes those original bytes again. Do not manually rewrite stored ciphertext or payload metadata as a substitute for a decoder. A task kind rename is separate: retain the old kind as a GoKindAliases() entry or a
Rust Task::ALIASES entry. Aliases route old kind names; they do not rename payload fields.
A task payload version is also distinct from the step schema used by
resumable work. Successfully decoding a job does not make a
changed durable step set compatible.
For encrypted jobs, use encrypted handler registration.
Decryption precedes typed decoding/upcasting, and authenticated data binds the original
job ID, kind, and schema version. Keep old decryption keys as well as old decoders until
all relevant jobs have aged out.
Run the example locally
The source checkout includes examples that need no database, mail server, or credentials:ErrNoUpcastPath; Rust tests verify
CodecError::UnknownVersion. Both examples run in scripts/test-examples.sh.
These checks demonstrate payload evolution, not live SQL/Redis conformance. For an
application rollout, add fixtures from every payload version you still retain, malformed
inputs, future versions, and the exact producer envelope construction you deploy.