numinnex commented on code in PR #4506:
URL: https://github.com/apache/iggy/pull/4506#discussion_r4268040971


##########
core/server/src/dispatch/mod.rs:
##########
@@ -256,25 +440,50 @@ async fn drain_client_requests<B, MJ, S, SB>(
     SB: SuperblockStore + 'static,
 {
     loop {
-        let Some(message) = pop_next_client_request(&queues, client_id) else {
+        let metadata_frontier = 
shard.plane.metadata().applied_frontier().get();
+        let next = pop_next_client_request(&queues, client_id, |queued| {
+            if is_ping(&queued.message) {
+                return true;
+            }
+            if drain.metadata_write.get() {
+                return false;
+            }
+            queued
+                .progress_key(metadata_frontier, |message| 
progress_key(&shard, message))
+                .is_none_or(|key| {
+                    drain.in_flight.get() < drain.capacity
+                        && !drain.progress.borrow().contains(&key)

Review Comment:
   nit: lane removal is barely pinned. Ignoring `drain.progress` here, or never 
calling `admit_progress` (:458-461), survives all 102 dispatch tests, and 
pipelined_frames_vsr.rs:182 still passes 3/3; only :156 catches it. No test 
covers Store -> Next(auto_commit=false) or Store -> GetConsumerOffset ordering. 
Add a drain-level unit test where a held keyed store keeps a queued Next poll 
queued until its guard drops.
   Minor, PR6-able: the lane is exclusive, so read-only keyed frames also 
serialize among themselves.



##########
core/server/src/dispatch/mod.rs:
##########
@@ -557,25 +814,51 @@ async fn handle_client_request<B, MJ, S, SB>(
             // (`submit_register_local_or_forward`). Every other non-replicated
             // code MUST go through Register first, which binds the acting user
             // the per-op authz gates resolve.
-            handle_non_replicated_request(
-                shard,
-                sessions,
-                server_config,
-                transport_client_id,
-                request,
-                ConnectionContext {
-                    bound,
-                    user_id,
-                    address: client_address,
-                    metadata_watermark,
-                },
-            )
-            .await;
+            let context = ConnectionContext {
+                bound,
+                user_id,
+                address: client_address,
+                metadata_watermark,
+            };
+            // `ClientDrain` says which frames stay on the drain.
+            if matches!(non_replicated_code(header), PING_CODE | 
BIND_SESSION_CODE) {
+                handle_non_replicated_request(
+                    shard,
+                    sessions,
+                    server_config,
+                    transport_client_id,
+                    request,
+                    context,
+                )
+                .await;
+                return;
+            }
+            let in_flight = match progress {
+                Some(progress) => progress,
+                None => drain.admit(Admission::Cap).await,
+            };
+            let task_shard = Rc::clone(shard);
+            let task_sessions = Rc::clone(sessions);
+            let task_config = Arc::clone(server_config);
+            shard.bus.spawn(async move {

Review Comment:
   **warning** (re the PR6 deferral): the held-poll PING test only refutes 
running the whole poll inline. The proposed design is different: run the read 
future inline on the drain, select it against the frame-arrival wake 
(:178-181), and on wake move the same future, with its `InFlight` and lane 
guard, into `shard.bus.spawn`.
   PING is then answered before the held owner reply by construction. Cap, 
lane, cancellation and unknown-outcome behaviour are unchanged (same future, 
same guard drop). Next/auto-commit order is stricter while inline and identical 
after promotion. Lockstep connections spawn nothing, which is master's cost.
   Master ran reads inline and fails this PING test too, so this is a new 
contract the promotion meets. It's about 40 lines here. As it stands the PR 
ships -17% poll / -48% consumer for every lockstep SDK through the edge builds.



##########
foreign/go/client/tcp/tcp_core.go:
##########
@@ -860,6 +857,9 @@ func (c *IggyTcpClient) attempt(
                if captured, ok := 
ctx.Value(capturedPartitionContext{}).(iggcon.PartitionContext); ok {

Review Comment:
   nit: two `ctx.Value` chain walks per stamp (:857, :860), plus a 
`context.WithValue` that boxes `PartitionContext` once per routed op 
(tcp_poll_routing.go:244, :406). Do one lookup with if/else.



##########
core/simulator/src/bin/workload-fuzz.rs:
##########
@@ -586,13 +608,19 @@ fn run_quiesce_phase(
     };
     println!(
         "quiesced and converged (leader-relative; entity oracle: 
{entity_oracle}; \
-         evictions={}; ops_compared={} partition_ops_compared={} 
replicas_compared={} \
-         namespaces_checked={})",
+         evictions={}; ops_compared={} partition_ops_compared={} \
+         deleted_partition_ops_compared={} replicas_compared={} 
namespaces_checked={} \
+         owners_compared={} offsets_compared_with_shadow={} 
stale_owner_stores={} owner_fence_refusals={})",
         workload.evictions(),
         convergence.ops_compared,
         convergence.partition_ops_compared,
+        workload.deleted_partition_ops_compared(),
         convergence.replicas_compared,
         convergence.namespaces_checked,
+        convergence.owners_compared,
+        convergence.offsets_compared_with_shadow,
+        workload.stale_owner_stores(),
+        workload.owner_fence_refusals(),

Review Comment:
   nit: `stale_owner_stores` / `owner_fence_refusals` are only printed. Unlike 
`min_owners_compared` (:656-659) there is no vacuity floor, and 4/12 lifecycle 
seeds submit zero stale stores. Exit non-zero on lifecycle `--shell` runs with 
zero refusals.



##########
.github/workflows/pre-merge.yml:
##########
@@ -237,11 +237,17 @@ jobs:
         uses: actions/github-script@v9
         with:
           script: |
-            const jobs = await github.rest.actions.listJobsForWorkflowRun({
-              owner: context.repo.owner,
-              repo: context.repo.repo,
-              run_id: context.runId
-            });
+            let jobs;
+            try {
+              jobs = await github.rest.actions.listJobsForWorkflowRun({
+                owner: context.repo.owner,
+                repo: context.repo.repo,
+                run_id: context.runId
+              });
+            } catch (error) {
+              core.warning(`Job timings unavailable: ${error.message}`);

Review Comment:
   nit: this CI fix is unrelated to partition routing and will ride inside the 
`feat(server)!` squash. Better as its own `ci` PR.



##########
foreign/csharp/Iggy_SDK/IggyClient/Implementations/TcpMessageStream.PollRouting.cs:
##########
@@ -201,105 +120,142 @@ private static IggyInvalidStatusCodeException 
TruncatedSend()
         return VsrError.Exception(VsrError.INVALID_COMMAND, "The SendMessages 
body is truncated.");
     }
 
-    private static ContextQuery? TryBuildContextQuery(int code, 
ReadOnlySpan<byte> body)
+    private static SendTarget PartitionTarget(int code, ReadOnlySpan<byte> 
body)
     {
-        if (code is CommandCodes.STORE_CONSUMER_OFFSET_CODE or 
CommandCodes.DELETE_CONSUMER_OFFSET_CODE)
+        if (code == CommandCodes.SEND_MESSAGES_CODE)
         {
-            var suffixSize = code == CommandCodes.STORE_CONSUMER_OFFSET_CODE
-                ? StoreOffsetSuffixSize : DeleteOffsetSuffixSize;
-            if (body.Length <= suffixSize)
-            {
-                return null;
-            }
-            var query = body[..^suffixSize].ToArray();
-            return new 
ContextQuery(CommandCodes.GET_CONSUMER_OFFSET_ROUTING_CODE, query, 
ConsumerSessionSize,
-                Convert.ToBase64String(query));
+            return ParseSendTarget(body);
         }
-        if (code != CommandCodes.POLL_MESSAGES_CODE || body.Length <= 
PollOptionsSize)
+
+        if (body.IsEmpty)
         {
-            return null;
+            throw TruncatedSend();
         }
-        return new ContextQuery(CommandCodes.GET_POLL_ROUTING_CODE, 
body.ToArray(), ConsumerSessionSize,
-            Convert.ToBase64String(body[..^PollOptionsSize]));
+        var group = body[0] == ConsumerGroupWireKind;
+        var position = 1;
+        _ = ReadIdentifier(body, ref position);
+        var identifiersStart = position;
+        var streamId = ReadIdentifier(body, ref position);
+        var topicId = ReadIdentifier(body, ref position);
+        var identifiersLength = position - identifiersStart;
+        if (position + 1 + sizeof(uint) > body.Length)
+        {
+            throw TruncatedSend();
+        }
+        var present = body[position++] != 0;
+        if (!present && (code is CommandCodes.STORE_CONSUMER_OFFSET_CODE or 
CommandCodes.DELETE_CONSUMER_OFFSET_CODE
+            || code == CommandCodes.POLL_MESSAGES_CODE && group))
+        {
+            throw VsrError.Exception(VsrError.INVALID_COMMAND, "This partition 
request requires an explicit partition id.");
+        }
+        var partitionId = present ? 
BinaryPrimitives.ReadUInt32LittleEndian(body[position..]) : 0;
+        var query = new byte[identifiersLength + sizeof(uint)];
+        body.Slice(identifiersStart, identifiersLength).CopyTo(query);
+        
BinaryPrimitives.WriteUInt32LittleEndian(query.AsSpan(identifiersLength), 
partitionId);
+        return new SendTarget(new PartitionRouteKey(streamId, topicId, 
partitionId), query);
+    }
+
+    private static byte[] RouteQuery(PartitionRouteKey key)
+    {
+        var query = new byte[IdentifierPrefixSize * 2 + key.StreamId.Length + 
key.TopicId.Length + sizeof(uint)];
+        query.AsSpan().WriteBytesFromStreamAndTopicIdentifiers(key.StreamId, 
key.TopicId);
+        BinaryPrimitives.WriteUInt32LittleEndian(query.AsSpan(query.Length - 
sizeof(uint)), key.PartitionId);
+        return query;
     }
 
-    /// <summary>
-    ///     Sends a poll or an offset write to the partition primary, over a 
data connection that leaves the
-    ///     membership connection where it is. A caller context, or else the 
first context a route reports, is
-    ///     retained across every retry, so a retry never moves the request 
onto a newer incarnation or owner.
-    /// </summary>
-    /// <param name="code">The poll or offset write command code.</param>
-    /// <param name="key">The cached route of the request.</param>
-    /// <param name="routing">The body of the routing request.</param>
-    /// <param name="payload">The body of the request.</param>
-    /// <param name="context">The caller context, or null to take the context 
the route reports.</param>
-    /// <param name="token">The cancellation token.</param>
-    private async Task<IMemoryOwner<byte>> SendRoutedAsync(int code, 
PollRouteKey key, ReadOnlyMemory<byte> routing,
+    private async Task<IMemoryOwner<byte>> SendRoutedAsync(int code, 
SendTarget target,
         ReadOnlyMemory<byte> payload, PartitionContext? context, 
CancellationToken token)
     {
         using var cancellation = 
CancellationTokenSource.CreateLinkedTokenSource(token);
         cancellation.CancelAfter(VsrRequestTimeoutMs);
         var deadline = Environment.TickCount64 + VsrRequestTimeoutMs;
+        // A new route can change the destination, but not the request's 
identity or captured position.
         var captured = context;
+        SessionFrame? frame = null;
+        ulong? generation = null;
+        var refreshHistory = code == CommandCodes.SEND_MESSAGES_CODE && 
context is null;
+        var refusedPrimary = false;
+        var lifecycleInterval = LifecycleRetryIntervalMs;
         try
         {
             while (true)
             {
                 try
                 {
-                    if (Volatile.Read(ref _clusterNodeCount) == 0)
+                    var route = await GetPartitionRouteAsync(target.Key, 
target.Query, deadline, cancellation.Token);
+                    captured ??= route.Route.Context;
+                    if (generation is not null && generation != 
route.Generation)
                     {
-                        using var metadata = await 
SendPollControlAsync(CommandCodes.GET_CLUSTER_METADATA_CODE,
-                            ReadOnlyMemory<byte>.Empty, deadline, 
cancellation.Token);
-                        if (metadata.Memory.Length == 0)
-                        {
-                            throw new MalformedResponseException("Poll routing 
requires a nonempty cluster roster.");
-                        }
-
-                        
RememberRoster(BinaryMapper.MapClusterMetadata(metadata.Memory.Span));
+                        throw PollNotAccepted();
                     }
-
+                    generation ??= route.Generation;
+                    frame ??= 
_consensusSession.Resolve(VsrOperations.ForCode(code));
                     if (Volatile.Read(ref _clusterNodeCount) <= 1)
                     {
-                        captured ??= await CapturePartitionContextAsync(code, 
payload, cancellation.Token);
-                        return await SendWithResponseAsync(code, payload, 
token: cancellation.Token,
-                            capturedContext: captured);
+                        var attempt = await SendVsrAttemptAsync(code, payload, 
Environment.TickCount64, deadline,
+                            false, cancellation.Token, captured.Value, frame,
+                            retryTransient: VsrOperations.ForCode(code) != 
VsrOperation.NonReplicated);
+                        if (attempt.Error is not null)
+                        {
+                            if (attempt.RequestStarted && 
!VsrOperations.IsReplaySafeRead(code, false, payload.Span)
+                                && !IsDefinitiveVerdict(attempt.Error))
+                            {
+                                throw new 
VsrRequestOutcomeUnknownException(attempt.Error);
+                            }
+                            ExceptionDispatchInfo.Throw(attempt.Error);
+                        }
+                        return attempt.Response!;
                     }
-                    var route = await GetPollRouteAsync(key, routing, 
deadline, cancellation.Token);
-                    captured ??= route.Context;
-                    return await SendPrimaryOnceAsync(key,
-                        code == CommandCodes.POLL_MESSAGES_CODE ? 
CommandCodes.POLL_MESSAGES_ON_PRIMARY_CODE : code,
-                        payload, route, captured.Value, deadline, 
cancellation.Token);
+                    return await SendPrimaryOnceAsync(target.Key, code, 
payload, route, captured.Value,
+                        frame.Value, deadline, cancellation.Token);
                 }
-                catch (IggyInvalidStatusCodeException error) when 
(error.StatusCode == VsrError.TRANSIENT_NOT_ACCEPTED)
+                catch (IggyInvalidStatusCodeException error) when 
(error.StatusCode is
+                    VsrError.TRANSIENT_NOT_ACCEPTED or VsrError.NOT_PRIMARY)
                 {
-                    RemovePollRoute(key);
-                    if (Environment.TickCount64 + PollRoutingRetryIntervalMs 
>= deadline)
+                    RemovePartitionRoute(target.Key);
+                    if ((generation is not null && generation != 
_consensusSession.Generation)

Review Comment:
   nit: the new test only fails if both :189 and :216 are reverted. Reverting 
:216 alone still passes 117/117. Add a case that reaches :189 
(LifecycleBusy/HistoryUnavailable, then relogin) so each guard is pinned.



##########
core/server/src/dispatch/partition.rs:
##########
@@ -377,6 +381,74 @@ async fn relay_partition_reply<B, MJ, S, SB>(
     });
 }
 
+/// The offset key shared by stored-offset reads and progress writes on a 
connection.
+/// Failed resolution is left to the normal handler's authorization and error 
path.
+pub(super) fn progress_key<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
+    message: &Message<GenericHeader>,
+) -> Option<ConsumerProgress>
+where
+    B: ShellBus,
+    MJ: JournalHandle + 'static,
+    MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = 
PrepareHeader>,
+    S: 'static,
+    SB: SuperblockStore + 'static,
+{
+    let request = message.try_as_typed::<RequestHeader>().ok()?;
+    let wire = match request.header().operation {
+        Operation::NonReplicated
+            if request.header().reserved[..4] == 
GET_CONSUMER_OFFSET_CODE.to_le_bytes() =>
+        {
+            GetConsumerOffsetRequest::decode_from(request.body()).ok()?
+        }
+        Operation::NonReplicated
+            if request.header().reserved[..4] == 
POLL_MESSAGES_CODE.to_le_bytes()
+                || request.header().reserved[..4]
+                    == POLL_MESSAGES_ON_PRIMARY_CODE.to_le_bytes() =>
+        {
+            let poll = PollMessagesRequest::decode_from(request.body()).ok()?;

Review Comment:
   nit: polls that are neither `Next` nor auto-commit fully decode 
`PollMessagesRequest` (WireName String allocs) just to return `None`, then the 
handler decodes again. Return early on the strategy/flag bytes, or carry the 
decoded request into the handler.



##########
foreign/java/java-sdk/src/main/java/org/apache/iggy/cluster/ClusterNode.java:
##########
@@ -19,14 +19,26 @@
 
 package org.apache.iggy.cluster;
 
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonSetter;
+import com.fasterxml.jackson.annotation.Nulls;
+
 /**
  * A single node in the cluster roster.
  *
+ * @param replicaId the configured replica id, independent of roster order
  * @param name      the node name
  * @param ip        the node IP address or hostname
  * @param endpoints the per-transport ports of the node
  * @param role      the node role
  * @param status    the node status
  */
 public record ClusterNode(
-        String name, String ip, TransportEndpoints endpoints, ClusterNodeRole 
role, ClusterNodeStatus status) {}
+        @JsonProperty(value = "replica_id", required = true) @JsonSetter(nulls 
= Nulls.FAIL)
+        int replicaId,

Review Comment:
   nit (PR description): the leading `replicaId` record component breaks 
constructor calls and record patterns. Together with C#'s `required ReplicaId` 
and the CLI list's extra column, these public breaks, and the Go/Java/Node/C# 
routing migration as a whole, aren't mentioned in the PR body, which becomes 
the squash message and release notes. Please add an SDK paragraph listing the 
breaking API changes.



##########
core/partitions/src/iggy_partition.rs:
##########
@@ -1035,21 +1077,31 @@ where
         if kind != ConsumerKind::ConsumerGroup {
             return owner_generation == 0;
         }
-        self.consumer_group_owner(u64::from(consumer_id))
-            .is_some_and(|owner| {
-                owner.generation == owner_generation
-                    && (!owner.is_unassigned()
-                        || (operation == Operation::DeleteConsumerOffset
-                            && is_auto_commit_client(client_id)))
-                    && (is_auto_commit_client(client_id)
-                        || (owner.client_id == client_id && owner.session == 
session))
-            })
+        let group_id = u64::from(consumer_id);
+        let Some(owner) = self.consumer_group_owner(group_id) else {
+            // Group ids are never reused, so no owner follows a retired group.
+            // A replica can still hold its offset when the primary that 
retired
+            // it had none, and a delete admitted under the fence can commit
+            // after the catalog, so this cleanup passes at any generation.
+            return operation == Operation::DeleteConsumerOffset
+                && is_auto_commit_client(client_id)
+                && self.consumer_group_retired(group_id);
+        };
+        owner.generation == owner_generation

Review Comment:
   nit: the auto-commit generation fence is unpinned. Mutating to 
`is_auto_commit_client(client_id) || owner.generation == owner_generation` 
survives 580 partitions tests, 236 sim tests and 12 lifecycle fuzz seeds, 
because the workload never emits a stale auto-commit (state_checker.rs:458 
never fires end to end).
   Add a partition unit test: auto-commit admitted at generation G, install G+1 
commits before it applies, expect refusal.



-- 
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]

Reply via email to