AsperforMias commented on issue #3615:
URL: https://github.com/apache/dubbo-go/issues/3615#issuecomment-5226228151

   ## Reproduced on v3.3.2 — deterministic docker repro + a second recovery 
blocker (closing tombstone)
   
   I reproduced this issue deterministically on `v3.3.2` (docker compose: Nacos 
2.5.1 + single Triple provider + consumer, application-level discovery, 
**local** metadata storage). The reproduction confirms the root cause described 
above, and surfaced a **second, stacked recovery blocker** that the proposed 
fix should take into account: the graceful-shutdown **closing tombstone** in 
`RegistryDirectory`.
   
   ### Deterministic repro (no timing race)
   
   The trick is to apply the network block **before** restarting the provider 
and hold it until the metadata failure is logged — so the "transient 
MetadataService failure" is guaranteed, not probabilistic:
   
   1. Start Nacos; start the provider (app `demo-provider`, tri `:20001`); 
start the consumer (app `demo-consumer`, calls `SayHello` every 2s and logs 
`CALL #n OK/FAIL`). Wait until calls succeed (baseline).
   2. `docker compose stop provider` (SIGTERM → graceful shutdown). Wait until 
the consumer logs `received instance notification event, service=demo-provider 
size=0` and calls start failing.
   3. In the consumer container: `iptables -A OUTPUT -d <provider-ip> -j DROP`.
   4. `docker compose start provider` — a new `timestamp` guarantees a **new 
revision**.
   5. Wait until the consumer logs `failed to get metadata from instance ... 
skipping this instance` (guaranteed: the metadata RPC to the blocked address 
times out), then sleep 3s.
   6. `iptables -D OUTPUT -d <provider-ip> -j DROP` — from here on, Nacos and 
the provider (incl. MetadataService) are fully healthy.
   7. Observe 60s: **consumer keeps failing `No provider available`**. Then 
`docker compose restart consumer` → calls succeed immediately.
   
   Full runnable package (provider/consumer/compose/script) at the bottom of 
this comment.
   
   ### Observed timeline (consumer log, v3.3.2)
   
   ```text
   11:38:33  INFO  received instance notification event, service=demo-provider 
size=1   # baseline, metadata RPC ok
   11:38:36  INFO  received instance notification event, service=demo-provider 
size=0   # provider stopped
             # iptables DROP consumer -> provider
   11:38:40  INFO  received instance notification event, service=demo-provider 
size=1   # provider restarted, new revision
   11:38:46  WARN  failed to get metadata from instance 172.30.0.11 (revision 
fc54e9e8...),
                   err=...: can not connect to remote metadata service host: 
172.30.0.11, skipping this instance
             # iptables unblock — provider/nacos fully healthy
   11:38:51  INFO  [Dubbo] refer service, 
url=dubbo://172.30.0.11:43537/org.apache.dubbo.metadata.MetadataService...   # 
a later push DID re-fetch metadata successfully
   11:38:51  INFO  [Registry][Directory] skip rebuilding closing instance due 
to tombstone, instance key: ...          # <-- second blocker
   11:38:52  INFO  [Registry][Directory] skip rebuilding closing instance due 
to tombstone
   11:38:52→11:39:10  CALL #22..#29 FAIL: No provider available ... (provider 
healthy)
             # docker compose restart consumer
   11:39:33  CALL #1..#14 OK                                                    
        # restart recovers
   ```
   
   ### Second recovery blocker: closing tombstone
   
   `docker stop` (SIGTERM) triggers the provider's graceful shutdown, which 
makes the consumer's `RegistryDirectory` mark a **closing tombstone** for the 
instance key (`registry/directory/directory.go` `markClosingTombstone`, default 
TTL 30s via `ClosingInvokerExpireTime`). In this run a later Nacos push arrived 
**after** the network was restored and the metadata re-fetch **succeeded** — 
but `doCacheInvoker` vetoed the invoker rebuild:
   
   
https://github.com/apache/dubbo-go/blob/v3.3.2/registry/directory/directory.go#L683-L687
   
   ```go
   if dir.hasActiveClosingTombstone(key) {
       logger.Infof("[Registry][Directory] skip rebuilding closing instance due 
to tombstone, instance key: %s", key)
       return nil, true
   }
   ```
   
   After the 30s TTL expired, no further instance event arrived, so the 
consumer stayed empty until restart. In other words, the "no retry source" gap 
is what makes it permanent, and the tombstone is what vetoed the one 
spontaneous recovery chance.
   
   ### Control experiment (SIGKILL instead of graceful stop)
   
   Same steps, but `docker kill` (SIGKILL) the provider in step 2 → no closing 
event → **no tombstone**. The blocked first metadata fetch fails identically, 
but the next Nacos push after unblocking re-fetches metadata and rebuilds the 
invoker — **the consumer self-heals ~3s after the network is restored**:
   
   ```text
   11:47:19  WARN  failed to get metadata from instance ... skipping this 
instance   # blocked
   11:47:24  INFO  [Registry][Directory] selector add service 
url{tri://172.30.0.11:20001/...}   # rebuilt, no tombstone
   11:47:25  CALL #237 OK                                                       
    # self-recovered
   ```
   
   So the permanent failure needs both: (a) no metadata retry (this issue's 
root cause), and (b) no *effective* event afterwards — either because Nacos 
never pushes again, or because a closing tombstone vetoes the rebuild.
   
   ### Implication for the fix (section 11.1)
   
   A revision retry queue alone is **not sufficient** for same-address restarts 
(hostNetwork / NodePort / IP-reused pods, docker, single-node dev): retries 
that succeed within the tombstone TTL (default 30s) will still be vetoed by 
`doCacheInvoker`, and if no event arrives after the TTL expires the consumer 
stays down anyway. Suggest either:
   
   - clearing/bypassing the closing tombstone when the registry reports the 
same instance key healthy again with a **new revision** (the provider is 
demonstrably back, not a stale closing event), or
   - having the metadata-retry scheduler drive `RegistryDirectory` rebuilds in 
a way that is not gated by the tombstone (or extending the tombstone semantics 
to expire on new-revision registration).
   
   ### Reproduction package
   
   <details><summary>docker-compose.yml</summary>
   
   ```yaml
   services:
     nacos:
       image: nacos/nacos-server:v2.5.1-slim
       environment:
         MODE: standalone
         NACOS_AUTH_ENABLE: "false"
         JVM_XMS: 256m
         JVM_XMX: 256m
       networks:
         repro-net:
           ipv4_address: 172.30.0.10
       ports:
         - "8848:8848"
         - "9848:9848"
   
     provider:
       build:
         context: .
         target: provider
       environment:
         NACOS_ADDR: nacos:8848
       depends_on:
         - nacos
       networks:
         repro-net:
           ipv4_address: 172.30.0.11
   
     consumer:
       build:
         context: .
         target: consumer
       cap_add:
         - NET_ADMIN
       environment:
         NACOS_ADDR: nacos:8848
       depends_on:
         - nacos
       networks:
         repro-net:
           ipv4_address: 172.30.0.12
   
   networks:
     repro-net:
       driver: bridge
       ipam:
         config:
           - subnet: 172.30.0.0/24
   ```
   
   </details>
   <details><summary>Dockerfile</summary>
   
   ```dockerfile
   FROM golang:1.26-bookworm AS build
   WORKDIR /app
   COPY go.mod go.sum ./
   RUN go mod download
   COPY provider ./provider
   COPY consumer ./consumer
   RUN CGO_ENABLED=0 go build -o /out/provider ./provider \
       && CGO_ENABLED=0 go build -o /out/consumer ./consumer
   
   FROM debian:bookworm-slim AS provider
   COPY --from=build /out/provider /provider
   ENTRYPOINT ["/provider"]
   
   FROM debian:bookworm-slim AS consumer
   RUN apt-get update && apt-get install -y --no-install-recommends iptables \
       && rm -rf /var/lib/apt/lists/*
   COPY --from=build /out/consumer /consumer
   ENTRYPOINT ["/consumer"]
   ```
   
   </details>
   <details><summary>provider/main.go</summary>
   
   ```go
   package main
   
   import (
        "context"
        "os"
        "os/signal"
        "syscall"
   )
   
   import (
        "github.com/dubbogo/gost/log/logger"
   )
   
   import (
        "google.golang.org/protobuf/types/known/emptypb"
        "google.golang.org/protobuf/types/known/wrapperspb"
   )
   
   import (
        "dubbo.apache.org/dubbo-go/v3/common"
        "dubbo.apache.org/dubbo-go/v3/common/constant"
        "dubbo.apache.org/dubbo-go/v3/global"
        "dubbo.apache.org/dubbo-go/v3/protocol"
        "dubbo.apache.org/dubbo-go/v3/registry"
        "dubbo.apache.org/dubbo-go/v3/server"
   
        _ "dubbo.apache.org/dubbo-go/v3/imports"
        tri "dubbo.apache.org/dubbo-go/v3/protocol/triple/triple_protocol"
   )
   
   const interfaceName = "com.example.GreetService"
   
   type GreetService struct{}
   
   func (s *GreetService) Reference() string { return interfaceName }
   
   func (s *GreetService) SayHello(context.Context, *emptypb.Empty) 
(*wrapperspb.StringValue, error) {
        return wrapperspb.String("hello from provider"), nil
   }
   
   func main() {
        nacosAddr := os.Getenv("NACOS_ADDR")
        if nacosAddr == "" {
                nacosAddr = "127.0.0.1:8848"
        }
   
        srv, err := server.NewServer(
                server.WithServerProtocol(
                        protocol.WithTriple(),
                        protocol.WithPort(20001),
                ),
                server.WithServerRegistry(
                        registry.WithNacos(),
                        registry.WithAddress(nacosAddr),
                        registry.WithRegisterService(), // application-level 
service discovery
                ),
                server.SetServerApplication(&global.ApplicationConfig{Name: 
"demo-provider"}),
        )
        if err != nil {
                panic(err)
        }
   
        svc := &GreetService{}
        info := &common.ServiceInfo{
                InterfaceName: interfaceName,
                ServiceType:   svc,
                Methods: []common.MethodInfo{
                        {
                                Name: "SayHello",
                                Type: constant.CallUnary,
                                ReqInitFunc: func() any {
                                        return &emptypb.Empty{}
                                },
                                MethodFunc: func(ctx context.Context, args 
[]any, handler any) (any, error) {
                                        req := args[0].(*emptypb.Empty)
                                        res, callErr := 
handler.(*GreetService).SayHello(ctx, req)
                                        if callErr != nil {
                                                return nil, callErr
                                        }
                                        return tri.NewResponse(res), nil
                                },
                        },
                },
        }
   
        if err := srv.Register(svc, info, server.WithInterface(interfaceName)); 
err != nil {
                panic(err)
        }
   
        logger.Infof("provider starting, nacos=%s port=20001", nacosAddr)
        if err := srv.Serve(); err != nil {
                panic(err)
        }
   
        sig := make(chan os.Signal, 1)
        signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
        <-sig
   }
   ```
   
   </details>
   <details><summary>consumer/main.go</summary>
   
   ```go
   package main
   
   import (
        "context"
        "fmt"
        "os"
        "time"
   )
   
   import (
        "github.com/dubbogo/gost/log/logger"
   )
   
   import (
        "google.golang.org/protobuf/types/known/emptypb"
        "google.golang.org/protobuf/types/known/wrapperspb"
   )
   
   import (
        "dubbo.apache.org/dubbo-go/v3/client"
        "dubbo.apache.org/dubbo-go/v3/global"
        "dubbo.apache.org/dubbo-go/v3/registry"
   
        _ "dubbo.apache.org/dubbo-go/v3/imports"
   )
   
   const interfaceName = "com.example.GreetService"
   
   func main() {
        nacosAddr := os.Getenv("NACOS_ADDR")
        if nacosAddr == "" {
                nacosAddr = "127.0.0.1:8848"
        }
   
        cli, err := client.NewClient(
                client.WithClientRegistry(
                        registry.WithNacos(),
                        registry.WithAddress(nacosAddr),
                        registry.WithRegisterService(), // application-level 
service discovery
                ),
                client.SetClientApplication(&global.ApplicationConfig{Name: 
"demo-consumer"}),
        )
        if err != nil {
                panic(err)
        }
   
        conn, err := cli.DialWithInfo(
                interfaceName,
                &client.ClientInfo{
                        InterfaceName: interfaceName,
                        MethodNames:   []string{"SayHello"},
                },
        )
        if err != nil {
                panic(err)
        }
   
        logger.Infof("consumer dialed %s via nacos=%s, starting call loop", 
interfaceName, nacosAddr)
        for i := 1; ; i++ {
                ctx, cancel := context.WithTimeout(context.Background(), 
3*time.Second)
                resp := &wrapperspb.StringValue{}
                callErr := conn.CallUnary(ctx, []any{&emptypb.Empty{}}, resp, 
"SayHello")
                cancel()
                if callErr != nil {
                        fmt.Printf("CALL #%d FAIL: %v\n", i, callErr)
                } else {
                        fmt.Printf("CALL #%d OK: %s\n", i, resp.GetValue())
                }
                time.Sleep(2 * time.Second)
        }
   }
   ```
   
   </details>
   <details><summary>repro.sh (orchestration: block → restart → unblock → 
observe → restart consumer)</summary>
   
   ```bash
   #!/usr/bin/env bash
   # Reproduce: application-level Nacos discovery stays permanently empty after 
a
   # transient MetadataService fetch failure during provider restart (dubbo-go 
v3.3.2).
   set -u
   cd "$(dirname "$0")"
   
   PROVIDER_IP=172.30.0.11
   COMPOSE="docker compose"
   
   say() { printf '\n\033[1;34m== %s\033[0m\n' "$*"; }
   fail() { printf '\033[1;31mFAIL: %s\033[0m\n' "$*" >&2; exit 1; }
   
   wait_log() { # service, pattern, timeout_seconds
     local deadline=$(( $(date +%s) + $3 ))
     while [ "$(date +%s)" -lt "$deadline" ]; do
       if $COMPOSE logs "$1" 2>&1 | grep -q "$2"; then return 0; fi
       sleep 2
     done
     return 1
   }
   
   wait_url() { # url, pattern, timeout_seconds
     local deadline=$(( $(date +%s) + $3 ))
     while [ "$(date +%s)" -lt "$deadline" ]; do
       if curl -sf "$1" 2>/dev/null | grep -q "$2"; then return 0; fi
       sleep 2
     done
     return 1
   }
   
   say "0. clean slate & build"
   $COMPOSE down -v --remove-orphans >/dev/null 2>&1
   $COMPOSE build || fail "docker build"
   
   say "1. start nacos"
   $COMPOSE up -d nacos || fail "start nacos"
   wait_url "http://127.0.0.1:8848/nacos/v1/console/health/readiness"; 'OK' 180 \
     || fail "nacos not ready"
   echo "nacos ready"
   
   say "2. start provider, wait for registration"
   $COMPOSE up -d provider || fail "start provider"
   wait_url 
"http://127.0.0.1:8848/nacos/v1/ns/instance/list?serviceName=demo-provider"; 
'"healthy":true' 90 \
     || fail "provider not registered in nacos"
   echo "provider registered"
   
   say "3. start consumer, wait for baseline CALL OK"
   $COMPOSE up -d consumer || fail "start consumer"
   wait_log consumer "CALL #[0-9]* OK" 120 || { $COMPOSE logs --tail=50 
consumer; fail "baseline call never succeeded"; }
   echo "baseline OK: consumer can call provider"
   
   say "4. stop provider, wait for consumer to see empty instance list"
   $COMPOSE stop provider >/dev/null
   wait_log consumer "No provider available" 120 || fail "consumer never saw 
provider go down"
   echo "consumer directory is now empty (provider offline) — expected"
   
   say "5. BLOCK consumer -> provider network (simulate transient failure 
window)"
   $COMPOSE exec -T consumer iptables -A OUTPUT -d "$PROVIDER_IP" -j DROP || 
fail "iptables block"
   
   say "6. restart provider (new timestamp => new metadata revision)"
   $COMPOSE start provider >/dev/null
   wait_url 
"http://127.0.0.1:8848/nacos/v1/ns/instance/list?serviceName=demo-provider"; 
'"healthy":true' 90 \
     || fail "provider not re-registered in nacos"
   echo "provider re-registered with new revision"
   
   say "7. wait for consumer's first metadata fetch to fail behind the block"
   wait_log consumer "failed to get metadata" 90 || { $COMPOSE logs --tail=80 
consumer; fail "metadata failure not observed — window missed"; }
   echo "observed: metadata fetch failed, instance skipped"
   sleep 3
   
   say "8. UNBLOCK network (provider and nacos are now fully healthy)"
   $COMPOSE exec -T consumer iptables -D OUTPUT -d "$PROVIDER_IP" -j DROP || 
fail "iptables unblock"
   
   say "9. observe 40s: consumer should KEEP FAILING with 'No provider 
available'"
   sleep 40
   RECENT="$($COMPOSE logs --since=40s consumer 2>&1)"
   echo "$RECENT" | grep "CALL #" | tail -8
   if echo "$RECENT" | grep -q "CALL #[0-9]* OK"; then
     fail "consumer recovered on its own — bug NOT reproduced"
   fi
   echo "$RECENT" | grep -q "No provider available" || fail "unexpected state: 
neither OK nor No provider"
   echo "confirmed: consumer permanently stuck at 'No provider available' 
despite healthy provider"
   
   say "10. restart consumer => expect immediate recovery"
   $COMPOSE restart consumer >/dev/null
   wait_log consumer "CALL #[0-9]* OK" 90 || { $COMPOSE logs --tail=50 
consumer; fail "consumer did not recover after restart"; }
   $COMPOSE logs --since=20s consumer 2>&1 | grep "CALL #" | tail -3
   
   say "BUG REPRODUCED: transient metadata failure => permanent No provider; 
consumer restart recovers"
   ```
   
   </details>
   


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