hubcio commented on code in PR #4506:
URL: https://github.com/apache/iggy/pull/4506#discussion_r4270146506
##########
core/server/src/dispatch/mod.rs:
##########
@@ -240,13 +284,153 @@ impl Drop for ActiveDrainSlot {
}
}
+/// The state that the drain task of one connection shares with the requests
+/// it spawned.
+///
+/// The drain takes the frames of the connection in arrival order and runs the
+/// prologue of each. Login, logout, `BIND_SESSION` and partition admission
+/// finish on the drain. Metadata commits run in tasks, but later non-PING
+/// frames wait in the queue until the metadata lane releases, before they
+/// capture session state or resolve a namespace. This orders dispatch, not
local
+/// metadata catch-up: partition writes still resolve from this shard's STM
+/// without waiting for the connection watermark. PING can pass that queue.
+/// Stored-offset reads, Next/auto-commit polls and explicit offset writes
share
+/// a lane per resolved namespace and consumer until their reply or wait
expiry.
+/// Expiry releases only this connection's guard, not an admitted mutation,
which
+/// may still commit after later progress. Independent requests can pass a
queued
+/// conflict, but cannot pass a queued session or metadata barrier. A PING
still waits
+/// behind a frame already suspended in admission or binding.
+///
+/// `capacity` caps those tasks at `message_bus.client_queue_capacity`, the
Review Comment:
Deferred to PR6 as reply-delivery correctness work, not just a cap
benchmark. B7 and the plan require bounded reply ownership through flush,
waiting for bounded delivery, or explicit connection failure on enqueue
refusal; logging and dropping a response while leaving its request pending is
insufficient. Acceptance includes slow readers with Java async pipelines above
queue capacity, bounded retained memory, and a response or explicit connection
failure for every pending call. The ClientDrain comment now states that task
permits end at enqueue and do not reserve outbound slots.
##########
core/server/src/http/forward.rs:
##########
@@ -332,92 +536,69 @@ fn record_relayed_floor(state: &HttpInner, user_id: u32,
response: &Response) {
state.metadata_watermarks.record(user_id, applied);
}
-async fn forward_partition_or_pass(state: HttpState, request: Request, next:
Next) -> Response {
- if !state.forward.active ||
request.headers().contains_key(FORWARDED_HEADER) {
- return next.run(request).await;
- }
- // A read that does not move consumer progress is answered by whichever
- // replica the caller reached, which is the point of reading from one. Only
- // an automatic commit needs a replica that can originate the offset
- // operation, so only that poll pays for the buffering and the credential
- // resolution below.
- if request.method() == Method::GET &&
!wants_auto_commit(request.uri().query()) {
- return next.run(request).await;
- }
+async fn attach_partition_request(state: HttpState, request: Request, next:
Next) -> Response {
+ let received = Instant::now();
+ let hop = match partition_hop(request.headers()) {
+ Ok(hop) => hop,
+ Err(error) => return CustomError::from(error).into_response(),
+ };
+ // Verify-only, like the metadata relay: an unauthenticated caller gets no
+ // buffered body and no relay. The handler authenticates again.
let bearer = match bearer_token(request.headers()) {
Ok(bearer) => bearer,
Err(error) => return CustomError::from(error).into_response(),
};
if let Err(rejection) = resolve_credential(&state, bearer).await {
return rejection.into_response();
}
-
- let (parts, request_body) = request.into_parts();
+ let _guard = if matches!(hop, PartitionHop::Target(_)) {
Review Comment:
Deferred to PR6 as an admission-isolation correctness gate. The earlier
target cap bounds the number of holds but does not prevent a caller without
topic permissions from starving the shared outbound pool. B7 and the plan
require trusted relay provenance and authorization before accepting untrusted
frontier holds, plus capacity isolation/reservation for legitimate forwarding.
The regression must hold 128 spoofed forwarded requests and prove unrelated
authorized traffic still progresses. Rejecting only u64::MAX or changing the
cap alone will not close this item.
##########
core/shard/src/lib.rs:
##########
@@ -6214,145 +6425,71 @@ where
.await;
}
- /// Compare this replica's log against the headers the view decided, and
drop or
- /// report where they disagree.
- ///
- /// Without this, divergence is silent and permanent: the replica acks
with its
- /// own checksum, the primary rejects the ack, and journal repair skips an
op
- /// it already has a header for.
- ///
- /// Both roles. A backup runs it against the `StartView` suffix it
adopted, the
- /// primary-elect against the merged log before the coverage scan in
- /// [`Self::advance_pending_metadata_view`]. The merge does NOT reconcile
the
- /// primary's log for it: nothing installs the merged headers into the
journal,
- /// `RebuildPipeline` reads the pipeline back out of it, and
`CommitJournal`
- /// applies whatever sits at each op up to the merged commit point.
- ///
- /// Runs on every adoption, parked suffix or not: an EMPTY `StartView`
suffix
- /// (`commit == op`, the steady case) parks no pending log, yet adoption
still
- /// drops the head under any journaled relics above it, and the primary's
next
- /// prepare would collide with them in `append` and poison the journal.
With
- /// no pending log the divergence scan has nothing to walk and only the
- /// above-head sweep applies, with the head read off the adopted sequencer.
- ///
- /// The split at the announced commit point is what matters. Above it a
- /// disagreement is ordinary, so the entry is dropped and the primary's
- /// retransmission refills the range. At or below it, this replica applied
- /// something the view says was different, which only state transfer
fixes, so
- /// it is reported and left alone.
- ///
- /// Truncation uses `Journal::truncate_from`, not `drain`: `drain` advances
- /// `snapshot_op` past what it removed, marking ops that must stay
refillable
- /// as evictable.
+ // The shard pump serializes these rewrites with append. The journal's
drain
+ // guard excludes concurrent compaction, but does not exclude append
itself.
#[allow(clippy::future_not_send)]
async fn reconcile_metadata_view_divergence(&self)
where
B: MessageBus,
MJ: JournalHandle,
<MJ as JournalHandle>::Target:
Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
- M: MetadataStm,
{
let metadata = self.plane.metadata();
- let Some(ref consensus) = metadata.consensus else {
+ let (Some(consensus), Some(journal)) = (&metadata.consensus,
&metadata.journal) else {
return;
};
- let pending = consensus.pending_view_log();
- let Some(journal) = metadata.journal.as_ref() else {
+ if !consensus.view_log_unproven() {
return;
- };
-
- // Truncation is safe only above what this replica has *applied*,
which is
- // not the view's commit point: `pending.commit_max` is the new
primary's
- // number and a backup can sit above it. Splitting on the view's number
- // would drop already-executed ops with no rollback, and silently.
- let announced_commit = pending.as_ref().map_or(0, |pending|
pending.commit_max);
- let applied_floor = consensus.commit_min();
-
- let mut repairable_from: Option<u64> = None;
- for canonical in pending.as_ref().map_or(&[][..], |pending|
&pending.headers) {
- let Some(local) = usize::try_from(canonical.op)
- .ok()
- .and_then(|slot| journal.handle().header(slot))
- else {
- continue;
- };
- if header_is_view_entry(&local, canonical) {
- continue;
- }
- if canonical.op <= applied_floor {
- tracing::error!(
- shard = self.id,
- op = canonical.op,
- view = consensus.view(),
- commit_max = announced_commit,
- commit_min = consensus.commit_min(),
- local_checksum = local.checksum,
- canonical_checksum = canonical.checksum,
- "committed op {} disagrees with the view that just
started; this replica \
- applied a different op as committed and cannot be
reconciled by log repair",
- canonical.op
- );
- continue;
- }
- repairable_from = Some(repairable_from.map_or(canonical.op, |op|
op.min(canonical.op)));
}
-
- // A suffix ABOVE the announced head is named by nobody, so the loop
cannot
- // see it, and it is exactly the log that AGREES in-window (restart and
- // re-adopt), where `repairable_from` never arms. Adoption drops the
head
- // under it, and the next prepare at `op_head + 1` then collides in
`append`,
- // which refuses the slot even when the ops match. Floored at the
applied
- // point too: an executed op is not rollback-able whatever the head
says.
- // With no parked suffix the adopted sequencer IS the announced head.
+ let pending = consensus.pending_view_log();
let op_head = pending.as_ref().map_or_else(
|| consensus.sequencer().current_sequence(),
|pending| pending.op_head,
);
- let above_head = op_head.max(applied_floor) + 1;
- if journal
- .handle()
- .last_op()
- .is_some_and(|last_op| last_op >= above_head)
+ let journal = journal.handle();
+ let above_head = op_head.max(consensus.commit_min()).saturating_add(1);
+ if consensus.view_log_repair_op().is_none()
+ && journal.last_op().is_some_and(|last| last >= above_head)
{
- repairable_from = Some(repairable_from.map_or(above_head, |op|
op.min(above_head)));
- }
-
- let Some(from_op) = repairable_from else {
- return;
- };
- // SERIALIZATION: the drain guard excludes `truncate_from` against
`drain`,
- // NOT against an append; that is metadata's private `journal_gate`.
It holds
- // by call-site placement, not construction: the pump is
single-threaded, and
- // both callers run with a view change parked, so no submit is
admitted and no
- // repair prepare is in flight for these ops. Routing shard-side
journal
- // mutations through gate-taking metadata methods would make it
structural.
- match journal.handle().truncate_from(from_op).await {
- Ok(removed) => {
- // The snapshot's head and commit point do not move when
entries
- // are removed under them, so without this the next
`DoViewChange`
- // would advertise the dropped headers and offer bodies this
replica
- // cannot serve.
- consensus.note_journal_mutation();
- tracing::warn!(
- shard = self.id,
- from_op,
- removed,
- op_head,
- view = consensus.view(),
- "dropped {removed} uncommitted entries from op {from_op}
that disagreed with \
- the view's log; the primary's retransmission refills the
range"
- );
+ if let Err(error) = journal.truncate_from(above_head).await {
+ tracing::error!(shard = self.id, above_head, %error, "could
not truncate superseded metadata suffix");
+ return;
}
- Err(error) => {
- tracing::error!(
- shard = self.id,
- from_op,
- %error,
- "could not drop the diverging uncommitted entries from op
{from_op}; journal \
- repair skips ops it already holds a header for, so this
replica will not \
- converge at those ops until it is restarted"
- );
+ consensus.note_journal_mutation();
+ }
+ loop {
+ match consensus::view_log_proof(
+ consensus.commit_min(),
+ pending.as_ref().map_or(&[][..], |pending| &pending.headers),
+ op_head,
+ |op| {
+ usize::try_from(op)
+ .ok()
+ .and_then(|slot| journal.header(slot).map(|header|
*header))
+ },
+ ) {
+ consensus::ViewLogProof::Proven => {
+ consensus.settle_view_log_proof();
+ return;
+ }
+ consensus::ViewLogProof::Missing {
+ op,
+ unproven_through,
+ } => {
+ consensus.fence_view_log(op, unproven_through);
+ return;
+ }
+ consensus::ViewLogProof::Divergent(op) => {
+ consensus.fence_view_log(op, op);
+ // The canonical tail may be this replica's only copy.
Remove
+ // the proven mismatch without cutting entries above a gap.
+ if let Err(error) = journal.remove_range(op..=op).await {
Review Comment:
Deferred to PR6 in B5's recovery gates. Batch only a proven contiguous
divergent range into one atomic metadata rewrite or partition continuation,
preserving unknown gaps, canonical tails, commit floors and retry after failed
I/O. Acceptance requires rewrite/fsync counts and
interrupted-publication/restart tests. Dropping unknown entries to reduce the
number of rewrites is not an acceptable shortcut.
##########
core/simulator/src/workload/state_checker.rs:
##########
@@ -265,6 +390,85 @@ impl StateChecker {
}
}
+/// Membership is metadata state; the partition's ordered install barrier is
+/// what fences a departed member's later offset writes.
+fn check_partition_owner(
+ owners: &mut PartitionOwners,
+ header: &PrepareHeader,
+ body: &[u8],
+ seed: u64,
+) {
+ if header.operation == Operation::InstallConsumerGroupOwner {
+ let install = InstallConsumerGroupOwnerRequest::decode_from(body)
+ .expect("a committed owner installation decodes");
+ if !owners
+ .retirement
+ .as_ref()
+ .is_some_and(|catalog| catalog.retires(install.group_id))
+ {
+ owners.installed.insert(install.group_id, install.owner);
+ }
+ return;
+ }
+ if header.operation == Operation::RetireConsumerGroupOwners {
Review Comment:
Deferred to PR6. The consolidated simulator gates, B6 and the plan now
require calling the actual reconcile_consumer_group_offsets producer in a
group-retirement/recreation workload. Acceptance requires nonzero
RetireConsumerGroupOwners delivery and retirement comparisons, vacuity failure
when either is absent, and an end-to-end mutation that the strict retirement
oracle catches. The PR description now calls the existing retirement check unit
coverage only; session revocation and generation-refusal counters are separate
evidence.
--
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]