[
https://issues.apache.org/jira/browse/KAFKA-20732?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
]
Adam Horky updated KAFKA-20732:
-------------------------------
Description:
Size-based remote retention deletes the oldest data when
{{{}onlyLocalLogSegmentsSize + remoteLogSizeBytes > retention.bytes{}}}.
{{onlyLocalLogSegmentsSize}} sums segments with {{{}baseOffset >=
highestOffsetInRemoteStorage(){}}}. That field is in-memory, initialised to
{{{}-1{}}}, not recovered on startup, and seeded only by the copy and follower
tasks.
After a leader election the copy and expiration tasks both start at {{-1}} and
are scheduled with {{initialDelay = 0}} on separate thread pools, so they race.
If the expiration task runs first, the filter {{baseOffset >= -1}} matches the
entire local log, and those segments are also counted in {{remoteLogSizeBytes}}
— the same offsets counted twice. The apparent size roughly doubles,
{{retention.bytes}} appears breached, and the broker deletes the oldest remote
segments and advances {{{}logStartOffset{}}}. {{logStartOffset}} replicates to
followers, so each replica deletes local segments below the new floor.
In-retention data is removed from both tiers on all replicas; consumers receive
{{OFFSET_OUT_OF_RANGE}} for the removed offsets.
The follower path already seeds this offset before it can cause harm:
{{RLMFollowerTask.execute()}} does so with the comment _"so that the local log
segments are not deleted before they are copied to remote storage."_ The
leader/expiration path, which runs size-retention, has no equivalent step.
h2. Example - one affected partition
A 24-partition test topic at its cap ({{{}retention.bytes = 134217728{}}} = 128
MiB, {{{}local.retention.bytes = -2{}}}, {{{}retention.ms = -1{}}}); both tiers
held the full window (remote ≈ local ≈ cap, copy-lag 0). One broker is
restarted and becomes the fresh leader of partition {{{}...-1{}}}; ~40 s later
its expiration task runs while {{highestOffsetInRemoteStorage}} is still
{{{}-1{}}}:
{code:java}
# leader shared-sasl-kafka-1 — size-retention on a partition whose unique data
is ~128 MiB:
08:43:59.420 RLMExpirationTask ...repro.v1-1] About to delete remote log
segment ... due to
retention size 134217728 breach. Log size after deletion will be
282854919
# cap = 134217728 (128 MiB); computed ~270 MiB = local(~128) +
remote(~128), counted twice
# ... deletes 9 remote segments, recomputing downward until
just above the cap:
08:43:59.430 ... due to retention size 134217728 breach. Log size after
deletion will be 149820412
08:43:59.430 UnifiedLog ...repro.v1-1] Incremented log start offset to 289616
due to segment deletion
# the new log-start floor replicates to the followers:
08:43:59.680 shared-sasl-kafka-0 ...repro.v1-1] Incremented log start offset
to 289616 due to leader offset increment
08:44:36.319 shared-sasl-kafka-2 ...repro.v1-1] Incremented log start offset
to 289616 due to leader offset increment
# each replica then deletes its local segments below the floor — data now gone
from BOTH tiers:
08:44:21.146 UnifiedLog ...repro.v1-1] Deleting segments due to local log
start offset 289616 breach:
LogSegment(baseOffset=144975,...), ... ,(baseOffset=273618,...)
# 9 segments, then LocalLog "Deleting segment files"
{code}
{{logStartOffset}} jumped 144975 → 289616: ~144,641 records (~141 MiB) removed
from both tiers, even though the true unique size (~128 MiB) was within the cap.
h2. Reproductions
* *Staging (3.9.0, KRaft, RF=3, Aiven RSM):* a rolling restart over-deleted 6
of 12 partitions of a topic that sat at its {{{}retention.bytes{}}}; on each,
the new {{logStartOffset}} equalled {{highestCopiedRemoteOffset + 1}} (the
whole copied window expired).
* *Test cluster:* the topic above, rolling-restarted, over-deleted 13 of 24
partitions (example shown). A control topic with {{retention.bytes = -1}}
(size-retention disabled) under the identical restart was unaffected.
* *Unit test (trunk and 3.9.0):* two tests with identical remote metadata and
{{{}retention.bytes{}}}, differing only in {{onlyLocalLogSegmentsSize}}
({{{}0{}}} when seeded → no deletion; whole-log when {{-1}} → both segments
deleted). A companion {{UnifiedLogTest}} confirms the real {{UnifiedLog}}
returns the whole local log at {{{}-1{}}}.
h2. Fix
Seeding {{highestOffsetInRemoteStorage}} from {{__remote_log_metadata}} before
size-retention (mirroring the follower path) makes the unit test pass and
removes the over-deletion on the test cluster (same fill + restart, zero
{{logStartOffset}} jumps).
* failing test (repro, no fix), diff vs trunk:
[https://github.com/apache/kafka/compare/trunk...horkyada:kafka:tiered-size-retention-unseeded-highest-offset-repro]
* proposed fix, diff vs trunk:
[https://github.com/apache/kafka/compare/trunk...horkyada:kafka:fix-seed-highest-remote-offset]
(Happy to open a PR against apache/kafka once I have an ICLA.)
h2. Scope
Size-retention only - {{retention.ms}} is unaffected (time-retention never
reads {{{}onlyLocalLogSegmentsSize{}}}/{{{}highestOffsetInRemoteStorage{}}}).
The loss requires a populated remote tier on a topic at/near its cap (with {{R
≈ 0}} there is nothing to double-count). Magnitude scales with the local-tier
size; with the default {{local.retention.bytes = -2}} it is the whole window.
h2. Related issues (same area, different root cause — not a duplicate)
* *KAFKA-17212* - same method, but a {{{}>={}}}{-}vs{-}{{{}>{}}} off-by-one
double-counting a _single_ segment while the offset is correct;
{{{}>={}}}→{{{}>{}}} does not fix this (with {{{}-1{}}}, {{baseOffset > -1}}
still matches everything). This is a whole-log over-count.
* *KAFKA-16711* - same {{-1}} stale-offset class, different trigger (logDir
altering), and it _blocks_ cleanup rather than over-deleting.
* *KAFKA-20148* - same {{RLMExpirationTask}} data-loss class, but the trigger
is _disabling_ remote storage (cancel-vs-execute). This needs no disable.
(Happy to provide the source line-by-line walk-through and the full
reproduction setup if useful.)
was:
Size-based remote retention deletes the oldest data when
{{onlyLocalLogSegmentsSize + remoteLogSizeBytes > retention.bytes}}.
{{onlyLocalLogSegmentsSize}} sums segments with {{baseOffset >=
highestOffsetInRemoteStorage()}}. That field is in-memory, initialised to
{{-1}}, not recovered on startup, and seeded only by the copy and follower
tasks.
After a leader election the copy and expiration tasks both start at {{-1}} and
are scheduled with {{initialDelay = 0}} on separate thread pools, so they race.
If the expiration task runs first, the filter {{baseOffset >= -1}} matches the
entire local log, and those segments are also counted in {{remoteLogSizeBytes}}
— the same offsets counted twice. The apparent size roughly doubles,
{{retention.bytes}} appears breached, and the broker deletes the oldest remote
segments and advances {{logStartOffset}}. {{logStartOffset}} replicates to
followers, so each replica deletes local segments below the new floor.
In-retention data is removed from both tiers on all replicas; consumers receive
{{OFFSET_OUT_OF_RANGE}} for the removed offsets.
The follower path already seeds this offset before it can cause harm:
{{RLMFollowerTask.execute()}} does so with the comment _"so that the local log
segments are not deleted before they are copied to remote storage."_ The
leader/expiration path, which runs size-retention, has no equivalent step.
h2. Example — one affected partition
A 24-partition test topic at its cap ({{retention.bytes = 134217728}} = 128
MiB, {{local.retention.bytes = -2}}, {{retention.ms = -1}}); both tiers held
the full window (remote ≈ local ≈ cap, copy-lag 0). One broker is restarted and
becomes the fresh leader of partition {{...-1}}; ~40 s later its expiration
task runs while {{highestOffsetInRemoteStorage}} is still {{-1}}:
{code}
# leader shared-sasl-kafka-1 — size-retention on a partition whose unique data
is ~128 MiB:
08:43:59.420 RLMExpirationTask ...repro.v1-1] About to delete remote log
segment ... due to
retention size 134217728 breach. Log size after deletion will be
282854919
# cap = 134217728 (128 MiB); computed ~270 MiB = local(~128) +
remote(~128), counted twice
# ... deletes 9 remote segments, recomputing downward until
just above the cap:
08:43:59.430 ... due to retention size 134217728 breach. Log size after
deletion will be 149820412
08:43:59.430 UnifiedLog ...repro.v1-1] Incremented log start offset to 289616
due to segment deletion
# the new log-start floor replicates to the followers:
08:43:59.680 shared-sasl-kafka-0 ...repro.v1-1] Incremented log start offset
to 289616 due to leader offset increment
08:44:36.319 shared-sasl-kafka-2 ...repro.v1-1] Incremented log start offset
to 289616 due to leader offset increment
# each replica then deletes its local segments below the floor — data now gone
from BOTH tiers:
08:44:21.146 UnifiedLog ...repro.v1-1] Deleting segments due to local log
start offset 289616 breach:
LogSegment(baseOffset=144975,...), ... ,(baseOffset=273618,...)
# 9 segments, then LocalLog "Deleting segment files"
{code}
{{logStartOffset}} jumped 144975 → 289616: ~144,641 records (~141 MiB) removed
from both tiers, even though the true unique size (~128 MiB) was within the cap.
h2. Reproductions
* *Staging (3.9.0, KRaft, RF=3, Aiven RSM):* a rolling restart over-deleted 6
of 12 partitions of a topic that sat at its {{retention.bytes}}; on each, the
new {{logStartOffset}} equalled {{highestCopiedRemoteOffset + 1}} (the whole
copied window expired).
* *Test cluster:* the topic above, rolling-restarted, over-deleted 13 of 24
partitions (example shown). A control topic with {{retention.bytes = -1}}
(size-retention disabled) under the identical restart was unaffected.
* *Unit test (trunk and 3.9.0):* two tests with identical remote metadata and
{{retention.bytes}}, differing only in {{onlyLocalLogSegmentsSize}} ({{0}} when
seeded → no deletion; whole-log when {{-1}} → both segments deleted). A
companion {{UnifiedLogTest}} confirms the real {{UnifiedLog}} returns the whole
local log at {{-1}}.
h2. Fix
Seeding {{highestOffsetInRemoteStorage}} from {{__remote_log_metadata}} before
size-retention (mirroring the follower path) makes the unit test pass and
removes the over-deletion on the test cluster (same fill + restart, zero
{{logStartOffset}} jumps).
* failing test (repro, no fix), diff vs trunk:
[https://github.com/apache/kafka/compare/trunk...horkyada:kafka:tiered-size-retention-unseeded-highest-offset-repro]
* proposed fix, diff vs trunk:
[https://github.com/apache/kafka/compare/trunk...horkyada:kafka:fix-seed-highest-remote-offset]
(Happy to open a PR against apache/kafka once I have an ICLA.)
h2. Scope
Size-retention only — {{retention.ms}} is unaffected (time-retention never
reads {{onlyLocalLogSegmentsSize}}/{{highestOffsetInRemoteStorage}}). The loss
requires a populated remote tier on a topic at/near its cap (with {{R ≈ 0}}
there is nothing to double-count). Magnitude scales with the local-tier size;
with the default {{local.retention.bytes = -2}} it is the whole window.
h2. Related issues (same area, different root cause — not a duplicate)
* *KAFKA-17212* — same method, but a {{>=}}-vs-{{>}} off-by-one double-counting
a _single_ segment while the offset is correct; {{>=}}→{{>}} does not fix this
(with {{-1}}, {{baseOffset > -1}} still matches everything). This is a
whole-log over-count.
* *KAFKA-16711* — same {{-1}} stale-offset class, different trigger (logDir
altering), and it _blocks_ cleanup rather than over-deleting.
* *KAFKA-20148* — same {{RLMExpirationTask}} data-loss class, but the trigger
is _disabling_ remote storage (cancel-vs-execute). This needs no disable.
(Happy to provide the source line-by-line walk-through and the full
reproduction setup if useful.)
> Data loss: tiered size-retention deletes in-retention data from both tiers
> after leader election when highestOffsetInRemoteStorage is unset (-1)
> ------------------------------------------------------------------------------------------------------------------------------------------------
>
> Key: KAFKA-20732
> URL: https://issues.apache.org/jira/browse/KAFKA-20732
> Project: Kafka
> Issue Type: Bug
> Components: Tiered-Storage
> Affects Versions: 3.9.0, 4.3.0
> Reporter: Adam Horky
> Priority: Critical
>
> Size-based remote retention deletes the oldest data when
> {{{}onlyLocalLogSegmentsSize + remoteLogSizeBytes > retention.bytes{}}}.
> {{onlyLocalLogSegmentsSize}} sums segments with {{{}baseOffset >=
> highestOffsetInRemoteStorage(){}}}. That field is in-memory, initialised to
> {{{}-1{}}}, not recovered on startup, and seeded only by the copy and
> follower tasks.
> After a leader election the copy and expiration tasks both start at {{-1}}
> and are scheduled with {{initialDelay = 0}} on separate thread pools, so they
> race. If the expiration task runs first, the filter {{baseOffset >= -1}}
> matches the entire local log, and those segments are also counted in
> {{remoteLogSizeBytes}} — the same offsets counted twice. The apparent size
> roughly doubles, {{retention.bytes}} appears breached, and the broker deletes
> the oldest remote segments and advances {{{}logStartOffset{}}}.
> {{logStartOffset}} replicates to followers, so each replica deletes local
> segments below the new floor. In-retention data is removed from both tiers on
> all replicas; consumers receive {{OFFSET_OUT_OF_RANGE}} for the removed
> offsets.
> The follower path already seeds this offset before it can cause harm:
> {{RLMFollowerTask.execute()}} does so with the comment _"so that the local
> log segments are not deleted before they are copied to remote storage."_ The
> leader/expiration path, which runs size-retention, has no equivalent step.
> h2. Example - one affected partition
> A 24-partition test topic at its cap ({{{}retention.bytes = 134217728{}}} =
> 128 MiB, {{{}local.retention.bytes = -2{}}}, {{{}retention.ms = -1{}}}); both
> tiers held the full window (remote ≈ local ≈ cap, copy-lag 0). One broker is
> restarted and becomes the fresh leader of partition {{{}...-1{}}}; ~40 s
> later its expiration task runs while {{highestOffsetInRemoteStorage}} is
> still {{{}-1{}}}:
> {code:java}
> # leader shared-sasl-kafka-1 — size-retention on a partition whose unique
> data is ~128 MiB:
> 08:43:59.420 RLMExpirationTask ...repro.v1-1] About to delete remote log
> segment ... due to
> retention size 134217728 breach. Log size after deletion will
> be 282854919
> # cap = 134217728 (128 MiB); computed ~270 MiB = local(~128)
> + remote(~128), counted twice
> # ... deletes 9 remote segments, recomputing downward until
> just above the cap:
> 08:43:59.430 ... due to retention size 134217728 breach. Log size after
> deletion will be 149820412
> 08:43:59.430 UnifiedLog ...repro.v1-1] Incremented log start offset to
> 289616 due to segment deletion
> # the new log-start floor replicates to the followers:
> 08:43:59.680 shared-sasl-kafka-0 ...repro.v1-1] Incremented log start offset
> to 289616 due to leader offset increment
> 08:44:36.319 shared-sasl-kafka-2 ...repro.v1-1] Incremented log start offset
> to 289616 due to leader offset increment
> # each replica then deletes its local segments below the floor — data now
> gone from BOTH tiers:
> 08:44:21.146 UnifiedLog ...repro.v1-1] Deleting segments due to local log
> start offset 289616 breach:
> LogSegment(baseOffset=144975,...), ... ,(baseOffset=273618,...)
> # 9 segments, then LocalLog "Deleting segment files"
> {code}
> {{logStartOffset}} jumped 144975 → 289616: ~144,641 records (~141 MiB)
> removed from both tiers, even though the true unique size (~128 MiB) was
> within the cap.
> h2. Reproductions
> * *Staging (3.9.0, KRaft, RF=3, Aiven RSM):* a rolling restart over-deleted
> 6 of 12 partitions of a topic that sat at its {{{}retention.bytes{}}}; on
> each, the new {{logStartOffset}} equalled {{highestCopiedRemoteOffset + 1}}
> (the whole copied window expired).
> * *Test cluster:* the topic above, rolling-restarted, over-deleted 13 of 24
> partitions (example shown). A control topic with {{retention.bytes = -1}}
> (size-retention disabled) under the identical restart was unaffected.
> * *Unit test (trunk and 3.9.0):* two tests with identical remote metadata
> and {{{}retention.bytes{}}}, differing only in {{onlyLocalLogSegmentsSize}}
> ({{{}0{}}} when seeded → no deletion; whole-log when {{-1}} → both segments
> deleted). A companion {{UnifiedLogTest}} confirms the real {{UnifiedLog}}
> returns the whole local log at {{{}-1{}}}.
> h2. Fix
> Seeding {{highestOffsetInRemoteStorage}} from {{__remote_log_metadata}}
> before size-retention (mirroring the follower path) makes the unit test pass
> and removes the over-deletion on the test cluster (same fill + restart, zero
> {{logStartOffset}} jumps).
> * failing test (repro, no fix), diff vs trunk:
> [https://github.com/apache/kafka/compare/trunk...horkyada:kafka:tiered-size-retention-unseeded-highest-offset-repro]
> * proposed fix, diff vs trunk:
> [https://github.com/apache/kafka/compare/trunk...horkyada:kafka:fix-seed-highest-remote-offset]
> (Happy to open a PR against apache/kafka once I have an ICLA.)
> h2. Scope
> Size-retention only - {{retention.ms}} is unaffected (time-retention never
> reads {{{}onlyLocalLogSegmentsSize{}}}/{{{}highestOffsetInRemoteStorage{}}}).
> The loss requires a populated remote tier on a topic at/near its cap (with
> {{R ≈ 0}} there is nothing to double-count). Magnitude scales with the
> local-tier size; with the default {{local.retention.bytes = -2}} it is the
> whole window.
> h2. Related issues (same area, different root cause — not a duplicate)
> * *KAFKA-17212* - same method, but a {{{}>={}}}{-}vs{-}{{{}>{}}} off-by-one
> double-counting a _single_ segment while the offset is correct;
> {{{}>={}}}→{{{}>{}}} does not fix this (with {{{}-1{}}}, {{baseOffset > -1}}
> still matches everything). This is a whole-log over-count.
> * *KAFKA-16711* - same {{-1}} stale-offset class, different trigger (logDir
> altering), and it _blocks_ cleanup rather than over-deleting.
> * *KAFKA-20148* - same {{RLMExpirationTask}} data-loss class, but the
> trigger is _disabling_ remote storage (cancel-vs-execute). This needs no
> disable.
> (Happy to provide the source line-by-line walk-through and the full
> reproduction setup if useful.)
--
This message was sent by Atlassian Jira
(v8.20.10#820010)