Siyao Meng created HDDS-16900:
---------------------------------
Summary: Primary SCM restart can lower the replicated
CertificateId row, so certificate IDs are returned twice
Key: HDDS-16900
URL: https://issues.apache.org/jira/browse/HDDS-16900
Project: Apache Ozone
Issue Type: Bug
Reporter: Siyao Meng
Attachments:
MC-1-do-not-lower-certificate-id-on-primary-scm-start.patch,
TestBugMC1CertificateIdReseed.java, TestBugMC2CertificateIdDivergence.java
h3. Mechanism
On every start of the primary SCM with security enabled,
{{StorageContainerManager.persistPrimarySCMCerts}} calls
{{SequenceIdGenerator.upgradeToCertificateSequenceId(scmMetadataStore, true)}}.
With {{force}} set, that method writes the largest serial found in
{{validSCMCertsTable}} and {{validCertsTable}} (at least 2) into the
{{CertificateId}} row of {{sequenceIdTable}}. It does not compare the new value
with the existing row (the row is only tested for absence, and with {{force}}
the result of that test is ignored), and it writes to the local RocksDB
directly, although once the row exists it is otherwise changed only through
Ratis ({{StateManagerImpl.allocateBatch}}).
The row can be above the largest stored serial.
{{SCMSecurityProtocolServer.getEncodedCertToString}} allocates the ID in one
Ratis transaction before {{DefaultCAServer.signAndStoreCertificate}} signs the
certificate and stores it in a second one. If signing or storing fails, the ID
stays allocated and no certificate carries it. The row is also above the
largest stored serial after the certificate with that serial has expired and
been removed. On the next start of the primary the row is then set back, the
generator loads the lower value and returns IDs it has already returned.
The other SCMs keep the replicated value, so the SCMs now disagree on a
replicated row. {{allocateBatch}} is a compare and set on the previous value,
so from then on an allocation changes the row on some SCMs and is a no-op on
the others, and every SCM that installs a checkpoint from the primary takes
over the lowered row.
The forced call came with HDDS-9138. It is present in every release tag from
ozone-1.4.0 to ozone-2.2.1 (read from the tagged source, not run on a release).
h3. Trigger
# The most recently allocated IDs have no stored certificate when the primary
stops, so the row is above the largest stored serial: signing or the store
transaction failed for the last request, or the certificate with the largest
serial expired and was removed.
# The row is durable on the primary. A clean stop is enough: Ratis takes a
snapshot when the server stops, and {{SCMStateMachine.takeSnapshot}} flushes
the SCM transaction buffer.
# The primary SCM is restarted.
No setting has to be changed for this. If the SCM is killed before the
allocation was flushed, the allocation is replayed from the Raft log after the
forced write and the row is not lowered. The buffer is also flushed every 60
seconds on a leader that is out of safe mode, when an SCM becomes leader and at
every Ratis snapshot, so a kill after one of those leaves the same state as a
clean stop (both read from the code, not run).
h3. Impact
Reproduced on unmodified source unless marked otherwise.
* *An ID is returned twice (one SCM).* With IDs allocated up to 4 and 3 as the
largest stored serial, the row is 3 after a clean restart and the next
certificate gets serial 4. [^TestBugMC1CertificateIdReseed.java]
* *Two certificates with the same serial (one SCM).* After the certificate with
serial 3 expired and was removed, the row is 2 after the restart and the same
CA issues a second certificate with serial 3 for another subject and key. This
needs the certificate with the largest serial to be expired and removed. With
the default lifetime of 365 days, renewal 28 days before expiry, and removal
only by {{ozone admin cert clean}} or by the root CA rotation manager (off by
default), that is not expected on a live cluster. The test uses a lifetime of 3
seconds. [^TestBugMC1CertificateIdReseed.java]
* *SCMs diverge and valid requests are refused (three SCMs, default settings).*
IDs were allocated up to 9 with 7 as the largest stored serial. After its
restart the primary has row 7 and the other two keep 9. Another SCM leads and
issues serials 10 and 11. On the primary the two allocations log "Failed to
allocate a batch for CertificateId, expected lastId is 9, actual lastId is 7."
and "expected lastId is 10, actual lastId is 7." and leave the row at 7. When
the primary leads next, five valid certificate requests in a row end as: issued
8, issued 9, refused with "Conflicting certificate ID10", refused with
"Conflicting certificate ID11", issued 12. One request is refused for every
certificate the other leader issued in the meantime, so the number of refusals
to come grows for as long as an SCM with the higher value leads. The allocation
of 12 is the first that all three SCMs apply again (read from the log of the
run), so from there the rows are equal. A restart of the lowered primary also
raises its row, to the largest serial stored by then, because the forced write
runs again, on unpatched code too (read from the code, not run).
[^TestBugMC2CertificateIdDivergence.java]
* *A new SCM starts with the lowered row (default settings).* A third SCM that
joins while the restarted primary leads installs the primary's checkpoint. Its
row is 7 while the remaining SCM has 9, and when it leads it issues serial 8.
[^TestBugMC2CertificateIdDivergence.java]
* *An existing follower is lowered by a checkpoint (log purge enabled).* With
{{ozone.scm.ha.ratis.log.purge.enabled}} set and a small purge gap and snapshot
threshold, a follower that held row 9 durably and was down while the primary
restarted installs the primary's checkpoint. All three SCMs then report the
same applied index with rows 7, 7 and 9, and the follower issues serial 8 when
it leads. With log purge off (the default) an existing follower catches up from
the log and keeps its row (read from the Ratis code, not run).
[^TestBugMC2CertificateIdDivergence.java]
In the multi SCM runs the IDs that were returned a second time had no stored
certificate, so no two stored certificates shared a serial.
{{SCMCertStore.checkValidCertID}} prevents that, and the same check is what
refuses the requests.
h3. Reproduction
The two attached classes are self contained and pass on unmodified source when
the bug is present (target path and Maven command are in each class comment).
Both go into {{hadoop-ozone/integration-test}} and use MiniKdc. No state is
injected and no source is changed.
{{TestBugMC1CertificateIdReseed}} runs one secure SCM with default flush and
snapshot settings, requests certificates from the handler of
{{SCMSecurityProtocolServer}} (called in process) and stops and starts the SCM.
The second test shortens the certificate lifetime to 3 seconds.
{{TestBugMC2CertificateIdDivergence}} runs a secure SCM HA mini cluster with
three SCMs per test and uses certificate requests, SCM stop and start, the
leadership transfer admin call and the checkpoint install that Ratis requests
by itself. The first and the last test use default Ratis settings. The second
enables log purge with purge gap 5 and snapshot threshold 5.
In both classes the ID without a certificate is produced by the generator call
that {{getEncodedCertToString}} makes
({{getNextId(SequenceIdType.CertificateId)}}), standing in for a request that
fails after the ID was taken. A failed request itself is not part of the
attached tests.
h3. Patch
[^MC-1-do-not-lower-certificate-id-on-primary-scm-start.patch], against
b0aa6475b7856d2c7f467dd3db12b9a1dd116f63. It also applies to master at
f650a22f3e3.
{{upgradeToCertificateSequenceId}} now reads the existing row once and writes
only when there is no row or the largest stored serial is above it. The forced
call from {{persistPrimarySCMCerts}} stays and can still raise the row over a
certificate that was just stored, it can no longer lower it. On a cluster
initialised with the current code the forced call was already a no-op at first
start (the log shows "upgrade CertificateId to 2" twice), so nothing changes
for clusters the bug does not affect. Checkpoint install needs no change: once
the primary keeps its row, the checkpoint it serves carries the replicated
value.
The patch keeps the local write to a replicated row, now raise only. Removing
the forced call (the first seeding of an absent row would stay local) or
sending it through Ratis is the stricter alternative. The patch also does not
repair rows that already differ. They converge as described under Impact.
Covered by {{testForcedCertificateIdUpgradeKeepsAllocatedIds}} in the existing
{{TestSequenceIDGenerator}}, which fails without the change ("expected: 4L but
was: 3L"). With the patch {{TestSequenceIDGenerator}} and seven surrounding SCM
unit suites (68 tests), {{TestSCMInstallSnapshotWithHA}} (3 tests) and five
cases of {{TestSecureOzoneCluster}} pass, and checkstyle is clean. The other
four cases of {{TestSecureOzoneCluster}} and {{TestSecretKeySnapshot}} time out
in this environment with and without the patch (Kerberos RPC connect timeouts)
and were left to CI. With the patch the scenarios of the attached classes keep
the row (4 on the single SCM, 9 on every SCM) and no request is refused, so the
attached classes, which assert the faulty values, fail at their first assertion
on the row.
Found by TLA+ model checking and code review of the SCM sequence ID generator
and its startup and checkpoint paths under HDDS-15926, on commit
b0aa6475b7856d2c7f467dd3db12b9a1dd116f63. Checked against HDDS issues and
apache/ozone pull requests for duplicates before filing. Related but not
duplicates: HDDS-9138 (introduced the forced call), HDDS-16035 (a secure SCM HA
cluster cannot start because the primary refuses to sign certificates for the
other SCMs without a leader) and HDDS-14337 (seeding of the local ID in the
same class). The attached patch is a proposal for review. Generated with
Specula (Claude Opus 5.5).
--
This message was sent by Atlassian Jira
(v8.20.10#820010)
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]