From b0e4074df847387970be20460bab805fdc75f14d Mon Sep 17 00:00:00 2001 From: antonydellavecchia Date: Mon, 6 Jul 2026 10:43:31 +0200 Subject: [PATCH 1/4] fix: recognize DEV-version files that need serialization upgrade The load path gated upgrades on `file_version < VERSION_NUMBER`. For DEV builds the file's version string carries a commit hash (e.g. "1.8.0-DEV-"), so this compared two VersionNumbers by their hash characters rather than by upgrade state. A file could then be judged up-to-date and skip upgrades it actually needed, causing load to fail. Gate instead on the newest upgrade script's version, applied to the file's effective upgrade version (which decodes the n_upgrades count encoded in DEV version strings). `upgrade()` now uses the same effective version so the gate and the per-script loop stay consistent. Verified by loading a file saved as "1.8.0-DEV-" that previously failed. Co-Authored-By: Claude Opus 4.8 --- src/Serialization/Upgrades/main.jl | 29 ++++++++++++++++++++++++++++- src/Serialization/main.jl | 7 ++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/Serialization/Upgrades/main.jl b/src/Serialization/Upgrades/main.jl index 9f4272a74d9e..81435b6c74ad 100644 --- a/src/Serialization/Upgrades/main.jl +++ b/src/Serialization/Upgrades/main.jl @@ -314,6 +314,32 @@ include("1.8.0+1.jl") const upgrade_scripts = collect(upgrade_scripts_set) sort!(upgrade_scripts; by=version) +# For DEV versions like "1.8.0-DEV-1-hash", the n_upgrades count tells us how many +# upgrade scripts for that base version existed when the file was saved — meaning those +# scripts were already applied. Map to the version of the n-th script so upgrade() +# correctly skips already-applied scripts. +# +# Julia parses "1.8.0-DEV-1-hash" with prerelease = ("DEV-1-hash",) as a single +# identifier, so we split on "-" to extract n_upgrades. +function effective_upgrade_version(format_version::VersionNumber) + pre = format_version.prerelease + isempty(pre) && return format_version + + # pre[1] has format "DEV--" or "DEV-" (n_upgrades=0) + pre_str = string(pre[1]) + startswith(pre_str, "DEV-") || return format_version + + parts = split(pre_str, "-") + length(parts) < 3 && return format_version # no n_upgrades encoded + + n_upgrades = tryparse(Int, string(parts[2])) + (isnothing(n_upgrades) || iszero(n_upgrades)) && return format_version + + base = Base.thispatch(format_version) + base_num = findfirst(s -> version(s) == base, upgrade_scripts) + return version(upgrade_scripts[base_num+n_upgrades-1]) +end + ################################################################################ # Loading with upgrade checks on dict const backref_sym = Symbol("#backref") @@ -327,9 +353,10 @@ has been achieved. """ function upgrade(format_version::VersionNumber, dict::AbstractDict{Symbol, Any}) upgraded_dict = dict + eff_version = effective_upgrade_version(format_version) for upgrade_script in upgrade_scripts script_version = version(upgrade_script) - if format_version < script_version + if eff_version < script_version # TODO: use a macro from Hecke that will allow user to suppress # such a message @debug("upgrading serialized data....", diff --git a/src/Serialization/main.jl b/src/Serialization/main.jl index 9803df1cc310..2919259b44fd 100644 --- a/src/Serialization/main.jl +++ b/src/Serialization/main.jl @@ -893,7 +893,12 @@ function load(io::IO; params::Any = nothing, type::Any = nothing, file_version = load_node(s) do obj serialization_version_info(obj) end - if file_version < VERSION_NUMBER + # A file needs upgrading iff some upgrade script targets a version newer than the + # file's effective version. Gate on the newest upgrade script rather than on + # VERSION_NUMBER: for DEV builds the file's version string carries a commit hash + # (e.g. "1.8.0-DEV-"), and comparing that against VERSION_NUMBER orders the two + # by hash characters rather than by upgrade state, which can wrongly skip upgrades. + if effective_upgrade_version(file_version) < version(last(upgrade_scripts)) # we need a mutable dictionary jsondict = copy(s.obj) jsondict = upgrade(file_version, jsondict) From 9751d7c344931d52bd2c05f6c563bf638101af35 Mon Sep 17 00:00:00 2001 From: antonydellavecchia Date: Mon, 6 Jul 2026 14:31:01 +0200 Subject: [PATCH 2/4] Update src/Serialization/Upgrades/main.jl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lars Göttgens --- src/Serialization/Upgrades/main.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Serialization/Upgrades/main.jl b/src/Serialization/Upgrades/main.jl index 81435b6c74ad..a943d7ee9a99 100644 --- a/src/Serialization/Upgrades/main.jl +++ b/src/Serialization/Upgrades/main.jl @@ -326,7 +326,7 @@ function effective_upgrade_version(format_version::VersionNumber) isempty(pre) && return format_version # pre[1] has format "DEV--" or "DEV-" (n_upgrades=0) - pre_str = string(pre[1]) + pre_str = string(only(pre)) startswith(pre_str, "DEV-") || return format_version parts = split(pre_str, "-") From a41aad5ad4b1bdb97b7790aa110391df2dd0114d Mon Sep 17 00:00:00 2001 From: antonydellavecchia Date: Mon, 6 Jul 2026 17:41:28 +0200 Subject: [PATCH 3/4] catch empty tuple upgrades --- src/Serialization/Upgrades/main.jl | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Serialization/Upgrades/main.jl b/src/Serialization/Upgrades/main.jl index a943d7ee9a99..4d7424c30d31 100644 --- a/src/Serialization/Upgrades/main.jl +++ b/src/Serialization/Upgrades/main.jl @@ -195,13 +195,15 @@ function upgrade_recursive(upgrade::Function, s::UpgradeState, dict::AbstractDic elseif type_name == "Tuple" upgraded_entries = Dict{Symbol, Any}[] upgraded_entry = nothing + + # catch empty tuples here before zip + !haskey(dict, :data) && return dict for (type, entry) in zip(dict[:_type][:params], dict[:data]) upgraded_entry = upgrade(s, Dict{Symbol, Any}(:_type => type, :data => entry)) push!(upgraded_entries, upgraded_entry) end dict[:_type][:params] = [u_e[:_type] for u_e in upgraded_entries] dict[:data] = [u_e[:data] for u_e in upgraded_entries] - elseif type_name == "NamedTuple" upgraded_entries = Dict{Symbol, Any}[] upgraded_entry = nothing From 3f93416dd30ade14cc5ae3c40b9955159cfab2c6 Mon Sep 17 00:00:00 2001 From: antonydellavecchia Date: Mon, 6 Jul 2026 17:51:17 +0200 Subject: [PATCH 4/4] test upgrade with new empty tuple file --- test/Serialization/upgrades/setup_tests.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Serialization/upgrades/setup_tests.jl b/test/Serialization/upgrades/setup_tests.jl index 81c8f0411d11..33ff11715406 100644 --- a/test/Serialization/upgrades/setup_tests.jl +++ b/test/Serialization/upgrades/setup_tests.jl @@ -9,7 +9,7 @@ if !isdefined(Main, :serialization_upgrade_test_path) || !isfile(joinpath(Main.serialization_upgrade_test_path, "LICENSE.md")) - serialization_upgrade_test_path = let commit_hash = "e3473ad614217bc19fad7f809de2d863984be157" + serialization_upgrade_test_path = let commit_hash = "46416710289058b38fd69fecb0809905e7b667bb" tarball = Downloads.download("https://github.com/oscar-system/serialization-upgrade-tests/archive/$(commit_hash).tar.gz") destpath = open(CodecZlib.GzipDecompressorStream, tarball) do io