Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Project.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name = "TestShards"
uuid = "acceef1d-f5e0-4fe4-a546-818dc56ce7b2"
version = "0.3.34"
version = "0.3.35"
authors = ["sota shimozono <shimozono-sota631@g.ecc.u-tokyo.ac.jp>"]

[deps]
Expand Down
108 changes: 104 additions & 4 deletions src/coverage.jl
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,89 @@ function line_totals(fs::AbstractVector{LcovFile})
return (sum(first, t; init=0), sum(last, t; init=0))
end

"""
counter_index(cov) -> String

The COUNTED LINES of a Julia `.cov` file, as `"<line>,<hits>"` — one per line, with a
`# lines <n>` header naming the source's line count.

Julia writes `Foo.jl.<pid>.cov` as a 9-character counter column, one space, and then **a
verbatim copy of the source line**. Measured on one 48-file shard payload of
`ParaLinearAlgebra.jl` (issue #74): 965,565 bytes, of which **815,592 (85.7 %) is that source
text**, and only 2,134 of 15,181 lines carry a counter at all — the rest are `-`. Since every
shard emits counters for the WHOLE tree rather than the files it touched, an 8-shard run ships
eight near-identical copies of the package's source through artifact storage. That exhausted an
organisation's Actions storage quota, which fails the run at `Shard labels`, before any test.

The index drops both redundancies. Same payload: **15,049 bytes, 64.2× smaller**.

Nothing is lost, because the text is already at the destination: `collect` runs
`actions/checkout` BEFORE `download-artifact` (it must — Codecov builds the file network from
the tree), so [`restore_counters`](@ref) rebuilds each `.cov` from the checkout. Verified on
that artifact: the embedded text is byte-identical to the repo file at the run's `head_sha` for
48 of 48 files, and the round trip reproduces the original `.cov` byte for byte for 48 of 48.
"""
function counter_index(cov::AbstractString)
io = IOBuffer()
n = 0
for line in eachline(cov)
n += 1
h = strip(SubString(line, 1, min(9, lastindex(line))))
(isempty(h) || h == "-") && continue
println(io, n, ",", h)
end
return string("# lines ", n, "\n", String(take!(io)))
end

# Rebuild `Foo.jl.<pid>.cov` from an index and the source that is already checked out. The
# format is Julia's: `%9s` of the counter (or `-`), a space, then the source line verbatim.
#
# THE MISMATCH CHECK IS NOT DEFENSIVE, IT IS THE FAILURE MODE THIS INTRODUCES. A short or
# shifted `.cov` does not look broken to CoverageTools — it reports FEWER covered lines, which
# reads as a coverage drop rather than as a bug, and nothing announces it. That is the same
# shape as the silent 54.5%-for-94.8% this file's other docstring records, so it refuses by
# name instead: the index carries the line count the shard saw, and a source that disagrees
# with it, or is absent from the checkout, stops the merge.
function _restore_from_index(idx::AbstractString, src::AbstractString, out::AbstractString)
lines = readlines(idx)
hdr = findfirst(startswith("# lines "), lines)
hdr === nothing && throw(
ArgumentError(
"restore_counters: $(idx) has no `# lines <n>` header; it is not a counter index",
),
)
want = parse(Int, strip(SubString(lines[hdr], length("# lines ") + 1)))
isfile(src) || throw(
ArgumentError(
"restore_counters: $(idx) indexes $(src), which is not in the checkout. The " *
"counters are rebuilt against the working tree, so `collect` must check out the " *
"SAME revision the shards ran. Rebuilding without it would silently under-report.",
),
)
text = readlines(src)
length(text) == want || throw(
ArgumentError(
"restore_counters: $(src) has $(length(text)) lines but $(idx) was written " *
"against $(want). The checkout is not the revision the shard ran; a `.cov` built " *
"from it would shift every counter and read as a coverage drop, not as an error.",
),
)
hits = Dict{Int,String}()
for l in lines[(hdr + 1):end]
isempty(strip(l)) && continue
c = findfirst(==(','), l)
c === nothing && continue
hits[parse(Int, SubString(l, 1, c - 1))] = String(SubString(l, c + 1, lastindex(l)))
end
mkpath(dirname(out))
open(out, "w") do io
for (i, t) in enumerate(text)
println(io, lpad(get(hits, i, "-"), 9), " ", t)
end
end
return out
end

"""
restore_counters(parts, dest = ".") -> Vector{String}

Expand Down Expand Up @@ -64,11 +147,21 @@ function restore_counters(parts::AbstractString, dest::AbstractString=".")
isempty(shard) && continue
for (root, _, files) in walkdir(entry)
for f in files
endswith(f, ".cov") || continue
# `.cov.idx` is the compact form (issue #74); a real `.cov` still restores by
# copy, so a run whose shards uploaded before this change and whose `collect`
# runs after it is not a broken run.
isidx = endswith(f, ".cov.idx")
(isidx || endswith(f, ".cov")) || continue
rel = relpath(joinpath(root, f), entry)
out = joinpath(dest, _tag_counter(rel, shard))
mkpath(dirname(out))
cp(joinpath(root, f), out; force=true)
out = joinpath(dest, _tag_counter(isidx ? chop(rel; tail=4) : rel, shard))
if isidx
_restore_from_index(
joinpath(root, f), joinpath(dest, _index_source(rel)), out
)
else
mkpath(dirname(out))
cp(joinpath(root, f), out; force=true)
end
push!(written, out)
end
end
Expand All @@ -83,6 +176,13 @@ function _counter_shard(dir::AbstractString)
return String(dir[(last(i) + 1):end])
end

"`src/Foo.jl.123.cov.idx` → `src/Foo.jl`: the source the index was written against."
function _index_source(rel::AbstractString)
base = chop(rel; tail=length(".cov.idx"))
i = findlast(==('.'), base) # strip the `.<pid>` Julia appends
return i === nothing ? base : String(SubString(base, 1, i - 1))
end

"`src/Foo.jl.123.cov` → `src/Foo.jl.123-s3.cov`, which `Foo.jl.*.cov` still matches."
function _tag_counter(rel::AbstractString, shard::AbstractString)
return string(chop(rel; tail=length(".cov")), "-", shard, ".cov")
Expand Down
98 changes: 98 additions & 0 deletions test/core/test_coverage.jl
Original file line number Diff line number Diff line change
Expand Up @@ -207,3 +207,101 @@ end
@test TestShards._counter_shard("testshards-records") == ""
@test TestShards._counter_shard("testshards-coverage-s12") == "s12"
end

# ── the compact counter index (issue #74) ────────────────────────────────────────────────────
#
# Julia's `.cov` is a 9-char counter column, a space, and a VERBATIM COPY OF THE SOURCE LINE.
# Measured on a real 48-file shard payload: 85.7 % of the bytes are that copy, and only 14.1 %
# of lines carry a counter at all — so an 8-shard run uploads eight copies of the source tree,
# which is what exhausted an organisation's Actions storage quota. `collect` already checks the
# tree out before downloading, so the text never needed to travel.

@testset "#74: the index keeps only counted lines, and rebuilds the .cov exactly" begin
d = mktempdir()
src = joinpath(d, "src")
mkpath(src)
# a source with covered lines, uncovered lines, and `-` lines that carry no counter
text = """
module M
# a comment, which is not executable
f(x) = x + 1

g(y) = y * 2
end
"""
write(joinpath(src, "M.jl"), text)
cov = joinpath(src, "M.jl.4242.cov")
counters = ["-", "-", " 7", "-", " 0", "-"]
open(cov, "w") do io
for (c, t) in zip(counters, split(text, '\n')[1:6])
println(io, lpad(c, 9), " ", t)
end
end

idx = TestShards.counter_index(cov)
# ONLY the counted lines survive — including the ZERO, which is the difference between
# "reached and never taken" and "not executable", and dropping it would inflate coverage.
@test occursin("# lines 6", idx)
@test occursin("3,7", idx)
@test occursin("5,0", idx)
@test !occursin("2,", idx)
@test length(idx) < length(read(cov, String)) ÷ 2 # measured 61.8× on the real payload

# ROUND TRIP against the checkout, which is what `collect` does
parts = joinpath(d, "parts", "testshards-coverage-s3", "src")
mkpath(parts)
write(joinpath(parts, "M.jl.4242.cov.idx"), idx)
w = only(TestShards.restore_counters(joinpath(d, "parts"), d))
@test w == joinpath(d, "src", "M.jl.4242-s3.cov")
@test read(w, String) == read(cov, String) # byte for byte
end

@testset "#74: a checkout that cannot support the index REFUSES, loudly" begin
d = mktempdir()
mkpath(joinpath(d, "src"))
write(joinpath(d, "src", "M.jl"), "a = 1\nb = 2\n")
parts = joinpath(d, "parts", "testshards-coverage-s1", "src")
mkpath(parts)

# (a) the source is not in the checkout at all
write(joinpath(parts, "Gone.jl.1.cov.idx"), "# lines 2\n1,3\n")
err = try
TestShards.restore_counters(joinpath(d, "parts"), d)
nothing
catch e
sprint(showerror, e)
end
@test err !== nothing
@test occursin("not in the checkout", err)
rm(joinpath(parts, "Gone.jl.1.cov.idx"))

# (b) the source is there but is a DIFFERENT revision. This is the one that must not pass
# quietly: a shifted rebuild reports fewer covered lines, which reads as a coverage drop
# rather than as a bug, and nothing else in the pipeline would notice.
write(joinpath(parts, "M.jl.1.cov.idx"), "# lines 9\n1,3\n")
err2 = try
TestShards.restore_counters(joinpath(d, "parts"), d)
nothing
catch e
sprint(showerror, e)
end
@test err2 !== nothing
@test occursin("2 lines but", err2) && occursin("written", err2)

# (c) a file that is not an index at all
write(joinpath(parts, "M.jl.1.cov.idx"), "1,3\n")
@test_throws ArgumentError TestShards.restore_counters(joinpath(d, "parts"), d)
end

@testset "#74: a raw .cov still restores by copy, so a mid-upgrade run is not broken" begin
# Shards that uploaded before this change and a `collect` that runs after it must still
# merge — the two halves of one run can straddle a workflow bump.
d = mktempdir()
parts = joinpath(d, "parts", "testshards-coverage-s1", "src")
mkpath(parts)
write(joinpath(parts, "M.jl.1.cov"), " 5 a = 1\n")
dest = joinpath(d, "repo")
w = only(TestShards.restore_counters(joinpath(d, "parts"), dest))
@test w == joinpath(dest, "src", "M.jl.1-s1.cov")
@test read(w, String) == " 5 a = 1\n" # copied, not rebuilt
end
Loading