bitflicker64 opened a new pull request, #3376:
URL: https://github.com/apache/hugegraph/pull/3376

   ## Purpose of the PR
   
   - Step 1 of 3 toward closing #3235 (schema caches of Servers that share one 
PD and HStore drift
     apart). The full change, reviewed in ten rounds, is 
hugegraph/hugegraph#322, which this PR and
     the next two cut into pieces a PD reviewer and a Server reviewer can each 
judge alone:
     1. this PR: the PD side of one atomic schema commit (the `txn` RPC, the 
per-graph record, the
        replay guard, ReadIndex on reads);
     2. PD delivers record changes over the existing watch with ACK and retry;
     3. the Server commits schema through `txn`, syncs its caches from the 
records and answers 503
        until synced. Step 3 closes #3235 and supersedes the PD half of #3237.
   - Implements the atomic-commit part of the maintainer's schema sync design: 
a schema change and
     its per-graph revision reach PD as one raft entry, so a crash between puts 
cannot leave half a
     change, and the revision is allocated at commit.
   - Base is master, which holds #3368 (the ReadIndex this PR waits on) and 
#3369.
   - Org mirror: hugegraph/hugegraph#326 (same branch name, same body).
   
   Today a schema DDL reaches PD as several separate puts, each its own raft 
entry. A crash between
   them leaves half a change, and nothing in PD records that the graph's schema 
changed. This PR adds
   the commit primitive; steps 2 and 3 build the notification and the Server 
side on it.
   
   ### Why `txn` has no caller after this merges, and why that is fine
   
   Nothing in PD or the Server calls `txn` until step 3 lands: the Server keeps 
writing schema with
   plain puts, and the apply listener has no subscriber until step 2. For every 
existing deployment
   this PR is inert. It lands first because the protocol, the applier and the 
replay guard are the
   part a PD reviewer can judge on their own, and the tests cover them without 
a Server. The reads
   do change: `get` and `scanPrefix` now wait for a ReadIndex (see below), 
which #3368 made cheap.
   The `txn` RPC and the read barrier are documented in 
`hugegraph-pd/docs/api-reference.md` and
   `configuration.md` in this PR.
   
   ### Scope: what this step holds, and what waits
   
   The design doc names three responsibilities for the first release; this PR 
is the PD half of the
   first one and nothing else. Not here, by the doc's own scope:
   
   - Conflict control for concurrent DDL from several Servers. One Server 
writes a graph's schema at
     a time; the doc keeps this out of the first release. The one compare the 
Server will use is
     "config absent" on create.
   - Request deduplication or idempotency keys. PD keeps no request ids; a 
`txn` is sent once and a
     failure is an unknown outcome for the caller (see the client below).
   - A graph lifecycle state machine (CREATING, COMMIT_CREATE, ADOPT, drop 
markers). The record has
     two states, LIVE and DROPPED, and an incarnation.
   - Tombstone cleanup. A DROPPED record stays, one small key per dropped graph.
   
   A review finding that needs one of these gets a reply pointing at this list 
rather than a change.
   Bugs in what is listed below get fixed here.
   
   ## Main Changes
   
   One commit, 19 files, 878 production, 1,390 test and 91 doc lines.
   
   **The `txn` RPC** on the KV service (`kv.proto`): compares (`ABSENT`, 
`EQUALS`), ops (`PUT`,
   `DELETE`, `DELETE_PREFIX`) and at most one graph record op. The response 
carries `succeeded`,
   `revision`, `incarnation`, `state`, `failure` and the failed compare. A 
compare or an op with an
   empty key is rejected before the TXN is proposed: to a `DELETE_PREFIX` an 
empty key is the whole
   KV space.
   
   **The record** `HUGEGRAPH/{cluster}/SCHEMA_SYNC/{graphspace}/{graph}`, value
   `{"rev": <raft index>, "inc": <incarnation>, "state": "LIVE" | "DROPPED"}`. 
`rev` is the raft
   index of the TXN that wrote it, so PD allocates it at commit, it only rises 
per graph, and it
   orders records across incarnations. The three record ops and what they 
reject:
   
   - `BUMP` sets `rev` to the raft index. With expected incarnation 0 it 
creates an absent record as
     LIVE incarnation 1 (a graph from before records, or one opened from 
`conf/graphs`). It fails
     with `GRAPH_DROPPED` on a dropped graph and `INCARNATION_MISMATCH` on 
another incarnation.
   - `CREATE` starts the next incarnation (or 1) as LIVE, whatever the stored 
state, and marks the
     record `"src":"create"`. The applier itself never refuses a CREATE; the 
Server (step 3) guards
     it with compares on its config key and on the record as it read it, so a 
LIVE record is never
     taken over.
   - `DROP` marks the expected incarnation DROPPED. It fails with 
`INCARNATION_MISMATCH` when the
     record is absent or of another incarnation, and `GRAPH_DROPPED` when it is 
dropped already.
   
   So an instance of an earlier incarnation cannot write into a recreated 
graph. A stored record
   that is not a JSON object with positive integer `rev` and `inc` and a known 
`state` fails every
   TXN on it with `INVALID_RECORD` instead of counting as absent, which would 
restart the
   incarnation at 1. Only a missing key is absent: an empty value is a value.
   
   **The `src` field** is an origin fact, not a lifecycle state. A CREATE 
writes it, BUMP and DROP
   keep it, nothing removes it, and a record a BUMP created has none. Step 3 
uses it so that a REST
   drop on a Server that does not serve the graph releases only a name an 
unfinished create left
   behind, never a `conf/graphs` graph another Server serves. The applier 
parses it, and only the
   value `create` counts as a mark.
   
   **`KvTxnApplier`** runs the compares, checks the record, and writes the ops 
plus the new record
   in one RocksDB batch (`HgKVStoreImpl.writeBatch`). It reads only the store 
and the entry. The
   node that proposes a TXN builds that entry (`TxnEntry` in `kv.proto`, 
internal to PD): the
   request plus every key encoded as that node encodes the keys of `put`, so 
every replica writes
   the bytes the proposer chose whatever its own default charset, and `get` 
reads what a TXN wrote.
   A successful TXN with a record calls the registered `TxnApplyListener` on 
the apply path; that
   is the hook step 2 subscribes to, and a listener failure keeps the TXN.
   
   **The replay guard.** jraft applies the entries after the last snapshot 
again on restart, and
   without a snapshot it does so over the store that already holds them. Plain 
puts are blind
   overwrites, so replaying them is harmless only in order. Each TXN writes its 
own raft index under
   one reserved key, `__TXN_LAST_INDEX` (`KvTxnApplier.LAST_INDEX_KEY`, not 
under any prefix a
   client reads), in the same batch, and on replay `RaftKVStore` skips every 
store write, TXN or
   plain, at or below that index. The one invariant: a store write whose raft 
index is at or below
   `__TXN_LAST_INDEX` is already in the store. Skipping only the TXNs would not 
do: a plain put from
   before the upgrade, replayed before a skipped TXN, would overwrite what the 
TXN wrote. Plain
   writes after the last TXN replay in order to the same store. TTL entries and 
CLEAR are not
   guarded, since they act on the in-memory TTL cache, not on RocksDB. A PD run 
without raft
   (`raft.enable=false`) has no log index and counts its TXNs under a key of 
its own,
   `__TXN_LOCAL_INDEX`, so a store switched to raft later does not take that 
count for a raft
   index. A live proposal whose index the store claims to hold already (a store 
restored without
   its log) is refused with an error to its caller instead of skipped: a 
skipped write would
   answer success and write nothing. Only a replay, which has no caller, is 
skipped.
   
   **Why a TXN is sent once.** PD keeps no request ids, and a TXN applied twice 
is not always
   harmless: a guarded CREATE fails its compare the second time. So 
`KvClient.txn` makes one
   attempt, plus one more on a new stub only when the connection was refused 
(the TXN never left the
   client). A follower forwards a TXN to the leader before proposing it; a 
leader whose apply failed
   answers with the error and never forwards, since the entry may be committed 
already and the next
   leader would apply it again. A throw is an unknown outcome, and the caller 
reads the state before
   retrying. A failed `txn` drops the client's stub only on UNAVAILABLE and 
only if it still holds
   the channel of the call, so it drops no stub another thread installed since; 
the client throws a
   `PDException` when a `txn` call gets no response, where the other KV calls 
hit a
   NullPointerException, and one that carries the gRPC status in its message 
and cause when PD
   answered with one, so a call an older PD rejected (UNIMPLEMENTED) is told 
from a lost one
   (UNAVAILABLE, DEADLINE_EXCEEDED).
   
   **ReadIndex on `get` and `scanPrefix`.** A leader elected a moment ago may 
not have applied its
   whole log, so a plain read could return a record older than a committed TXN, 
and a Server would
   open a graph with a stale incarnation. Both reads wait for a raft ReadIndex 
(the mechanism from
   #3368) and fail with a `PDException` when the leader cannot confirm within 
`raft.rpc-timeout`,
   instead of returning an old value.
   
   **Raft plumbing.** `KVOperation.TXN`, the closure adapter passes the apply 
result to the
   proposer (it dropped it before), and a TXN that fails in raft reaches the 
caller with its cause.
   
   ### Decisions
   
   Of the decisions recorded on hugegraph/hugegraph#322, the ones this step 
carries:
   
   1. Atomic commits and read barriers. One raft entry and one RocksDB batch; 
revision = the raft
      index; authoritative reads wait for a ReadIndex. The small generic 
request shape (two compare
      kinds, three op types, one record op) is kept rather than a purpose-built 
schema-commit message:
      the generality costs about 25 lines in the applier and 20 in the proto, 
CREATE needs the
      compare, and section 7 of the design leaves conditional commits open. 
Alternative: a per-graph
      counter in the record with no ordering across incarnations, or the 
purpose-built message and a
      second RPC later if compares are ever wanted.
   2. No request ids and no dedup window (the PD half of decision 4). Send 
once, unknown outcome on
      failure, the caller reads the state before retrying. Alternative: a 
request-id window in PD
      (about 120 production lines and a cleanup task), which the earlier series 
carried and the doc
      keeps out of the first release.
   3. Mixed-version writers (decision 11). An old Server's plain schema puts 
are outside the
      convergence guarantee, as section 7 of the doc says. The replay guard is 
what keeps a PD restart
      from reapplying such a put over a TXN; the old cache clear event stays 
until every Server is
      upgraded.
   4. Landing shape (decision 8, updated). Three apache PRs, PD commit, PD 
watch, Server, one at a
      time, each cut from the reviewed branch of hugegraph/hugegraph#322. 
Alternative: one PR, or two.
   
   ### Rollout
   
   Upgrade every PD node before any Server of step 3. An old PD follower that 
logs `Err op 10` after
   the upgrade did not know the TXN entry and is rebuilt from a snapshot. This 
PR alone changes no
   Server behaviour, so Servers of the current release keep running against an 
upgraded PD.
   
   ## Verifying these changes
   
   - [x] Need tests and can be verified as follows:
       - `KvTxnApplierTest` (17, `hg-pd-test`, in `PDCoreSuiteTest`): 
`testOpsWrittenTogether` and
         `testFailedCompareWritesNothing` (one batch or nothing), 
`testEmptyValueIsNotAbsent`,
         `testDeletePrefix`, `testRecordLifecycle`, 
`testCreateMarksTheRecordForGood`,
         `testDropChecksStateAndIncarnation`, 
`testGuardedCreateWaitsForTheNameToBeDropped`,
         `testIncarnationMismatch`, `testUnreadableRecordFailsTheTxn` 
(INVALID_RECORD),
         `testTxnSentAgainIsAppliedAgain` (why the client sends once),
         `testListenerFailureKeepsTheTxn`, `testValidate` (empty keys and a 
record op without its key
         or expected incarnation are refused before the proposal), 
`testKeysAreTheProposersBytes`
         (an entry a proposer with another charset built is applied with its 
bytes, and an entry
         whose keys do not match its request is refused), 
`testStoreWithoutRaftKeepsItsOwnCount`
         (a store used without raft, then run with raft, skips nothing),
         `testStoreAheadOfTheLogRefusesLiveWrites` (a live put or txn below the 
stored index fails
         with an error and writes nothing), and through a real raft group
         `testRaftApplyReturnsTheResult`, `testRaftTxnFailureKeepsItsCause` and
         `testRaftReplayOfLegacyPlainWritesAndTxns` (legacy plain writes and 
TXNs replayed on a
         restart without a snapshot keep the last writer).
       - `KvServiceTest.testTxn` (`hg-pd-test`): the service path with a record.
       - `KvServiceGrpcImplTest` (`hg-pd-service`): 
`testGetAndScanReadAfterTheReadIndex`,
         `testFailedReadIndexReturnsNoStoredValue` (an error and no stored 
value, never an old one),
         `testTxnIsRedirectedToTheLeader` (a follower forwards; a deposed 
leader answers with the
         error instead of forwarding).
       - `KvClientTest` (`hg-pd-test`, against a packaged PD): 
`testTxnLifecycleThroughRaft`,
         `testConcurrentCreatesThroughRaft` (one winner), 
`testTxnWithoutAResponseIsAPdException`,
         `testOnlyAnUnavailableTxnDropsTheStub`, 
`testRefusedTxnIsSentOnceMoreOnANewStub`,
         `testTxnRefusedTwiceFails`, `testTxnRejectedByPdNamesTheStatus` 
(UNIMPLEMENTED from a PD
         without the RPC reaches the caller as that status, not as an 
unreachable PD).
       - Suites on this head (JDK 17, Linux, library, 2026-10-20, 
run/failures/errors/skipped):
         pd-common 83/0/0/0, pd-core 211/0/0/2, pd-client 90/0/0/0 against a 
packaged PD started
         with `travis/start-pd.sh`; the `hg-pd-service` module tests run inside 
each of them;
         `editorconfig:check` clean. Server unit and core suites are unchanged 
by this PR; they ran on
         the full branch of hugegraph/hugegraph#322 (unit 1384/0/0/8, core on 
hstore 992/0/0/76).
       - Replay negative control, on a packaged PD (library, 2026-10-18, script 
`replay-neg.sh`
         under the notes of hugegraph/hugegraph#322): with the guard narrowed 
to skip only TXNs on
         replay, a legacy put of `a=old`, a TXN put of `a=new` and a restart 
without a snapshot leave
         `a=old`; with the guard as in this PR the same sequence keeps `a=new`
         (`testRaftReplayOfLegacyPlainWritesAndTxns` is the same check in a 
unit test).
       - The two-Server evidence (a change on one Server visible on the other 
in about 20 ms, 503
         within 2 s of PD stopping, service back 5 s after it returns) needs 
all three steps and is
         in step 3's description; the numbers are on hugegraph/hugegraph#322 
today.
   
   ## Does this PR potentially affect the following parts?
   
   - [ ]  Dependencies
   - [ ]  Modify configurations
   - [x]  The public API: `txn` on the PD KV gRPC service, `KvClient.txn`, and 
`get` and
          `scanPrefix` wait for a ReadIndex (an error instead of a stale value 
when the leader cannot
          confirm within `raft.rpc-timeout`). `TxnEntry` in `kv.proto` is the 
raft payload, internal
          to PD.
   - [x]  Other affects: an old PD follower rebuilt from a snapshot after the 
upgrade (Rollout above).
   - [ ]  Nope
   
   ## Documentation Status
   
   - [x] `Doc - Done`: documentation is included here.
   
   Documentation files in this PR or paired hugegraph-doc PR:
   `hugegraph-pd/docs/api-reference.md` (the `Txn` RPC under the KV service, 
the ReadIndex note
   under `Get`, the "KV transactions" section of the Java client: the request 
shape, the three
   record ops and their failures, send-once and the unknown outcome, the replay 
guard, and the
   retry advice that now excludes `txn`) and 
`hugegraph-pd/docs/configuration.md` (the
   `raft.rpc-timeout` row names `get` and `scanPrefix` among the reads that 
fail on the ReadIndex
   deadline). The watch, the ACK and the record repair notes land with step 2.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to