use serde::de::DeserializeOwned;
use serde_json::Value;
pub const VERSION_MARKER: &str = "__autumn_schema_version";
pub const VERSION_ARGS_KEY: &str = "args";
pub const DEFAULT_VERSION: u32 = 1;
pub type UpgradeError = Box<dyn std::error::Error + Send + Sync + 'static>;
pub type UpgradeFn = fn(u32, Value) -> Result<Value, UpgradeError>;
#[must_use]
pub fn wrap(version: u32, args: Value) -> Value {
let mut obj = serde_json::Map::with_capacity(2);
obj.insert(VERSION_MARKER.to_string(), Value::from(version));
obj.insert(VERSION_ARGS_KEY.to_string(), args);
Value::Object(obj)
}
#[must_use]
pub fn read_version(value: &Value) -> u32 {
as_version_envelope(value).map_or(DEFAULT_VERSION, |(version, _)| version)
}
fn as_version_envelope(value: &Value) -> Option<(u32, &Value)> {
let obj = value.as_object()?;
if obj.len() != 2 {
return None;
}
let version = obj
.get(VERSION_MARKER)?
.as_u64()
.and_then(|v| u32::try_from(v).ok())
.filter(|&v| v >= DEFAULT_VERSION)?;
let args = obj.get(VERSION_ARGS_KEY)?;
Some((version, args))
}
#[must_use]
pub fn split_version(value: &Value) -> (u32, &Value) {
as_version_envelope(value).unwrap_or((DEFAULT_VERSION, value))
}
#[must_use]
pub fn strip_version(value: Value) -> (u32, Value) {
let Some((version, _)) = as_version_envelope(&value) else {
return (DEFAULT_VERSION, value);
};
match value {
Value::Object(mut obj) => {
let inner = obj.remove(VERSION_ARGS_KEY).unwrap_or(Value::Null);
(version, inner)
}
other => (version, other),
}
}
#[derive(Debug)]
enum VersionErrorKind {
NoUpgradePath,
TooNew,
UpgradeFailed,
DecodeFailed,
}
#[derive(Debug)]
pub struct JobPayloadVersionError {
job_type: String,
stored_version: u32,
expected_version: u32,
kind: VersionErrorKind,
source: Option<UpgradeError>,
}
const ERROR_SENTINEL: &str = "stored payload version";
impl JobPayloadVersionError {
#[must_use]
pub fn job_type(&self) -> &str {
&self.job_type
}
#[must_use]
pub const fn stored_version(&self) -> u32 {
self.stored_version
}
#[must_use]
pub const fn expected_version(&self) -> u32 {
self.expected_version
}
#[must_use]
pub fn source_error(&self) -> Option<&(dyn std::error::Error + Send + Sync + 'static)> {
self.source.as_deref()
}
}
impl std::fmt::Display for JobPayloadVersionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let Self {
job_type,
stored_version,
expected_version,
kind,
source,
} = self;
match kind {
VersionErrorKind::NoUpgradePath => write!(
f,
"job \"{job_type}\": stored payload version {stored_version} is incompatible with \
expected version {expected_version} (no upgrade path)"
),
VersionErrorKind::TooNew => write!(
f,
"job \"{job_type}\": stored payload version {stored_version} is newer than expected \
version {expected_version}; this worker cannot decode it (deploy the newer code, \
or roll back the producer)"
),
VersionErrorKind::UpgradeFailed => {
write!(
f,
"job \"{job_type}\": stored payload version {stored_version} could not be \
upgraded to expected version {expected_version}"
)?;
if let Some(source) = source {
write!(f, ": {source}")?;
}
Ok(())
}
VersionErrorKind::DecodeFailed => {
if stored_version == expected_version {
write!(
f,
"job \"{job_type}\": stored payload version {stored_version} does not match \
the current args shape"
)?;
} else {
write!(
f,
"job \"{job_type}\": stored payload version {stored_version} does not match \
the current args shape for expected version {expected_version}"
)?;
}
if let Some(source) = source {
write!(f, ": {source}")?;
}
Ok(())
}
}
}
}
impl std::error::Error for JobPayloadVersionError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.source
.as_ref()
.map(|s| s.as_ref() as &(dyn std::error::Error + 'static))
}
}
#[must_use]
pub fn is_payload_version_error(message: &str) -> bool {
message.contains(ERROR_SENTINEL)
}
pub fn decode_versioned<T: DeserializeOwned>(
job_type: &str,
expected_version: u32,
upgrade: Option<UpgradeFn>,
stored: Value,
) -> Result<T, JobPayloadVersionError> {
let (stored_version, mut args) = strip_version(stored);
if stored_version > expected_version {
return Err(JobPayloadVersionError {
job_type: job_type.to_string(),
stored_version,
expected_version,
kind: VersionErrorKind::TooNew,
source: None,
});
}
let mut version = stored_version;
while version < expected_version {
let Some(upgrade) = upgrade else {
return Err(JobPayloadVersionError {
job_type: job_type.to_string(),
stored_version,
expected_version,
kind: VersionErrorKind::NoUpgradePath,
source: None,
});
};
args = upgrade(version, args).map_err(|source| JobPayloadVersionError {
job_type: job_type.to_string(),
stored_version,
expected_version,
kind: VersionErrorKind::UpgradeFailed,
source: Some(source),
})?;
version += 1;
}
serde_json::from_value(args).map_err(|source| JobPayloadVersionError {
job_type: job_type.to_string(),
stored_version,
expected_version,
kind: VersionErrorKind::DecodeFailed,
source: Some(Box::new(source)),
})
}
pub fn assert_golden_payload<T: DeserializeOwned>(
job_type: &str,
expected_version: u32,
upgrade: Option<UpgradeFn>,
stored_json: &str,
) {
let stored: Value = serde_json::from_str(stored_json)
.unwrap_or_else(|e| panic!("golden fixture for job \"{job_type}\" is not valid JSON: {e}"));
if let Err(error) = decode_versioned::<T>(job_type, expected_version, upgrade, stored) {
panic!("golden payload guard failed: {error}");
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde::Deserialize;
use serde_json::json;
#[test]
fn wrap_then_strip_is_identity_on_args() {
let args = json!({ "a": 1, "b": [2, 3] });
let (version, inner) = strip_version(wrap(4, args.clone()));
assert_eq!(version, 4);
assert_eq!(inner, args);
}
#[test]
fn missing_marker_reads_as_default_version() {
let raw = json!({ "a": 1 });
assert_eq!(read_version(&raw), DEFAULT_VERSION);
let (version, inner) = split_version(&raw);
assert_eq!(version, DEFAULT_VERSION);
assert_eq!(inner, &raw);
assert_eq!(read_version(&Value::Null), DEFAULT_VERSION);
assert_eq!(strip_version(Value::Null), (DEFAULT_VERSION, Value::Null));
}
#[test]
fn marker_without_args_is_not_an_envelope() {
let raw = json!({ VERSION_MARKER: 5, "other": 1 });
assert_eq!(read_version(&raw), DEFAULT_VERSION);
let (sv, si) = split_version(&raw);
assert_eq!(sv, DEFAULT_VERSION);
assert_eq!(si, &raw);
let (tv, ti) = strip_version(raw.clone());
assert_eq!(tv, DEFAULT_VERSION);
assert_eq!(ti, raw);
}
#[test]
fn extra_keys_alongside_marker_and_args_is_not_an_envelope() {
let raw = json!({ VERSION_MARKER: 5, "args": { "x": 1 }, "extra": 2 });
assert_eq!(read_version(&raw), DEFAULT_VERSION);
assert_eq!(split_version(&raw), (DEFAULT_VERSION, &raw));
assert_eq!(strip_version(raw.clone()), (DEFAULT_VERSION, raw));
}
#[test]
fn non_integer_marker_is_not_an_envelope() {
let raw = json!({ VERSION_MARKER: "v3", "args": { "x": 1 } });
assert_eq!(read_version(&raw), DEFAULT_VERSION);
assert_eq!(split_version(&raw), (DEFAULT_VERSION, &raw));
assert_eq!(strip_version(raw.clone()), (DEFAULT_VERSION, raw));
}
#[test]
fn raw_payload_with_marker_field_decodes_whole_object() {
#[derive(Debug, Deserialize, PartialEq, Eq)]
struct Raw {
#[serde(rename = "__autumn_schema_version")]
marker: u32,
other: i64,
}
let raw = json!({ "__autumn_schema_version": 5, "other": 7 });
let decoded: Raw = decode_versioned("j", 1, None, raw).unwrap();
assert_eq!(
decoded,
Raw {
marker: 5,
other: 7
}
);
}
#[test]
fn exact_two_key_envelope_is_detected() {
let env = wrap(3, json!({ "x": 1 }));
assert_eq!(read_version(&env), 3);
assert_eq!(split_version(&env), (3, &json!({ "x": 1 })));
assert_eq!(strip_version(env), (3, json!({ "x": 1 })));
}
#[derive(Debug, Deserialize, PartialEq, Eq)]
struct Args {
user_id: i64,
greeting: String,
}
fn upgrade(from: u32, mut value: Value) -> Result<Value, UpgradeError> {
if from == 1 {
value
.as_object_mut()
.ok_or("expected object")?
.insert("greeting".into(), json!("hi"));
}
Ok(value)
}
#[test]
fn decode_upgrades_missing_marker_as_v1() {
let stored = json!({ "user_id": 3 });
let decoded: Args = decode_versioned("welcome", 2, Some(upgrade), stored).unwrap();
assert_eq!(
decoded,
Args {
user_id: 3,
greeting: "hi".into()
}
);
}
#[test]
fn decode_at_current_version_needs_no_upgrade() {
let stored = wrap(2, json!({ "user_id": 3, "greeting": "yo" }));
let decoded: Args = decode_versioned("welcome", 2, None, stored).unwrap();
assert_eq!(
decoded,
Args {
user_id: 3,
greeting: "yo".into()
}
);
}
#[test]
fn no_upgrade_path_error_names_job_and_versions() {
let stored = wrap(1, json!({ "user_id": 3 }));
let err = decode_versioned::<Args>("send_welcome", 3, None, stored).unwrap_err();
assert_eq!(err.job_type(), "send_welcome");
assert_eq!(err.stored_version(), 1);
assert_eq!(err.expected_version(), 3);
let msg = err.to_string();
assert!(is_payload_version_error(&msg), "detectable: {msg}");
assert!(msg.contains("no upgrade path"), "{msg}");
}
#[test]
fn too_new_error_when_stored_exceeds_expected() {
let stored = wrap(5, json!({ "user_id": 3, "greeting": "hi" }));
let err = decode_versioned::<Args>("welcome", 2, Some(upgrade), stored).unwrap_err();
assert_eq!(err.stored_version(), 5);
assert_eq!(err.expected_version(), 2);
assert!(err.to_string().contains("newer than expected"));
assert!(is_payload_version_error(&err.to_string()));
}
#[test]
fn upgrade_failure_surfaces_source_and_stays_detectable() {
fn bad_upgrade(_from: u32, _v: Value) -> Result<Value, UpgradeError> {
Err("boom".into())
}
let stored = wrap(1, json!({ "user_id": 3 }));
let err = decode_versioned::<Args>("welcome", 2, Some(bad_upgrade), stored).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("boom"), "{msg}");
assert!(is_payload_version_error(&msg));
}
#[test]
fn shape_mismatch_at_current_version_is_a_version_error_not_bare_serde() {
let stored = wrap(2, json!({ "user_id": 3 }));
let err = decode_versioned::<Args>("welcome", 2, None, stored).unwrap_err();
assert!(
err.to_string()
.contains("does not match the current args shape")
);
assert!(is_payload_version_error(&err.to_string()));
}
#[test]
fn is_payload_version_error_rejects_generic_messages() {
assert!(!is_payload_version_error(
"job args deserialization failed: missing field"
));
}
}