Stable outbox handler identifiers #434
Closed
rolandbeisel
started this conversation in
Ideas
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Stable outbox handler identifiers
Problem
Namastack persists a handler identifier on each outbox record. The identifier is later used to find
the handler, fallback handler, and retry policy responsible for that record.
For regular handlers, the current identifier is derived from the concrete target class and handler
method:
This is a useful zero-configuration default, but it makes the persisted record depend on application
implementation details. Moving a class to another package, renaming a class or method, or changing a
parameter type changes the identifier. Records written before such a change can then no longer be
routed to their handler.
Lambda handlers have an additional problem: generated lambda class names are not stable across JVM
runs. Mainline Namastack therefore already uses the Spring bean name as the identifier for
interface-based lambda handlers.
The objective is to let applications and integrations opt into a logical identifier without
breaking existing handlers, existing databases, or source compatibility.
Goals
Non-goals
identifier.
Compatibility model
The feature is optional. Existing declarations remain valid:
The annotation attributes have defaults:
Interface handlers receive JVM default methods:
An empty annotation ID or a
nullinterface ID means that the handler has not opted into a stableID. Regular handlers then keep the existing class-and-method identifier. This avoids source,
behavioral, and data compatibility breaks.
Terminology
Canonical ID
The identifier written to newly created outbox records.
Routing ID
Any identifier that resolves to a handler. The routing IDs of a handler consist of its canonical ID
and all registered aliases.
Alias
An alternative routing ID that resolves to the same handler but is not written to new records.
Aliases may represent either:
The second use is required for rolling-deployment-safe migrations. Aliases must therefore be
supported independently of whether an explicit canonical ID is configured.
Identifier resolution
The canonical ID is selected using the following precedence.
Annotation-based handler
Examples:
An annotation-based bean may contain multiple handler methods. Explicit IDs are therefore naturally
per method. No Spring bean name is required to distinguish them.
Interface-based handler
The explicit interface ID takes precedence over the lambda default if a non-lambda implementation or
wrapper supplies one. A plain Java or Kotlin SAM lambda cannot override additional interface default
methods, so its practical default remains the Spring bean name.
Stable Spring bean names
Spring bean names are unique within their application context, but automatically derived names can
change when a component class or
@Beanmethod is renamed. Applications that rely on a bean name asa persistent identifier should declare it explicitly:
Alias construction
Configured aliases are always registered, including when no explicit stable ID is configured.
When a regular handler opts into an explicit canonical ID, Namastack also registers its previous
generated target-class and method ID automatically:
For a regular AOP-proxied handler, the current runtime proxy-class identifier may also be registered
for compatibility with records written by versions that used the runtime proxy class.
Generated lambda class identifiers must not be registered automatically, whether the lambda is
proxied or not. A generated lambda ID is not stable across JVM runs and the class name observed in the
current process may not match a value stored by an earlier process.
Some historical identifiers cannot be reconstructed automatically, including:
Known values can be supplied explicitly:
@OutboxHandler( id = "order-created", aliases = [ "com.oldpackage.OrderHandler#handle(com.example.OrderCreated)", ], ) fun handle(event: OrderCreated) {}Registration model
Each logical handler registration consists of:
This does not require the public implementation to use this exact data class, but all three runtime
lookups must share the same routing-ID set:
Otherwise an old record might find its primary handler through an alias while silently receiving the
default retry policy or no fallback handler.
Only canonical IDs are used when scheduling new records and when listing primary handler
descriptors. Aliases are lookup-only metadata.
Uniqueness and validation
Canonical IDs and aliases share one global routing namespace within a Namastack handler registry.
No routing ID may resolve to two different handlers.
The following configuration is invalid:
Configured IDs and aliases must not be blank. Duplicate detection must happen at application startup
and report:
Registration must validate the complete canonical-ID and alias set before mutating any handler type
indexes. A rejected handler must not remain partially registered.
Mixed annotation and interface discovery
A bean can implement a handler interface and also declare an
@OutboxHandlermethod. The annotationand interface scanners may then discover the same logical method.
The implementation must define one deterministic behavior. The recommended behavior is to
deduplicate registrations by bean identity and invocable method, with annotation configuration taking
precedence for that method. If the discoveries describe different logical handlers, they remain
separate and must satisfy the global routing-ID uniqueness rule.
If annotation and interface aliases apply to the same registration, they should be combined rather
than silently discarding one source.
Rolling deployments
The asymmetric compatibility problem
Suppose the old canonical ID is:
and the new stable ID is:
After a one-step rolling deployment, a new instance understands old records because it registers the
old generated ID as an alias. An old instance does not understand records written with
order-created, because that instance has never registered the new ID.If an old instance claims such a record, handler lookup fails with an exception equivalent to:
The record is not silently lost, but it may accumulate failures, use the default retry policy, lack a
matching fallback, or reach permanent failure before the rollout completes.
Safe two-deployment migration
Aliases allow both old and new application instances to understand both IDs before the write ID is
changed.
Deployment 1: register the future ID as an alias
The explicit
idremains empty:After this rolling deployment finishes, every instance understands both IDs while all instances still
write the old ID.
Deployment 2: switch the canonical ID
The routing configuration becomes:
During the second rolling deployment:
order-createdorder-createdorder-createdand old generated IDEvery instance can therefore process every record.
The same procedure applies to interface handlers by returning the future ID from
getHandlerAliases()in the first deployment and returning it fromgetHandlerId()in the second.Alternatives
If two application deployments are not practical, an application may instead:
A database migration can normalize old records after the switch, but it does not by itself make a
mixed-version rollout safe: old instances still cannot understand newly written IDs unless processing
is paused or the future ID was registered in advance.
Removing aliases
Aliases should not necessarily remain part of the application contract forever. A typical lifecycle
is:
Operators should be able to group pending records by handler ID before removing an alias. The exact
query depends on the persistence module and configured table names, but is conceptually:
Failure behavior
An unknown handler ID is a routing or configuration failure, not an exception thrown by application
handler code. Namastack should preserve the record and surface a clear diagnostic containing the
record ID and handler ID.
Retries can be useful during deployments, but they are not a replacement for bidirectional ID
compatibility. The behavior of fallback processing for an unknown handler must be deliberate: a
fallback associated with a known handler cannot be selected until the handler ID itself resolves.
Required test coverage
Compatibility
idretains the existing generated ID.getHandlerId()retains the existing generated ID.Explicit IDs and aliases
Lambdas and proxies
Validation
Migration
Documentation requirements
The public handler documentation must include:
Decision summary
idgetHandlerId()getHandlerId()This design keeps current applications unchanged by default, gives integrations a stable logical
routing contract when they opt in, and supports both backward-compatible record processing and safe
rolling migrations through bidirectional aliases.
All reactions