Skip to content

CAS: object-store connections exhaust the container's ephemeral ports, fencing out the mount leaseΒ #2243

Description

@CarlosFelipeOR

πŸ€– Generated with AI assistance β€” Claude Opus 5 (claude-opus-5[1m]) β€” from the CI logs and artifacts linked below, reviewed before posting.

βœ… I checked the Altinity Stable Builds lifecycle table, and the Altinity Stable Build version I'm using is still supported.

Type of problem

Bug report β€” availability failure under read-heavy concurrency on a CAS default policy.

Describe the situation

With cas_disk as the default storage policy, a read-heavy concurrent phase drives the ClickHouse container to exhaust its local ephemeral ports against the object store. Every CAS request then fails at connect() with errno 99:

Poco::Exception. Code: 1000, e.code() = 99,
Net Exception: Cannot assign requested address: 172.18.0.2:9001

Nothing on the object-store side is down β€” the port pool on the ClickHouse side is empty. Because the mount-lease renewal is a conditional write over the same path, the outage takes down the lease, and the whole disk stops answering reads and writes until a self-remount.

Found in RegressionTestsRelease / CAS (selects) / cas_selects, test /selects/final/force/concurrent/SELECT WHERE parallel inserts, deletes, updates. Both retries of the job failed, at the same phase, from the same cause β€” this is not flaky.

This issue:

  • Turns a client-side resource exhaustion into a full read+write outage of cas_disk (1330 queries refused over ~42 s in retry 2)
  • Fences out the mount lease and forces a self-remount, discarding an in-flight PartWriteTxn
  • Is not caused by the store: RustFS 1.0.0-beta.12 honours HTTP keep-alive (verified β€” 3 requests, 1 connect, no Connection: close), so the connection churn is client-side
  • Is not bounded by any existing limit: disk_connections_store_limit=10000 / disk_connections_hard_limit=200000 against 28 232 ephemeral ports in the container netns

How to reproduce the behavior

Environment

  • Version: 26.6.2.20000.altinityantalya
  • Build type: release, amd (build_amd_release)
  • Package: PR #2159 7b274f0a461d67fff65a56aafb0865ba0e1af4c8
    • https://altinity-build-artifacts.s3.amazonaws.com/PRs/2159/7b274f0a461d67fff65a56aafb0865ba0e1af4c8/build_amd_release/clickhouse
  • Store: RustFS 1.0.0-beta.12, single container (selects/selects_env/rustfs-service.yml)
  • Disk: cas_disk overriding the default policy (selects/cas_mode.py), no filesystem cache in front of it
  • Container netns: ip_local_port_range = 32768 60999 (28 232 ports), tcp_fin_timeout = 60

Steps

python3 -u selects/regression.py --clickhouse https://altinity-build-artifacts.s3.amazonaws.com/PRs/2159/7b274f0a461d67fff65a56aafb0865ba0e1af4c8/build_amd_release/clickhouse --cas --local --clickhouse-version 26.6.2.20000.altinityantalya --log log.log  --only '/selects/final/*'

While it runs, watch the port pressure inside the server container:

docker exec <clickhouse1> grep -c ' 06 ' /proc/net/tcp   # TIME_WAIT

Scope matters. Running only the failing scenario (--only '/selects/final/force/concurrent/SELECT WHERE parallel inserts, deletes, updates/*') passes β€” 280 s, 3837 OK, 0 fail, peak TIME_WAIT 1051 of 28 232. Exhaustion is cumulative: it needs the whole final subtree, a store with accumulated manifests (GC sweep running), and enough parts for FINAL to re-read.


Expected behavior

Concurrent SELECT ... FINAL load should not be able to drain the port pool the mount lease depends on. If the data plane does saturate, the lease should survive and the failure should stay scoped to the affected queries β€” not take the disk offline for reads.


Actual behavior

Same root cause, two blast radii across the two retries of the same job:

Retry 1 β€” lease survived; the write budget did not:

05:33:32 <Warning> CasWriteRetryLater: CAS write could not be committed
  (stageManifest: part-manifest PUT at 'data/cas/manifests/selects-cas-clickhouse1/store/6c6/
   6c641193-...@cas@/0000000000000001-0000000000000a6e/000001.zst' is UNCERTAIN
   (retry budget exhausted) β€” nothing conclusive was named); retrying later

Surfaces to the client as the INSERT failing:

Code: 210. DB::Exception: CAS write could not be committed (...) ; retrying later:
while pushing to view default.MergeTree_table_..._core_mview:
While executing WaitForAsyncInsert. (NETWORK_ERROR)

16 attempts / 90 s operation deadline (CasRequestControl.h) against ~2.5 min of unavailable ports.

Retry 2 β€” the lease was lost:

12:18:01 <Error> CasMountLeaseKeeper: background renewal failed transiently, retrying while the lease is still valid: ... e.code() = 99 ...
12:19:41 <Error> CasMountLeaseKeeper: (same)
12:19:51 <Error> CasMountLeaseKeeper: (same)
12:20:01 <Error> CasMountLeaseKeeper: background renewal failed, the mount-lease stops advancing

The pool goes TransientNotLive and every query on the disk is refused for ~42 s:

Code: 210. DB::Exception: content-addressed disk 'cas_disk' -- mount lease not held;
backing may be temporarily unreachable; ... TRANSIENT unavailability, not damage

β†’ 1330 occurrences in the TestFlows log, including plain SELECTs and the async-insert queue.

Then the self-remount invalidates work already in flight:

12:23:16 <Warning> CasWriteRetryLater: PartWriteTxn (writer_epoch 1) belongs to a superseded
  mount incarnation (live epoch 2) β€” the mount was fenced out and self-remounted; restart the build

Root cause analysis

The trigger is measurable in the server log. MergeTreePrefetchedReadPool activity jumps ~25Γ— in exactly the minute the ports run out β€” cas_disk has no cache, so each of the 4 parallel SELECT ... FINAL variants re-reads every part from the store:

prefetch baseline prefetch peak first errno 99 total errno 99
retry 1 ~1 000/min 22 973/min (05:31) 05:31:24 7 362 (until 05:33:46)
retry 2 ~1 000/min 25 818/min (12:17) 12:17:32 8 722 (until 12:20:06)

All 8 722 target 172.18.0.2:9001 β€” the object store, nothing else.

~430 GETs/s with tcp_fin_timeout=60 puts on the order of 26 000 sockets in TIME_WAIT, against 28 232 available ports. The retry loops then make it worse rather than better: errno 99 is local resource exhaustion, but it is classified as an ordinary retryable network error, so 16 CAS attempts (and the base client's s3_retry_attempts=500) keep opening more sockets. The lease renewal shares that path and has only 3 chances (mount_lease_ttl_ms=30000 / mount_renew_period=10000).


Suggested fix

  1. Bound port usage. disk_connections_store_limit=10000 / disk_connections_hard_limit=200000 are both far above the 28 232 ports a container has. Cap disk connections for CAS disks below the ephemeral range (and/or investigate why the pool churns connections when the store honours keep-alive).
  2. Do not let the data plane fence out the lease. Give the mount-lease renewal reserved capacity, or widen the margin β€” 30 s TTL / 10 s renew is 3 attempts.
  3. Classify EADDRNOTAVAIL as local exhaustion, not as a retryable remote error: back off hard instead of spending 16 attempts opening further sockets.
  4. CI mitigation (separate PR): put a filesystem cache in front of cas_disk in selects/cas_mode.py, and/or raise ip_local_port_range / lower tcp_fin_timeout in the CAS test envs.

Additional context

  • Job: RegressionTestsRelease / CAS (selects) / cas_selects β€” failed on both retries

  • Server logs (cas_selects-artifacts-release_zookeeper, path inside the zip: selects/_instances/clickhouse1/logs/clickhouse-server.log) β€” every excerpt above comes from these:

  • Possibly related: #2233 (replica HTTP dies under a CAS NETWORK_ERROR storm on soak) β€” worth checking whether port exhaustion is the shared mechanism there too

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions