This is an automated email from the ASF dual-hosted git repository.

Aias00 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/shenyu.git


The following commit(s) were added to refs/heads/master by this push:
     new 3ae637febe perf(admin): coalesce burst HTTP cache rebuilds by 
namespace and group (#7843)
3ae637febe is described below

commit 3ae637febe8245e447d6e27471d4d1af70ea3fcc
Author: redyyang <[email protected]>
AuthorDate: Tue Oct 20 12:37:54 2026 +0800

    perf(admin): coalesce burst HTTP cache rebuilds by namespace and group 
(#7843)
    
    * perf(admin): coalesce HTTP cache rebuilds by namespace and group
    
    * perf(admin): parallelize, back off and degrade gracefully in HTTP cache 
rebuild coalescing
    
    Address review feedback on #7843:
    
    - Add shenyu.sync.http.cache-rebuild-threads (default 1, 1-64). Each key
      keeps a single scheduled or running drain, so distinct namespace/group 
keys
      rebuild in parallel while one key never rebuilds concurrently. Document 
the
      single-worker serialization trade-off.
    - Back off failed rebuilds exponentially (1s doubling, capped at 30s, never
      below the window) and reset after a success, instead of retrying every
      second forever. Retries still never give up, so the newest committed
      configuration is eventually published.
    - Return false from request() after close so a commit racing shutdown
      rebuilds synchronously instead of failing its after-commit hook; skip the
      long-poll notification once the notification scheduler has stopped.
    - Explain why notifyChanged() is a no-op when coalescing is enabled.
    
    Refs #7839
---
 docs/benchmarks/http-cache-rebuild-256.csv         |  73 ++++
 docs/benchmarks/http-cache-rebuild-4096.csv        |  73 ++++
 docs/http-cache-rebuild-coalescing.md              | 222 +++++++++++
 .../config/properties/HttpSyncProperties.java      |  82 ++++
 .../listener/AbstractDataChangedListener.java      |  26 +-
 .../listener/http/HttpCacheRebuildCoordinator.java | 173 +++++++++
 .../http/HttpLongPollingDataChangedListener.java   |  83 +++-
 shenyu-admin/src/main/resources/application.yml    |   3 +
 .../config/properties/HttpSyncPropertiesTest.java  |  20 +
 .../listener/http/HttpCacheRebuildBenchmark.java   | 425 +++++++++++++++++++++
 .../http/HttpCacheRebuildCoordinatorTest.java      | 296 ++++++++++++++
 .../admin/listener/http/HttpCacheRebuildTest.java  | 357 +++++++++++++++++
 12 files changed, 1818 insertions(+), 15 deletions(-)

diff --git a/docs/benchmarks/http-cache-rebuild-256.csv 
b/docs/benchmarks/http-cache-rebuild-256.csv
new file mode 100644
index 0000000000..abeae94b56
--- /dev/null
+++ b/docs/benchmarks/http-cache-rebuild-256.csv
@@ -0,0 +1,73 @@
+namespaces,groups,pace_ms,window_ms,sample,rows_per_key,handle_chars,events,queries,serializations,hashes,allocated_bytes,cpu_ms,gc_count,gc_ms,elapsed_ms,edit_p99_ms,fetch_p99_ms,max_publish_lag_ms,final_convergence_ms
+1,1,0,0,0,1000,256,100,100,100,100,218876872,671.875,1,1,128.352,2.396,1.025,3.565,1.093
+1,1,0,10,0,1000,256,100,1,1,1,12077704,93.750,0,0,22.594,0.078,0.727,16.344,16.344
+1,1,0,10,1,1000,256,100,1,1,1,12996336,109.375,0,0,23.958,0.167,0.679,18.078,18.078
+1,1,0,0,1,1000,256,100,100,100,100,216895248,218.750,10,9,117.227,2.397,1.251,3.994,2.042
+1,1,0,0,2,1000,256,100,100,100,100,216641784,125.000,1,1,102.701,1.097,0.768,1.948,0.989
+1,1,0,10,2,1000,256,100,1,1,1,12823128,15.625,0,0,26.813,0.071,0.601,23.166,23.166
+1,1,5,0,0,1000,256,100,100,100,100,239381304,578.125,2,3,504.622,2.046,0.780,4.033,0.987
+1,1,5,10,0,1000,256,100,33,33,33,106512848,250.000,3,2,521.962,0.330,0.853,16.584,1.378
+1,1,5,10,1,1000,256,100,33,33,33,105874160,359.375,4,4,523.376,0.294,0.955,16.555,16.555
+1,1,5,0,1,1000,256,100,100,100,100,238510160,234.375,17,13,501.673,2.873,0.931,2.776,0.955
+1,1,5,0,2,1000,256,100,100,100,100,238484864,1234.375,29,19,497.829,2.489,1.313,2.511,0.967
+1,1,5,10,2,1000,256,100,32,32,32,102562768,109.375,10,7,517.533,0.239,0.747,16.969,12.846
+1,1,25,0,0,1000,256,100,100,100,100,349373552,359.375,1,1,2489.179,1.887,0.912,2.010,1.198
+1,1,25,10,0,1000,256,100,100,100,100,355900336,218.750,1,1,2500.557,0.204,0.946,24.438,17.490
+1,1,25,10,1,1000,256,100,100,100,100,355916552,375.000,2,2,2498.587,0.204,0.903,22.275,17.509
+1,1,25,0,1,1000,256,100,100,100,100,348501552,406.250,1,1,2482.540,1.848,0.839,2.312,1.525
+1,1,25,0,2,1000,256,100,100,100,100,348493088,0.000,1,1,2481.802,1.824,0.947,2.951,1.356
+1,1,25,10,2,1000,256,100,100,100,100,356091848,625.000,2,2,2502.575,0.187,0.844,22.656,17.781
+1,2,0,0,0,1000,256,100,100,100,100,230511552,156.250,1,1,102.723,1.206,0.762,2.279,0.989
+1,2,0,10,0,1000,256,100,2,2,2,9980072,31.250,0,0,15.496,0.031,0.714,14.222,14.222
+1,2,0,10,1,1000,256,100,2,2,2,22508680,0.000,0,0,28.474,0.034,0.735,26.056,26.056
+1,2,0,0,1,1000,256,100,100,100,100,232291232,78.125,2,3,102.641,2.173,1.657,2.399,0.995
+1,2,0,0,2,1000,256,100,100,100,100,230501640,109.375,1,1,98.887,1.123,1.864,2.508,0.964
+1,2,0,10,2,1000,256,100,2,2,2,17137000,0.000,0,0,15.678,0.031,0.733,13.160,13.160
+1,2,5,0,0,1000,256,100,100,100,100,277039424,0.000,1,1,499.142,1.713,0.931,2.280,1.019
+1,2,5,10,0,1000,256,100,49,49,49,178006016,0.000,0,0,513.523,0.214,0.892,17.181,16.323
+1,2,5,10,1,1000,256,100,48,48,48,172429656,15.625,1,1,507.818,0.152,1.676,18.099,11.629
+1,2,5,0,1,1000,256,100,100,100,100,277033024,0.000,1,1,497.660,1.702,0.890,2.259,0.982
+1,2,5,0,2,1000,256,100,100,100,100,278822936,640.625,1,2,507.720,1.874,2.027,3.047,1.341
+1,2,5,10,2,1000,256,100,48,48,48,172231664,125.000,1,1,509.922,0.154,1.205,17.850,12.131
+1,2,25,0,0,1000,256,100,100,100,100,504354424,203.125,2,2,2481.064,2.543,0.961,2.479,1.293
+1,2,25,10,0,1000,256,100,100,100,100,510455760,359.375,2,2,2487.504,0.180,0.978,18.275,17.464
+1,2,25,10,1,1000,256,100,100,100,100,512035768,125.000,1,1,2489.723,0.190,1.031,18.439,17.277
+1,2,25,0,1,1000,256,100,100,100,100,504363448,0.000,1,1,2481.679,2.193,0.950,2.685,1.274
+1,2,25,0,2,1000,256,100,100,100,100,504363448,453.125,1,1,2477.516,1.781,0.841,2.390,1.528
+1,2,25,10,2,1000,256,100,100,100,100,517401472,0.000,1,1,2505.326,0.181,0.810,17.683,16.823
+2,1,0,0,0,1000,256,100,100,100,100,224524608,140.625,1,1,93.229,1.202,0.775,2.148,0.890
+2,1,0,10,0,1000,256,100,2,2,2,18429856,0.000,0,0,18.311,0.020,0.773,16.311,16.311
+2,1,0,10,1,1000,256,100,2,2,2,9798128,31.250,0,0,16.667,0.024,0.811,14.531,14.531
+2,1,0,0,1,1000,256,100,100,100,100,224524920,93.750,2,2,94.845,2.183,1.464,2.206,0.903
+2,1,0,0,2,1000,256,100,100,100,100,224524656,93.750,1,1,95.548,1.331,0.814,2.232,0.949
+2,1,0,10,2,1000,256,100,2,2,2,11495288,0.000,0,0,16.119,0.024,0.580,14.647,14.647
+2,1,5,0,0,1000,256,100,100,100,100,271332472,31.250,2,2,503.334,2.138,1.044,2.176,1.520
+2,1,5,10,0,1000,256,100,48,48,48,169194088,375.000,1,1,522.215,0.111,0.812,16.913,11.710
+2,1,5,10,1,1000,256,100,49,49,49,171297928,62.500,0,0,511.242,0.243,1.083,17.951,12.363
+2,1,5,0,1,1000,256,100,100,100,100,271330840,31.250,2,2,505.416,2.146,1.650,2.728,1.331
+2,1,5,0,2,1000,256,100,100,100,100,271330576,0.000,1,1,505.260,1.624,0.778,2.192,1.202
+2,1,5,10,2,1000,256,100,47,47,47,165359088,0.000,1,1,509.694,0.106,0.817,17.378,11.251
+2,1,25,0,0,1000,256,100,100,100,100,491493512,187.500,2,2,2484.231,2.616,0.904,2.977,1.372
+2,1,25,10,0,1000,256,100,100,100,100,498917104,218.750,1,1,2495.815,0.175,0.974,17.825,17.325
+2,1,25,10,1,1000,256,100,100,100,100,504105536,31.250,1,1,2507.086,0.153,0.818,17.531,16.808
+2,1,25,0,1,1000,256,100,100,100,100,491493344,828.125,1,1,2486.209,1.680,0.834,2.246,1.143
+2,1,25,0,2,1000,256,100,100,100,100,491493248,0.000,1,1,2490.748,1.944,0.953,2.508,1.471
+2,1,25,10,2,1000,256,100,100,100,100,497473920,15.625,2,2,2492.128,0.182,0.944,18.293,16.888
+2,2,0,0,0,1000,256,100,100,100,100,243109856,125.000,2,2,100.004,2.283,1.018,2.299,2.259
+2,2,0,10,0,1000,256,100,4,4,4,32234472,93.750,0,0,17.775,0.025,0.761,15.517,15.517
+2,2,0,10,1,1000,256,100,4,4,4,28695536,31.250,0,0,16.283,0.019,0.750,14.774,14.774
+2,2,0,0,1,1000,256,100,100,100,100,243093344,93.750,1,1,97.313,1.161,0.781,2.265,1.000
+2,2,0,0,2,1000,256,100,100,100,100,243109568,93.750,1,1,98.634,1.145,0.784,2.387,0.977
+2,2,0,10,2,1000,256,100,4,4,4,32235024,15.625,1,2,18.859,0.023,0.748,17.576,17.576
+2,2,5,0,0,1000,256,100,100,100,100,336183320,15.625,1,1,501.563,1.710,0.977,2.922,1.304
+2,2,5,10,0,1000,256,100,73,73,73,293938768,15.625,1,1,513.968,0.149,0.805,19.462,17.468
+2,2,5,10,1,1000,256,100,73,73,73,295391432,171.875,1,2,523.952,0.150,0.766,18.673,18.476
+2,2,5,0,1,1000,256,100,100,100,100,336196120,125.000,1,1,498.489,1.946,0.798,2.338,1.075
+2,2,5,0,2,1000,256,100,100,100,100,336196120,921.875,1,1,497.201,1.723,0.841,2.208,1.275
+2,2,5,10,2,1000,256,100,72,72,72,290604072,328.125,1,1,522.695,0.164,0.899,19.115,16.586
+2,2,25,0,0,1000,256,100,100,100,100,793725520,0.000,2,2,2480.874,1.928,0.796,2.743,1.369
+2,2,25,10,0,1000,256,100,100,100,100,801252368,218.750,2,2,2488.670,0.152,0.824,18.599,16.791
+2,2,25,10,1,1000,256,100,100,100,100,812807960,0.000,2,2,2504.339,0.175,0.819,17.424,17.411
+2,2,25,0,1,1000,256,100,100,100,100,790133416,0.000,3,3,2486.083,2.602,0.803,2.838,1.426
+2,2,25,0,2,1000,256,100,100,100,100,793587384,1343.750,3,3,2486.734,2.821,0.816,2.859,1.440
+2,2,25,10,2,1000,256,100,100,100,100,812821152,15.625,2,2,2508.207,0.186,0.778,17.887,16.998
diff --git a/docs/benchmarks/http-cache-rebuild-4096.csv 
b/docs/benchmarks/http-cache-rebuild-4096.csv
new file mode 100644
index 0000000000..d90ad76f9d
--- /dev/null
+++ b/docs/benchmarks/http-cache-rebuild-4096.csv
@@ -0,0 +1,73 @@
+namespaces,groups,pace_ms,window_ms,sample,rows_per_key,handle_chars,events,queries,serializations,hashes,allocated_bytes,cpu_ms,gc_count,gc_ms,elapsed_ms,edit_p99_ms,fetch_p99_ms,max_publish_lag_ms,final_convergence_ms
+1,1,0,0,0,1000,4096,100,100,100,100,2501958776,1859.375,16,15,804.569,9.551,8.286,11.239,7.494
+1,1,0,10,0,1000,4096,100,1,1,1,97111200,156.250,1,1,34.881,0.233,5.426,27.260,27.260
+1,1,0,10,1,1000,4096,100,1,1,1,79683312,156.250,0,0,25.869,0.078,4.879,20.326,20.326
+1,1,0,0,1,1000,4096,100,100,100,100,2508539240,1562.500,33,27,820.990,10.426,8.011,10.220,8.665
+1,1,0,0,2,1000,4096,100,100,100,100,2493433520,828.125,36,29,817.579,9.858,7.506,9.760,8.602
+1,1,0,10,2,1000,4096,100,1,1,1,69945856,31.250,0,0,21.447,0.038,5.187,18.212,18.212
+1,1,5,0,0,1000,4096,100,100,100,100,2508508688,1140.625,44,37,813.621,9.499,7.290,9.473,8.763
+1,1,5,10,0,1000,4096,100,17,17,17,835690472,281.250,8,7,524.470,0.433,6.472,25.018,25.018
+1,1,5,10,1,1000,4096,100,17,17,17,831492600,562.500,8,7,523.678,0.370,7.080,25.013,24.788
+1,1,5,0,1,1000,4096,100,100,100,100,2523590104,1156.250,52,42,832.339,9.711,8.365,9.643,7.721
+1,1,5,0,2,1000,4096,100,100,100,100,2538596496,1765.625,75,58,852.424,9.685,8.426,10.337,8.301
+1,1,5,10,2,1000,4096,100,17,17,17,835778624,1046.875,16,14,521.356,0.189,6.594,24.917,24.670
+1,1,25,0,0,1000,4096,100,100,100,100,4123111864,1156.250,159,121,2488.383,10.788,7.129,10.674,9.495
+1,1,25,10,0,1000,4096,100,81,81,81,3859576840,2921.875,242,162,2515.550,0.196,7.982,26.873,26.345
+1,1,25,10,1,1000,4096,100,81,81,81,3842741520,2921.875,115,73,2504.771,0.214,8.183,34.183,18.425
+1,1,25,0,1,1000,4096,100,100,100,100,4123083656,2484.375,13,13,2484.484,9.102,6.539,9.174,7.407
+1,1,25,0,2,1000,4096,100,100,100,100,4123072624,3312.500,15,13,2483.706,8.908,6.373,8.944,8.648
+1,1,25,10,2,1000,4096,100,81,81,81,3858292920,1234.375,11,10,2510.501,0.232,6.345,38.547,23.052
+1,2,0,0,0,1000,4096,100,100,100,100,3074231024,750.000,15,13,707.330,8.394,6.378,8.420,6.968
+1,2,0,10,0,1000,4096,100,2,2,2,120066512,31.250,1,1,28.667,0.033,5.271,26.457,26.457
+1,2,0,10,1,1000,4096,100,2,2,2,114664296,31.250,0,0,26.410,0.029,4.873,23.282,23.282
+1,2,0,0,1,1000,4096,100,100,100,100,3074226000,1390.625,17,16,712.181,8.748,6.604,8.742,6.778
+1,2,0,0,2,1000,4096,100,100,100,100,3097407272,1578.125,21,20,722.293,9.000,6.819,8.932,8.439
+1,2,0,10,2,1000,4096,100,2,2,2,119963872,31.250,1,1,28.985,0.032,5.461,25.089,25.089
+1,2,5,0,0,1000,4096,100,100,100,100,3177112856,843.750,40,34,748.670,8.681,6.717,9.083,8.602
+1,2,5,10,0,1000,4096,100,35,35,35,1260876816,671.875,17,17,526.902,0.226,9.456,25.399,18.686
+1,2,5,10,1,1000,4096,100,37,37,37,1303940880,703.125,25,25,526.750,0.239,9.169,25.152,19.755
+1,2,5,0,1,1000,4096,100,100,100,100,3340954720,1968.750,112,88,832.952,9.950,7.511,9.989,8.843
+1,2,5,0,2,1000,4096,100,100,100,100,2575986216,1812.500,53,45,881.170,16.536,21.235,25.022,8.696
+1,2,5,10,2,1000,4096,100,34,34,34,1142108120,593.750,6,6,519.258,0.212,25.105,32.335,20.101
+1,2,25,0,0,1000,4096,100,100,100,100,5432644640,5062.500,61,52,2491.964,17.599,19.831,17.588,16.821
+1,2,25,10,0,1000,4096,100,100,100,100,4847604352,6671.875,80,75,2517.146,0.236,20.472,33.401,33.166
+1,2,25,10,1,1000,4096,100,100,100,100,4708704824,6343.750,80,73,2504.799,0.243,20.806,34.338,33.016
+1,2,25,0,1,1000,4096,100,100,100,100,4597079952,5468.750,88,78,2505.398,17.781,20.706,19.557,17.008
+1,2,25,0,2,1000,4096,100,100,100,100,4628366056,8843.750,90,80,2502.475,17.983,21.155,26.029,16.919
+1,2,25,10,2,1000,4096,100,100,100,100,4587705184,7359.375,91,79,2515.831,0.318,21.052,33.251,32.522
+2,1,0,0,0,1000,4096,100,100,100,100,2686624608,1578.125,13,12,836.439,17.411,22.879,22.412,8.360
+2,1,0,10,0,1000,4096,100,2,2,2,113740208,62.500,2,2,43.351,0.030,7.636,41.079,41.079
+2,1,0,10,1,1000,4096,100,2,2,2,124828568,171.875,1,1,44.137,0.036,7.398,41.047,41.047
+2,1,0,0,1,1000,4096,100,100,100,100,3082208752,906.250,17,13,760.568,21.277,21.250,26.487,6.979
+2,1,0,0,2,1000,4096,100,100,100,100,2992198088,2453.125,36,28,829.919,21.404,21.551,21.489,9.162
+2,1,0,10,2,1000,4096,100,2,2,2,102555848,234.375,3,3,41.728,0.025,16.407,40.669,40.669
+2,1,5,0,0,1000,4096,100,100,100,100,3057772784,4031.250,79,59,1205.560,23.879,22.103,25.005,15.538
+2,1,5,10,0,1000,4096,100,34,34,34,1546954632,484.375,9,7,547.442,0.236,18.132,38.734,38.734
+2,1,5,10,1,1000,4096,100,34,34,34,1608904496,406.250,7,6,537.946,0.205,17.383,38.606,38.606
+2,1,5,0,1,1000,4096,100,100,100,100,3073613112,1734.375,17,16,761.704,18.222,19.770,21.009,9.139
+2,1,5,0,2,1000,4096,100,100,100,100,3066909520,1500.000,15,14,735.889,19.492,18.552,24.198,6.950
+2,1,5,10,2,1000,4096,100,34,34,34,1577078648,765.625,8,8,537.649,0.188,6.456,32.873,29.730
+2,1,25,0,0,1000,4096,100,100,100,100,5541799728,4421.875,56,46,2487.316,19.552,17.826,21.165,16.486
+2,1,25,10,0,1000,4096,100,100,100,100,5429957568,3703.125,59,47,2508.953,0.301,18.020,33.242,31.384
+2,1,25,10,1,1000,4096,100,100,100,100,6420931288,3734.375,31,25,2508.530,0.212,15.769,33.107,23.444
+2,1,25,0,1,1000,4096,100,100,100,100,5981170728,2937.500,35,28,2499.376,17.301,17.769,17.194,16.722
+2,1,25,0,2,1000,4096,100,100,100,100,6531360496,4390.625,33,30,2481.787,9.291,6.588,9.295,7.726
+2,1,25,10,2,1000,4096,100,100,100,100,6558445536,1562.500,33,31,2496.811,0.235,6.444,25.469,24.479
+2,2,0,0,0,1000,4096,100,100,100,100,3104555640,1250.000,14,13,777.860,21.895,32.623,23.241,6.996
+2,2,0,10,0,1000,4096,100,4,4,4,234955144,78.125,2,1,55.898,0.025,5.867,53.309,53.309
+2,2,0,10,1,1000,4096,100,4,4,4,191588176,187.500,1,1,51.097,0.024,14.073,48.245,48.245
+2,2,0,0,1,1000,4096,100,100,100,100,3142363880,1718.750,14,12,773.177,16.131,20.018,18.318,7.266
+2,2,0,0,2,1000,4096,100,100,100,100,3104555376,1234.375,16,14,736.585,13.983,20.821,14.397,7.336
+2,2,0,10,2,1000,4096,100,4,4,4,208775160,78.125,2,2,48.395,0.023,5.658,46.600,46.600
+2,2,5,0,0,1000,4096,100,100,100,100,3128486744,1171.875,16,13,729.063,11.681,7.588,13.905,13.905
+2,2,5,10,0,1000,4096,100,60,60,60,2016881504,875.000,10,10,530.052,0.209,21.906,36.417,34.186
+2,2,5,10,1,1000,4096,100,64,64,64,2107876856,1031.250,10,8,527.499,0.279,16.616,32.864,28.490
+2,2,5,0,1,1000,4096,100,100,100,100,3117810240,1218.750,14,12,731.658,16.384,17.435,17.143,7.805
+2,2,5,0,2,1000,4096,100,100,100,100,3267494888,1296.875,69,55,786.463,9.523,7.019,9.988,8.101
+2,2,5,10,2,1000,4096,100,66,66,66,2174786200,1140.625,10,10,527.627,0.186,17.584,34.499,25.696
+2,2,25,0,0,1000,4096,100,100,100,100,6457537944,2890.625,30,25,2487.921,17.217,15.127,17.277,8.953
+2,2,25,10,0,1000,4096,100,100,100,100,6494157912,2968.750,33,27,2512.869,0.293,16.400,32.895,23.669
+2,2,25,10,1,1000,4096,100,100,100,100,6472350952,2531.250,33,26,2497.611,0.236,15.364,33.274,23.510
+2,2,25,0,1,1000,4096,100,100,100,100,6517576816,1921.875,35,27,2495.031,17.628,15.166,17.507,7.779
+2,2,25,0,2,1000,4096,100,100,100,100,5555343384,5796.875,71,59,2494.199,17.943,26.007,23.223,17.283
+2,2,25,10,2,1000,4096,100,100,100,100,5796829944,8937.500,61,47,2508.890,0.331,25.705,33.509,32.356
diff --git a/docs/http-cache-rebuild-coalescing.md 
b/docs/http-cache-rebuild-coalescing.md
new file mode 100644
index 0000000000..f4e0aaf8a8
--- /dev/null
+++ b/docs/http-cache-rebuild-coalescing.md
@@ -0,0 +1,222 @@
+# HTTP cache rebuild coalescing
+
+This implements the producer-side experiment in 
[#7839](https://github.com/apache/shenyu/issues/7839).
+It applies to independent committed configuration events handled by the HTTP 
cache listener.
+Selector import batching and the event dispatcher are unchanged.
+
+## Configuration and compatibility
+
+```yaml
+shenyu:
+  sync:
+    http:
+      enabled: true
+      cache-rebuild-window: 10ms
+      cache-rebuild-max-pending-keys: 1024
+      cache-rebuild-threads: 1
+```
+
+The default window is **0ms**, preserving synchronous event processing. Enable 
a positive
+window only when repeated updates to the same namespace/group justify the 
latency cost.
+Accepted windows are zero or 1ms–1s; the pending-key capacity must be 
positive; the
+worker thread count must be between 1 and 64 (default 1).
+Configuration takes effect when the listener is constructed; restart after 
changing it.
+Return the window to zero and restart to restore the original event path.
+
+The existing seven event-driven groups use the hook: app auth, plugin, rule, 
selector,
+metadata, proxy selector and discovery upstream. Full startup/periodic 
refreshes,
+including the existing AI proxy API key cache path, retain their existing 
scope.
+
+## Scheduling and correctness
+
+- Each namespace/group owns at most one queued or running rebuild. Its first 
event sets
+  a fixed deadline. Later events increment a generation without resetting that 
deadline.
+- A rebuild reads the authoritative group from the database, then uses the 
existing Gson,
+  MD5 and version logic. An empty database result publishes an authoritative 
empty group.
+  Identical JSON preserves the previous cache object and version.
+- A generation change during a rebuild schedules another pass. Comparing the 
generation
+  and removing the pending marker share the same monitor as event admission, 
preventing
+  a lost wakeup at the end of a pass.
+- Only snapshot work is combined. Every event still reaches the dispatcher, 
other
+  transport listeners and per-event callbacks. DELETE/UPDATE payloads are 
never merged
+  into a synthetic event. Existing after-commit delivery is retained; rollback 
cannot
+  enqueue a rebuild. Event payloads are not retained in the queue.
+- Successful publication queues the existing long-poll notification. When 
enabled,
+  long-poll registration also rechecks MD5/version on the notification 
scheduler to close
+  the compare/register race.
+- Namespace locks serialize snapshot loads/publications with periodic and 
client-triggered
+  repair, preventing an older concurrent read from overwriting a newer 
publication.
+  No event triggers a global refresh.
+- A separate pool of `cache-rebuild-threads` workers performs rebuilds; the 
existing
+  notification/timer scheduler keeps its existing thread count. Each key has 
at most one
+  scheduled or running drain, so several workers rebuild distinct keys in 
parallel but never
+  the same key concurrently. At capacity, a new key rebuilds synchronously on 
its committing
+  caller. Existing pending keys continue to coalesce. No unbounded per-event 
task list is created.
+- A failed rebuild retains its dirty generation and retries with exponential 
backoff: 1s, 2s,
+  4s, … capped at 30s, never shorter than the window. The count resets after a 
successful
+  rebuild. Retries never give up, because dropping the key would leave 
long-poll readers on
+  a stale snapshot; backoff keeps a permanently failing key to at most one 
error log every 30s.
+  The key yields to other keys between attempts, and the last successfully 
published cache
+  remains readable.
+- Shutdown cancels queued work and interrupts the workers. A commit that races 
shutdown is
+  rebuilt synchronously on its caller instead of failing its after-commit 
hook; the
+  long-poll notification is skipped once the notification scheduler has 
stopped.
+
+### Worker threads trade-off
+
+With the default single worker, coalescing trades burst redundancy for 
serialization:
+without coalescing, distinct namespaces rebuild in parallel on their 
committing threads,
+while with one worker every key queues behind the same thread. Because a 
rebuild also holds
+its namespace lock, one slow namespace delays unrelated groups queued behind 
it. The recorded
+benchmark used one worker and at most four active keys. Deployments where many 
namespaces or
+groups change concurrently should raise `cache-rebuild-threads` (for example 
to the number of
+actively edited namespaces, bounded by database connection capacity). Rebuilds 
of different
+groups in the same namespace still serialize on that namespace's lock, as they 
do today.
+
+For a healthy idle key, the **intentional added delay is at most the 
configured window W**.
+Publication also needs queue wait Q and rebuild time R, followed by 
notification scheduling.
+An event arriving during a rebuild can additionally wait for that in-flight 
read to finish.
+With at most K pending keys and bounded rebuild time Rmax, one queue pass 
costs at most
+approximately K × Rmax; a slow database, GC pause or blocked notification 
scheduler can
+exceed a wall-clock target. This is not a promise of 10ms end-to-end delivery 
under overload.
+The local benchmark below gates final cache publication at **250ms** from the 
last committed
+version of every key. Production rollout needs an explicit end-to-end 
convergence budget.
+
+## Reproduce the tests
+
+Use Java 17 and the Maven wrapper (on Windows, `./mvnw.cmd`).
+
+```sh
+./mvnw -B -ntp -pl shenyu-admin -am test \
+  
-Dtest=HttpCacheRebuildCoordinatorTest,HttpCacheRebuildTest,AbstractDataChangedListenerTest,HttpLongPollingDataChangedListenerTest,HttpSyncPropertiesTest,DataChangedEventDispatcherTest,SelectorServiceTest
 \
+  -Dsurefire.failIfNoSpecifiedTests=false -Djacoco.skip=true \
+  -DskipRemoteResources=true -Dmaven.javadoc.skip=true
+./mvnw -B -ntp -pl shenyu-admin apache-rat:check
+```
+
+Tests cover concurrent independent H2 commits, rollback, event/callback 
preservation,
+DELETE followed by UPDATE, authoritative empty groups, namespace/group 
separation,
+capacity fallback, unchanged MD5/version, disabled mode, notification ordering 
and the
+registration race. Deterministic coordinator tests cover in-flight writes, 
retry without
+another event, capped exponential backoff and its reset, fixed deadlines, key 
reuse, rejected
+scheduling and shutdown (including a commit racing shutdown). Multi-worker 
tests show that
+distinct keys rebuild in parallel while one key never rebuilds concurrently.
+The existing selector import tests run unchanged.
+
+PR verification on master `eb0c32ff7` plus this change: **105 tests passed**, 
with no failures or errors; reactor Checkstyle
+passed. The admin RAT check approved all 1037 scanned resources, including the 
new Java files.
+The benchmark below was recorded before worker threads and failure backoff 
became configurable; it ran with
+the default single worker and no failures, so those changes do not alter the 
recorded paths.
+
+## Reproduce the producer benchmark
+
+```sh
+./mvnw -B -ntp -pl shenyu-admin -am test \
+  -Dtest=HttpCacheRebuildBenchmark -Dsurefire.failIfNoSpecifiedTests=false \
+  -Dshenyu.http.benchmark=true -DargLine="-Xms512m -Xmx512m" \
+  -Djacoco.skip=true -DskipRemoteResources=true -Dmaven.javadoc.skip=true
+```
+
+The output is `shenyu-admin/target/http-cache-rebuild-benchmark.csv`. Optional 
properties:
+`shenyu.http.benchmark.rows` (1000), `events` (100), `handleChars` (256),
+`warmup` (5), `samples` (3), and `output` (the CSV path); all use the same
+`shenyu.http.benchmark.` prefix. Set `handleChars=4096` to repeat with larger 
handles.
+
+Each variant uses real H2 commits and queries, the real event dispatcher, the 
HTTP listener,
+and complete DTO serialization/hashing. A small JDBC-backed service fixture 
substitutes for
+MyBatis; SQL counts are actual fixture SELECTs, not estimates of production 
mapper query
+fan-out. RULE and SELECTOR have 1000 rows per key, with synthetic handles and 
no conditions.
+The matrix covers one/two namespaces × one/two groups, with burst, 5ms and 
25ms spacing.
+Every update is a separate transaction, never an import. A serial producer 
follows a fixed
+arrival schedule; if it falls behind, it catches up. Burst mode runs as fast 
as the caller
+can commit, so elapsed time is part of the comparison, not a fixed-rate 
saturation claim.
+
+Each trial warms the write/rebuild and fetch paths, alternates variant order 
across three samples,
+and runs a concurrent in-process `fetchConfig` reader. CSV metrics include 
full SELECT,
+serialization and hash counts, live-JVM-thread allocated bytes, JVM process 
CPU time, GC count/time,
+edit p99, fetch p99, lag for published versions and final convergence. 
Allocation totals
+include the reader and JVM background threads alive at sampling; process CPU 
includes
+all JVM threads (Windows per-thread CPU deltas are too coarse here). These are 
not isolated
+production allocation profiles. The fetch path is measured in-process, without 
HTTP,
+gateway plugins, a network or business traffic. Intermediate overwritten 
versions are
+intentionally not required to be published.
+
+## Recorded local results
+
+Measured on 2026-10-19 with Microsoft OpenJDK 17.0.18+8-LTS, Windows 11
+(10.0.26200), AMD Ryzen 9 8945HX, 32 logical processors, approximately 32 GiB 
RAM,
+and a fixed 512 MiB JVM heap. Base checkout: 
`297ac6f849857bd0c2ad368709013e98494b0330`
+plus this change. Each payload size ran in a separate JVM with three samples 
per scenario,
+five warmup traces of 100 independent commits, and 20 fetch warmups per key.
+
+Both runs used the command above with an explicit output path. The second also 
set
+`-Dshenyu.http.benchmark.handleChars=4096`. All other parameters were 
identical.
+
+Raw results: [256-character handles](benchmarks/http-cache-rebuild-256.csv) and
+[4096-character handles](benchmarks/http-cache-rebuild-4096.csv), 72 measured 
trials each.
+Each table entry is the median of three trials, except the final column, which 
is the
+maximum final convergence across those trials. Pairs show **disabled → 10ms 
window**.
+Every full rebuild below is one actual fixture SELECT, one full serialization 
and one hash.
+Allocation totals include concurrent reads and depend on trace completion time.
+CPU and GC observations are retained in the CSV. Windows CPU counters remain 
coarsely
+quantized (including zero deltas); do not interpret those zero values as zero 
CPU use or
+derive a CPU-speedup claim from this run. No gateway business p99 is 
represented here.
+
+### 256-character handles
+
+| Namespaces × groups | Spacing | Full rebuilds | Allocated MiB | Edit p99 ms 
| Fetch p99 ms | Max final convergence, enabled (ms) |
+| --- | --- | --- | --- | --- | --- | --- |
+| 1 × 1 | burst | 100 → 1 | 206.8 → 12.2 | 2.396 → 0.078 | 1.025 → 0.679 | 
23.166 |
+| 1 × 1 | 5ms | 100 → 33 | 227.5 → 101.0 | 2.489 → 0.294 | 0.931 → 0.853 | 
16.555 |
+| 1 × 1 | 25ms | 100 → 100 | 332.4 → 339.4 | 1.848 → 0.204 | 0.912 → 0.903 | 
17.781 |
+| 1 × 2 | burst | 100 → 2 | 219.8 → 16.3 | 1.206 → 0.031 | 1.657 → 0.733 | 
26.056 |
+| 1 × 2 | 5ms | 100 → 48 | 264.2 → 164.4 | 1.713 → 0.154 | 0.931 → 1.205 | 
16.323 |
+| 1 × 2 | 25ms | 100 → 100 | 481.0 → 488.3 | 2.193 → 0.181 | 0.950 → 0.978 | 
17.464 |
+| 2 × 1 | burst | 100 → 2 | 214.1 → 11.0 | 1.331 → 0.024 | 0.814 → 0.773 | 
16.311 |
+| 2 × 1 | 5ms | 100 → 48 | 258.8 → 161.4 | 2.138 → 0.111 | 1.044 → 0.817 | 
12.363 |
+| 2 × 1 | 25ms | 100 → 100 | 468.7 → 475.8 | 1.944 → 0.175 | 0.904 → 0.944 | 
17.325 |
+| 2 × 2 | burst | 100 → 4 | 231.8 → 30.7 | 1.161 → 0.023 | 0.784 → 0.750 | 
17.576 |
+| 2 × 2 | 5ms | 100 → 73 | 320.6 → 280.3 | 1.723 → 0.150 | 0.841 → 0.805 | 
18.476 |
+| 2 × 2 | 25ms | 100 → 100 | 756.8 → 775.2 | 2.602 → 0.175 | 0.803 → 0.819 | 
17.411 |
+
+### 4096-character handles
+
+| Namespaces × groups | Spacing | Full rebuilds | Allocated MiB | Edit p99 ms 
| Fetch p99 ms | Max final convergence, enabled (ms) |
+| --- | --- | --- | --- | --- | --- | --- |
+| 1 × 1 | burst | 100 → 1 | 2386.1 → 76.0 | 9.858 → 0.078 | 8.011 → 5.187 | 
27.260 |
+| 1 × 1 | 5ms | 100 → 17 | 2406.7 → 797.0 | 9.685 → 0.370 | 8.365 → 6.594 | 
25.018 |
+| 1 × 1 | 25ms | 100 → 81 | 3932.1 → 3679.6 | 9.102 → 0.214 | 6.539 → 7.982 | 
26.345 |
+| 1 × 2 | burst | 100 → 2 | 2931.8 → 114.4 | 8.748 → 0.032 | 6.604 → 5.271 | 
26.457 |
+| 1 × 2 | 5ms | 100 → 35 | 3029.9 → 1202.5 | 9.950 → 0.226 | 7.511 → 9.456 | 
20.101 |
+| 1 × 2 | 25ms | 100 → 100 | 4414.0 → 4490.6 | 17.781 → 0.243 | 20.706 → 
20.806 | 33.166 |
+| 2 × 1 | burst | 100 → 2 | 2853.6 → 108.5 | 21.277 → 0.030 | 21.551 → 7.636 | 
41.079 |
+| 2 × 1 | 5ms | 100 → 34 | 2924.8 → 1504.0 | 19.492 → 0.205 | 19.770 → 17.383 
| 38.734 |
+| 2 × 1 | 25ms | 100 → 100 | 5704.1 → 6123.5 | 17.301 → 0.235 | 17.769 → 
15.769 | 31.384 |
+| 2 × 2 | burst | 100 → 4 | 2960.7 → 199.1 | 16.131 → 0.024 | 20.821 → 5.867 | 
53.309 |
+| 2 × 2 | 5ms | 100 → 64 | 2983.6 → 2010.2 | 11.681 → 0.209 | 7.588 → 17.584 | 
34.186 |
+| 2 × 2 | 25ms | 100 → 100 | 6158.4 → 6172.5 | 17.628 → 0.293 | 15.166 → 
16.400 | 32.356 |
+
+Across all measured burst scenarios, 100 committed events required **1–4 full 
rebuilds**,
+instead of 100. The largest enabled final convergence was **53.309ms**, below 
the 250ms
+local publication budget. With 256-character handles, 25ms-spaced updates 
performed
+100 rebuilds in both modes and slightly increased allocation when enabled: use 
zero for
+that workload. With larger handles, some 25ms traces still overlapped a 
rebuild and merged;
+this does not establish a universal benefit for sparse traffic.
+
+These measurements support an opt-in window for repeated hot-key updates. They 
do not
+support changing the default or promising lower deployed business-request p99.
+
+## Deployment validation boundary
+
+These producer results do **not** establish deployed gateway business-request 
p99 or
+network convergence. The local Docker engine was unavailable during this run, 
so the
+Docker HTTP integration suite and an admin + gateway + backend load run were 
not executed.
+The default therefore remains disabled.
+
+Before enabling in a deployment, repeat baseline/window runs with identical 
database,
+admin, gateway and backend resources, warm all request paths, repeat the same 
committed
+update trace, and measure database statements, CPU/allocation/GC, gateway 
convergence,
+business-request p99 and errors. Require a query/allocation benefit larger 
than sample
+variation while meeting the chosen convergence budget; leave the window at 
zero for
+sparse updates. See the repository's [integration test 
setup](../shenyu-integrated-test/README.md)
+and [benchmark contract](https://github.com/apache/shenyu/issues/7650).
diff --git 
a/shenyu-admin/src/main/java/org/apache/shenyu/admin/config/properties/HttpSyncProperties.java
 
b/shenyu-admin/src/main/java/org/apache/shenyu/admin/config/properties/HttpSyncProperties.java
index 0e751910b6..f8909381ac 100644
--- 
a/shenyu-admin/src/main/java/org/apache/shenyu/admin/config/properties/HttpSyncProperties.java
+++ 
b/shenyu-admin/src/main/java/org/apache/shenyu/admin/config/properties/HttpSyncProperties.java
@@ -20,6 +20,7 @@ package org.apache.shenyu.admin.config.properties;
 import org.springframework.boot.context.properties.ConfigurationProperties;
 
 import java.time.Duration;
+import java.util.Objects;
 
 /**
  * the http sync strategy properties.
@@ -42,6 +43,87 @@ public class HttpSyncProperties {
      */
     private int notifyBatchSize = 100;
 
+    /**
+     * Fixed cache rebuild coalescing window. Zero preserves synchronous event 
processing.
+     */
+    private Duration cacheRebuildWindow = Duration.ZERO;
+
+    /**
+     * Maximum pending namespace/group keys. New keys rebuild on the caller 
when full.
+     */
+    private int cacheRebuildMaxPendingKeys = 1024;
+
+    /**
+     * Worker threads rebuilding coalesced keys. Distinct namespace/group keys 
rebuild in parallel;
+     * one key never rebuilds concurrently.
+     */
+    private int cacheRebuildThreads = 1;
+
+    /**
+     * Get the fixed coalescing window.
+     *
+     * @return cache rebuild window
+     */
+    public Duration getCacheRebuildWindow() {
+        return cacheRebuildWindow;
+    }
+
+    /**
+     * Set the fixed coalescing window.
+     *
+     * @param cacheRebuildWindow zero to disable, or a window from 1 to 1000 
milliseconds
+     */
+    public void setCacheRebuildWindow(final Duration cacheRebuildWindow) {
+        if (Objects.isNull(cacheRebuildWindow) || 
cacheRebuildWindow.isNegative()
+                || cacheRebuildWindow.compareTo(Duration.ofSeconds(1)) > 0
+                || (!cacheRebuildWindow.isZero() && 
cacheRebuildWindow.toMillis() == 0)) {
+            throw new IllegalArgumentException("cacheRebuildWindow must be 
zero or between 1ms and 1s");
+        }
+        this.cacheRebuildWindow = cacheRebuildWindow;
+    }
+
+    /**
+     * Get the pending key capacity.
+     *
+     * @return maximum pending keys
+     */
+    public int getCacheRebuildMaxPendingKeys() {
+        return cacheRebuildMaxPendingKeys;
+    }
+
+    /**
+     * Set the pending key capacity.
+     *
+     * @param cacheRebuildMaxPendingKeys positive maximum pending keys
+     */
+    public void setCacheRebuildMaxPendingKeys(final int 
cacheRebuildMaxPendingKeys) {
+        if (cacheRebuildMaxPendingKeys <= 0) {
+            throw new IllegalArgumentException("cacheRebuildMaxPendingKeys 
must be positive");
+        }
+        this.cacheRebuildMaxPendingKeys = cacheRebuildMaxPendingKeys;
+    }
+
+    /**
+     * Get the number of cache rebuild worker threads.
+     *
+     * @return cache rebuild worker threads
+     */
+    public int getCacheRebuildThreads() {
+        return cacheRebuildThreads;
+    }
+
+    /**
+     * Set the number of cache rebuild worker threads.
+     *
+     * @param cacheRebuildThreads worker threads from 1 to 64
+     */
+    public void setCacheRebuildThreads(final int cacheRebuildThreads) {
+        if (cacheRebuildThreads <= 0 || cacheRebuildThreads > 64) {
+            throw new IllegalArgumentException("cacheRebuildThreads must be 
between 1 and 64");
+        }
+        this.cacheRebuildThreads = cacheRebuildThreads;
+    }
+
     /**
      * Gets the value of enabled.
      *
diff --git 
a/shenyu-admin/src/main/java/org/apache/shenyu/admin/listener/AbstractDataChangedListener.java
 
b/shenyu-admin/src/main/java/org/apache/shenyu/admin/listener/AbstractDataChangedListener.java
index 5415c22553..9f5f991fef 100644
--- 
a/shenyu-admin/src/main/java/org/apache/shenyu/admin/listener/AbstractDataChangedListener.java
+++ 
b/shenyu-admin/src/main/java/org/apache/shenyu/admin/listener/AbstractDataChangedListener.java
@@ -153,7 +153,7 @@ public abstract class AbstractDataChangedListener 
implements DataChangedListener
             return;
         }
         groupByNamespace(changed, AppAuthData::getNamespaceId, 
ConfigGroupEnum.APP_AUTH).forEach((namespaceId, namespaceChanged) -> {
-            this.updateAppAuthCache(namespaceId);
+            this.refreshCache(ConfigGroupEnum.APP_AUTH, namespaceId, () -> 
this.updateAppAuthCache(namespaceId));
             this.afterAppAuthChanged(namespaceChanged, eventType, namespaceId);
         });
     }
@@ -173,7 +173,7 @@ public abstract class AbstractDataChangedListener 
implements DataChangedListener
             return;
         }
         groupByNamespace(changed, MetaData::getNamespaceId, 
ConfigGroupEnum.META_DATA).forEach((namespaceId, namespaceChanged) -> {
-            this.updateMetaDataCache(namespaceId);
+            this.refreshCache(ConfigGroupEnum.META_DATA, namespaceId, () -> 
this.updateMetaDataCache(namespaceId));
             this.afterMetaDataChanged(namespaceChanged, eventType, 
namespaceId);
         });
     }
@@ -193,7 +193,7 @@ public abstract class AbstractDataChangedListener 
implements DataChangedListener
             return;
         }
         groupByNamespace(changed, PluginData::getNamespaceId, 
ConfigGroupEnum.PLUGIN).forEach((namespaceId, namespaceChanged) -> {
-            this.updatePluginCache(namespaceId);
+            this.refreshCache(ConfigGroupEnum.PLUGIN, namespaceId, () -> 
this.updatePluginCache(namespaceId));
             this.afterPluginChanged(namespaceChanged, eventType, namespaceId);
         });
     }
@@ -223,7 +223,7 @@ public abstract class AbstractDataChangedListener 
implements DataChangedListener
             return;
         }
         groupByNamespace(changed, RuleData::getNamespaceId, 
ConfigGroupEnum.RULE).forEach((namespaceId, namespaceChanged) -> {
-            this.updateRuleCache(namespaceId);
+            this.refreshCache(ConfigGroupEnum.RULE, namespaceId, () -> 
this.updateRuleCache(namespaceId));
             this.afterRuleChanged(namespaceChanged, eventType, namespaceId);
         });
     }
@@ -243,7 +243,7 @@ public abstract class AbstractDataChangedListener 
implements DataChangedListener
             return;
         }
         groupByNamespace(changed, SelectorData::getNamespaceId, 
ConfigGroupEnum.SELECTOR).forEach((namespaceId, namespaceChanged) -> {
-            this.updateSelectorCache(namespaceId);
+            this.refreshCache(ConfigGroupEnum.SELECTOR, namespaceId, () -> 
this.updateSelectorCache(namespaceId));
             this.afterSelectorChanged(namespaceChanged, eventType, 
namespaceId);
         });
     }
@@ -260,7 +260,7 @@ public abstract class AbstractDataChangedListener 
implements DataChangedListener
             return;
         }
         groupByNamespace(changed, ProxySelectorData::getNamespaceId, 
ConfigGroupEnum.PROXY_SELECTOR).forEach((namespaceId, namespaceChanged) -> {
-            this.updateProxySelectorDataCache(namespaceId);
+            this.refreshCache(ConfigGroupEnum.PROXY_SELECTOR, namespaceId, () 
-> this.updateProxySelectorDataCache(namespaceId));
             this.afterProxySelectorChanged(namespaceChanged, eventType, 
namespaceId);
         });
     }
@@ -286,7 +286,7 @@ public abstract class AbstractDataChangedListener 
implements DataChangedListener
             return;
         }
         groupByNamespace(changed, DiscoverySyncData::getNamespaceId, 
ConfigGroupEnum.DISCOVER_UPSTREAM).forEach((namespaceId, namespaceChanged) -> {
-            this.updateDiscoveryUpstreamDataCache(namespaceId);
+            this.refreshCache(ConfigGroupEnum.DISCOVER_UPSTREAM, namespaceId, 
() -> this.updateDiscoveryUpstreamDataCache(namespaceId));
             this.afterDiscoveryUpstreamDataChanged(namespaceChanged, 
eventType, namespaceId);
         });
     }
@@ -330,6 +330,18 @@ public abstract class AbstractDataChangedListener 
implements DataChangedListener
 
     protected abstract void afterInitialize();
 
+    /**
+     * Refresh a group for a committed change. Transport implementations may 
coalesce snapshot work;
+     * event callbacks are still invoked once for every event and namespace.
+     *
+     * @param group configuration group
+     * @param namespaceId namespace id
+     * @param rebuild authoritative cache rebuild, without event payloads
+     */
+    protected void refreshCache(final ConfigGroupEnum group, final String 
namespaceId, final Runnable rebuild) {
+        rebuild.run();
+    }
+
     /**
      * if md5 is not the same as the original, then update local cache.
      *
diff --git 
a/shenyu-admin/src/main/java/org/apache/shenyu/admin/listener/http/HttpCacheRebuildCoordinator.java
 
b/shenyu-admin/src/main/java/org/apache/shenyu/admin/listener/http/HttpCacheRebuildCoordinator.java
new file mode 100644
index 0000000000..648b7ca8d4
--- /dev/null
+++ 
b/shenyu-admin/src/main/java/org/apache/shenyu/admin/listener/http/HttpCacheRebuildCoordinator.java
@@ -0,0 +1,173 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.admin.listener.http;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Bounded snapshot work, with one scheduled or running rebuild per 
namespace/group.
+ * The first event sets the deadline; later events never postpone it.
+ *
+ * <p>Each key has at most one scheduled or running drain, so the executor may 
use several
+ * threads: distinct keys rebuild in parallel while a single key is never 
rebuilt concurrently.
+ * A failing rebuild keeps its key and retries with exponential backoff, 
starting at
+ * {@value #FAILURE_RETRY_MILLIS}ms and capped at {@value 
#MAX_FAILURE_RETRY_MILLIS}ms, until a
+ * successful rebuild covers every arrival; it never gives up, because giving 
up would leave
+ * long-poll readers on a stale snapshot.</p>
+ */
+final class HttpCacheRebuildCoordinator implements AutoCloseable {
+
+    static final long FAILURE_RETRY_MILLIS = 1000;
+
+    static final long MAX_FAILURE_RETRY_MILLIS = 30_000;
+
+    private static final Logger LOG = 
LoggerFactory.getLogger(HttpCacheRebuildCoordinator.class);
+
+    private final Map<String, PendingRebuild> pending = new HashMap<>();
+
+    private final ScheduledExecutorService executor;
+
+    private final long windowMillis;
+
+    private final int maxPendingKeys;
+
+    private boolean closed;
+
+    HttpCacheRebuildCoordinator(final ScheduledExecutorService executor, final 
long windowMillis, final int maxPendingKeys) {
+        this.executor = executor;
+        this.windowMillis = windowMillis;
+        this.maxPendingKeys = maxPendingKeys;
+    }
+
+    /**
+     * Mark a committed key dirty without retaining the event payload.
+     *
+     * @param key namespace/group key
+     * @param rebuild reload, publish and notify action
+     * @return false when the caller must rebuild synchronously (capacity, 
executor rejection or
+     *         a coordinator that is already closed during shutdown)
+     */
+    boolean request(final String key, final Runnable rebuild) {
+        synchronized (pending) {
+            if (closed) {
+                // A commit racing shutdown must not fail its after-commit 
hook.
+                return false;
+            }
+            PendingRebuild existing = pending.get(key);
+            if (Objects.nonNull(existing)) {
+                existing.generation++;
+                return true;
+            }
+            if (pending.size() >= maxPendingKeys) {
+                return false;
+            }
+            PendingRebuild next = new PendingRebuild(rebuild);
+            pending.put(key, next);
+            return schedule(key, next, windowMillis);
+        }
+    }
+
+    private boolean schedule(final String key, final PendingRebuild state, 
final long delayMillis) {
+        try {
+            executor.schedule(() -> drain(key, state), delayMillis, 
TimeUnit.MILLISECONDS);
+            return true;
+        } catch (RejectedExecutionException ex) {
+            pending.remove(key, state);
+            LOG.warn("HTTP cache rebuild scheduling rejected for {}", key, ex);
+            return false;
+        }
+    }
+
+    private void drain(final String key, final PendingRebuild state) {
+        long generation;
+        synchronized (pending) {
+            if (closed) {
+                return;
+            }
+            generation = state.generation;
+        }
+        boolean successful = false;
+        try {
+            state.rebuild.run();
+            successful = true;
+        } catch (Exception ex) {
+            synchronized (pending) {
+                state.failures++;
+            }
+            LOG.error("HTTP cache rebuild failed for {} (attempt {}), will 
retry in {}ms",
+                    key, state.failures, retryDelayMillis(state.failures), ex);
+        } finally {
+            synchronized (pending) {
+                if (!closed) {
+                    if (successful) {
+                        state.failures = 0;
+                    }
+                    if (successful && generation == state.generation) {
+                        pending.remove(key, state);
+                    } else {
+                        // Keep ownership until a successful rebuild covers 
all arrivals. Yield between
+                        // generations so a hot or failing key cannot 
monopolize a worker.
+                        schedule(key, state, successful ? windowMillis : 
retryDelayMillis(state.failures));
+                    }
+                }
+            }
+        }
+    }
+
+    /**
+     * Exponential backoff for consecutive failures of one key, never shorter 
than the window.
+     *
+     * @param failures consecutive failures, at least one
+     * @return the retry delay in milliseconds
+     */
+    long retryDelayMillis(final int failures) {
+        int doublings = Math.min(Math.max(failures, 1) - 1, 30);
+        long backoff = Math.min(MAX_FAILURE_RETRY_MILLIS, FAILURE_RETRY_MILLIS 
<< doublings);
+        return Math.max(windowMillis, backoff);
+    }
+
+    @Override
+    public void close() {
+        synchronized (pending) {
+            closed = true;
+            pending.clear();
+            executor.shutdownNow();
+        }
+    }
+
+    private static final class PendingRebuild {
+
+        private final Runnable rebuild;
+
+        private long generation;
+
+        private int failures;
+
+        private PendingRebuild(final Runnable rebuild) {
+            this.rebuild = rebuild;
+        }
+    }
+}
diff --git 
a/shenyu-admin/src/main/java/org/apache/shenyu/admin/listener/http/HttpLongPollingDataChangedListener.java
 
b/shenyu-admin/src/main/java/org/apache/shenyu/admin/listener/http/HttpLongPollingDataChangedListener.java
index f9fcd5976a..95084fb167 100644
--- 
a/shenyu-admin/src/main/java/org/apache/shenyu/admin/listener/http/HttpLongPollingDataChangedListener.java
+++ 
b/shenyu-admin/src/main/java/org/apache/shenyu/admin/listener/http/HttpLongPollingDataChangedListener.java
@@ -45,6 +45,7 @@ import org.apache.shenyu.common.exception.ShenyuException;
 import org.apache.shenyu.common.utils.GsonUtils;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.DisposableBean;
 import org.springframework.http.MediaType;
 
 import jakarta.servlet.AsyncContext;
@@ -62,6 +63,7 @@ import java.util.concurrent.ArrayBlockingQueue;
 import java.util.concurrent.BlockingQueue;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.Future;
+import java.util.concurrent.RejectedExecutionException;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.ScheduledThreadPoolExecutor;
 import java.util.concurrent.TimeUnit;
@@ -77,7 +79,7 @@ import static 
org.apache.shenyu.common.constant.Constants.SYS_DEFAULT_NAMESPACE_
  * @since 2.0.0
  */
 @SuppressWarnings("all")
-public class HttpLongPollingDataChangedListener extends 
AbstractDataChangedListener {
+public class HttpLongPollingDataChangedListener extends 
AbstractDataChangedListener implements DisposableBean {
 
     private static final Logger LOG = 
LoggerFactory.getLogger(HttpLongPollingDataChangedListener.class);
 
@@ -100,6 +102,8 @@ public class HttpLongPollingDataChangedListener extends 
AbstractDataChangedListe
 
     private final HttpSyncProperties httpSyncProperties;
 
+    private final HttpCacheRebuildCoordinator cacheRebuildCoordinator;
+
     /**
      * Instantiates a new Http long polling data changed listener.
      *
@@ -111,6 +115,60 @@ public class HttpLongPollingDataChangedListener extends 
AbstractDataChangedListe
         this.scheduler = new ScheduledThreadPoolExecutor(1,
                 ShenyuThreadFactory.create("long-polling", true));
         this.httpSyncProperties = httpSyncProperties;
+        this.cacheRebuildCoordinator = 
Objects.nonNull(httpSyncProperties.getCacheRebuildWindow()) && 
!httpSyncProperties.getCacheRebuildWindow().isZero()
+                ? new HttpCacheRebuildCoordinator(new 
ScheduledThreadPoolExecutor(httpSyncProperties.getCacheRebuildThreads(),
+                        ShenyuThreadFactory.create("http-cache-rebuild", 
true)),
+                        httpSyncProperties.getCacheRebuildWindow().toMillis(), 
httpSyncProperties.getCacheRebuildMaxPendingKeys()) : null;
+    }
+
+    @Override
+    protected void refreshCache(final ConfigGroupEnum group, final String 
namespaceId, final Runnable rebuild) {
+        if (Objects.isNull(cacheRebuildCoordinator)) {
+            super.refreshCache(group, namespaceId, rebuild);
+            return;
+        }
+        Runnable work = () -> {
+            synchronized (refreshLocks.computeIfAbsent(namespaceId, key -> new 
Object())) {
+                rebuild.run();
+            }
+            // The coalescing path owns its notification: wake long-poll 
clients only after publication.
+            try {
+                scheduler.execute(new DataChangeTask(group, namespaceId));
+            } catch (RejectedExecutionException ex) {
+                if (!scheduler.isShutdown()) {
+                    throw ex;
+                }
+                LOG.debug("skip long-polling notification for {} {}: listener 
is shutting down", namespaceId, group, ex);
+            }
+        };
+        if (!cacheRebuildCoordinator.request(buildCacheKey(namespaceId, 
group.name()), work)) {
+            // Bounded memory under overload: new keys apply backpressure to 
their committing caller.
+            work.run();
+        }
+    }
+
+    @Override
+    protected void refreshLocalCache(final String namespaceId) {
+        synchronized (refreshLocks.computeIfAbsent(namespaceId, key -> new 
Object())) {
+            super.refreshLocalCache(namespaceId);
+        }
+    }
+
+    private void notifyChanged(final ConfigGroupEnum group, final String 
namespaceId) {
+        // Intentionally a no-op when coalescing is enabled: the coalesced 
rebuild in refreshCache issues
+        // the DataChangeTask after it publishes the new snapshot, so 
notifying here would wake clients
+        // before the cache they compare against has changed.
+        if (Objects.isNull(cacheRebuildCoordinator)) {
+            scheduler.execute(new DataChangeTask(group, namespaceId));
+        }
+    }
+
+    @Override
+    public void destroy() {
+        if (Objects.nonNull(cacheRebuildCoordinator)) {
+            cacheRebuildCoordinator.close();
+        }
+        scheduler.shutdownNow();
     }
 
     @Override
@@ -172,37 +230,37 @@ public class HttpLongPollingDataChangedListener extends 
AbstractDataChangedListe
 
     @Override
     protected void afterAppAuthChanged(final List<AppAuthData> changed, final 
DataEventTypeEnum eventType, final String namespaceId) {
-        scheduler.execute(new DataChangeTask(ConfigGroupEnum.APP_AUTH, 
namespaceId));
+        notifyChanged(ConfigGroupEnum.APP_AUTH, namespaceId);
     }
 
     @Override
     protected void afterMetaDataChanged(final List<MetaData> changed, final 
DataEventTypeEnum eventType, final String namespaceId) {
-        scheduler.execute(new DataChangeTask(ConfigGroupEnum.META_DATA, 
namespaceId));
+        notifyChanged(ConfigGroupEnum.META_DATA, namespaceId);
     }
 
     @Override
     protected void afterPluginChanged(final List<PluginData> changed, final 
DataEventTypeEnum eventType, final String namespaceId) {
-        scheduler.execute(new DataChangeTask(ConfigGroupEnum.PLUGIN, 
namespaceId));
+        notifyChanged(ConfigGroupEnum.PLUGIN, namespaceId);
     }
 
     @Override
     protected void afterRuleChanged(final List<RuleData> changed, final 
DataEventTypeEnum eventType, final String namespaceId) {
-        scheduler.execute(new DataChangeTask(ConfigGroupEnum.RULE, 
namespaceId));
+        notifyChanged(ConfigGroupEnum.RULE, namespaceId);
     }
 
     @Override
     protected void afterSelectorChanged(final List<SelectorData> changed, 
final DataEventTypeEnum eventType, final String namespaceId) {
-        scheduler.execute(new DataChangeTask(ConfigGroupEnum.SELECTOR, 
namespaceId));
+        notifyChanged(ConfigGroupEnum.SELECTOR, namespaceId);
     }
 
     @Override
     protected void afterProxySelectorChanged(final List<ProxySelectorData> 
changed, final DataEventTypeEnum eventType, final String namespaceId) {
-        scheduler.execute(new DataChangeTask(ConfigGroupEnum.PROXY_SELECTOR, 
namespaceId));
+        notifyChanged(ConfigGroupEnum.PROXY_SELECTOR, namespaceId);
     }
 
     @Override
     protected void afterDiscoveryUpstreamDataChanged(final 
List<DiscoverySyncData> changed, final DataEventTypeEnum eventType, final 
String namespaceId) {
-        scheduler.execute(new 
DataChangeTask(ConfigGroupEnum.DISCOVER_UPSTREAM, namespaceId));
+        notifyChanged(ConfigGroupEnum.DISCOVER_UPSTREAM, namespaceId);
     }
 
     private List<ConfigGroupEnum> compareChangedGroup(final HttpServletRequest 
request) {
@@ -426,6 +484,15 @@ public class HttpLongPollingDataChangedListener extends 
AbstractDataChangedListe
         @Override
         public void run() {
             try {
+                if (Objects.nonNull(cacheRebuildCoordinator)) {
+                    // Publication may race the request thread's first 
comparison. Registration and
+                    // notification share this scheduler, so recheck before 
entering the wait queue.
+                    List<ConfigGroupEnum> changedGroups = 
compareChangedGroup((HttpServletRequest) asyncContext.getRequest());
+                    if (CollectionUtils.isNotEmpty(changedGroups)) {
+                        sendResponse(changedGroups);
+                        return;
+                    }
+                }
                 BlockingQueue<LongPollingClient> namespaceClients = 
clientsMap.getOrDefault(namespaceId, new ArrayBlockingQueue<>(1024));
                 this.asyncTimeoutFuture = scheduler.schedule(() -> {
                     namespaceClients.remove(LongPollingClient.this);
diff --git a/shenyu-admin/src/main/resources/application.yml 
b/shenyu-admin/src/main/resources/application.yml
index 9265355509..015396d931 100755
--- a/shenyu-admin/src/main/resources/application.yml
+++ b/shenyu-admin/src/main/resources/application.yml
@@ -96,6 +96,9 @@ shenyu:
 #      connectionTimeoutMilliseconds: 2000
 #    http:
 #      enabled: true
+#      cache-rebuild-window: 0ms # opt in with e.g. 10ms after measuring 
convergence
+#      cache-rebuild-max-pending-keys: 1024
+#      cache-rebuild-threads: 1 # raise when many namespaces/groups change 
concurrently
 #    nacos:
 #      url: localhost:8848
 #      namespace: 1c10d748-af86-43b9-8265-75f487d20c6c
diff --git 
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/config/properties/HttpSyncPropertiesTest.java
 
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/config/properties/HttpSyncPropertiesTest.java
index 4821e9781a..bf5a91d8d2 100644
--- 
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/config/properties/HttpSyncPropertiesTest.java
+++ 
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/config/properties/HttpSyncPropertiesTest.java
@@ -44,6 +44,9 @@ public final class HttpSyncPropertiesTest extends 
AbstractConfigurationTest {
         assertThat(httpSyncProperties.isEnabled(), comparesEqualTo(true));
         Assertions.assertEquals(httpSyncProperties.getNotifyBatchSize(), 0);
         assertThat(httpSyncProperties.getRefreshInterval(), 
comparesEqualTo(Duration.ofMinutes(5)));
+        Assertions.assertEquals(Duration.ZERO, 
httpSyncProperties.getCacheRebuildWindow());
+        Assertions.assertEquals(1024, 
httpSyncProperties.getCacheRebuildMaxPendingKeys());
+        Assertions.assertEquals(1, 
httpSyncProperties.getCacheRebuildThreads());
     }
 
     @Test
@@ -54,6 +57,23 @@ public final class HttpSyncPropertiesTest extends 
AbstractConfigurationTest {
         assertThat(httpSyncProperties.getRefreshInterval(), 
comparesEqualTo(Duration.ofMinutes(1)));
     }
 
+    @Test
+    void testCoalescingConfiguration() {
+        load(HttpSyncPropertiesConfiguration.class, 
"shenyu.sync.http.cache-rebuild-window=10ms", 
"shenyu.sync.http.cache-rebuild-max-pending-keys=32",
+                "shenyu.sync.http.cache-rebuild-threads=4");
+        HttpSyncProperties properties = 
getContext().getBean(HttpSyncProperties.class);
+        Assertions.assertEquals(Duration.ofMillis(10), 
properties.getCacheRebuildWindow());
+        Assertions.assertEquals(32, 
properties.getCacheRebuildMaxPendingKeys());
+        Assertions.assertEquals(4, properties.getCacheRebuildThreads());
+        Assertions.assertThrows(IllegalArgumentException.class, () -> 
properties.setCacheRebuildThreads(0));
+        Assertions.assertThrows(IllegalArgumentException.class, () -> 
properties.setCacheRebuildThreads(65));
+        Assertions.assertThrows(IllegalArgumentException.class, () -> 
properties.setCacheRebuildWindow(Duration.ofMillis(-1)));
+        Assertions.assertThrows(IllegalArgumentException.class, () -> 
properties.setCacheRebuildWindow(Duration.ofSeconds(2)));
+        Assertions.assertThrows(IllegalArgumentException.class, () -> 
properties.setCacheRebuildWindow(Duration.ofNanos(1)));
+        Assertions.assertThrows(IllegalArgumentException.class, () -> 
properties.setCacheRebuildWindow(null));
+        Assertions.assertThrows(IllegalArgumentException.class, () -> 
properties.setCacheRebuildMaxPendingKeys(0));
+    }
+
     @Configuration
     @EnableConfigurationProperties(HttpSyncProperties.class)
     static class HttpSyncPropertiesConfiguration {
diff --git 
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/listener/http/HttpCacheRebuildBenchmark.java
 
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/listener/http/HttpCacheRebuildBenchmark.java
new file mode 100644
index 0000000000..390170d8d7
--- /dev/null
+++ 
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/listener/http/HttpCacheRebuildBenchmark.java
@@ -0,0 +1,425 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.admin.listener.http;
+
+import ch.qos.logback.classic.Level;
+import ch.qos.logback.classic.Logger;
+import com.sun.management.ThreadMXBean;
+import com.sun.management.OperatingSystemMXBean;
+import org.apache.shenyu.admin.config.properties.ClusterProperties;
+import org.apache.shenyu.admin.config.properties.HttpSyncProperties;
+import org.apache.shenyu.admin.listener.DataChangedEvent;
+import org.apache.shenyu.admin.listener.DataChangedEventDispatcher;
+import org.apache.shenyu.admin.service.RuleService;
+import org.apache.shenyu.admin.service.SelectorService;
+import org.apache.shenyu.common.dto.RuleData;
+import org.apache.shenyu.common.dto.SelectorData;
+import org.apache.shenyu.common.enums.ConfigGroupEnum;
+import org.apache.shenyu.common.enums.DataEventTypeEnum;
+import org.h2.jdbcx.JdbcDataSource;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
+import org.slf4j.LoggerFactory;
+import org.springframework.context.ApplicationContext;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.datasource.DataSourceTransactionManager;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.transaction.support.TransactionSynchronization;
+import 
org.springframework.transaction.support.TransactionSynchronizationManager;
+import org.springframework.transaction.support.TransactionTemplate;
+
+import java.lang.management.ManagementFactory;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.locks.LockSupport;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Opt-in H2 producer benchmark. Measures real queries and HTTP cache 
serialization, with
+ * in-process fetch latency; it does not measure a deployed gateway's 
business-request p99.
+ */
+@EnabledIfSystemProperty(named = "shenyu.http.benchmark", matches = "true")
+class HttpCacheRebuildBenchmark {
+
+    private static final ThreadMXBean THREADS = (ThreadMXBean) 
ManagementFactory.getThreadMXBean();
+
+    private static final OperatingSystemMXBean PROCESS = 
(OperatingSystemMXBean) ManagementFactory.getOperatingSystemMXBean();
+
+    private final int rows = Integer.getInteger("shenyu.http.benchmark.rows", 
1000);
+
+    private final int events = 
Integer.getInteger("shenyu.http.benchmark.events", 100);
+
+    private final int warmup = 
Integer.getInteger("shenyu.http.benchmark.warmup", 5);
+
+    private final int samples = 
Integer.getInteger("shenyu.http.benchmark.samples", 3);
+
+    private final int handleChars = 
Integer.getInteger("shenyu.http.benchmark.handleChars", 256);
+
+    @Test
+    void benchmark() throws Exception {
+        THREADS.setThreadAllocatedMemoryEnabled(true);
+        Logger root = (Logger) 
LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME);
+        Level previous = root.getLevel();
+        root.setLevel(Level.WARN);
+        List<String> output = new ArrayList<>();
+        
output.add("namespaces,groups,pace_ms,window_ms,sample,rows_per_key,handle_chars,events,queries,serializations,hashes,"
+                + 
"allocated_bytes,cpu_ms,gc_count,gc_ms,elapsed_ms,edit_p99_ms,fetch_p99_ms,max_publish_lag_ms,final_convergence_ms");
+        try {
+            for (int namespaces : List.of(1, 2)) {
+                for (int groups : List.of(1, 2)) {
+                    for (int pace : List.of(0, 5, 25)) {
+                        compare(namespaces, groups, pace, output);
+                    }
+                }
+            }
+            Path path = 
Path.of(System.getProperty("shenyu.http.benchmark.output", 
"target/http-cache-rebuild-benchmark.csv"));
+            Files.createDirectories(path.toAbsolutePath().getParent());
+            Files.write(path, output);
+        } finally {
+            root.setLevel(previous);
+        }
+        LoggerFactory.getLogger(getClass()).info("HTTP cache benchmark: JDK 
{}, processors {}, max heap {}, JVM args {}",
+                System.getProperty("java.runtime.version"), 
Runtime.getRuntime().availableProcessors(),
+                Runtime.getRuntime().maxMemory(), 
ManagementFactory.getRuntimeMXBean().getInputArguments());
+    }
+
+    private void compare(final int namespaces, final int groups, final int 
pace, final List<String> output) throws Exception {
+        // Warm both variants independently; alternate measurement order to 
expose ordering effects.
+        for (int sample = 0; sample < samples; sample++) {
+            for (int window : sample % 2 == 0 ? List.of(0, 10) : List.of(10, 
0)) {
+                try (Fixture fixture = new Fixture(namespaces, groups, 
window)) {
+                    for (int iteration = 0; iteration < warmup; iteration++) {
+                        fixture.run(events, 0);
+                    }
+                    for (int iteration = 0; iteration < 20; iteration++) {
+                        fixture.keys.forEach(key -> 
fixture.listener.fetchConfig(key.group, key.namespace));
+                    }
+                    output.add(fixture.measure(pace, window, sample));
+                }
+            }
+        }
+    }
+
+    private static long gcCount() {
+        return 
ManagementFactory.getGarbageCollectorMXBeans().stream().mapToLong(bean -> 
Math.max(0, bean.getCollectionCount())).sum();
+    }
+
+    private static long gcMillis() {
+        return 
ManagementFactory.getGarbageCollectorMXBeans().stream().mapToLong(bean -> 
Math.max(0, bean.getCollectionTime())).sum();
+    }
+
+    private static double p99(final List<Long> samples) {
+        List<Long> sorted = new ArrayList<>(samples);
+        Collections.sort(sorted);
+        return sorted.isEmpty() ? 0 : sorted.get(Math.min(sorted.size() - 1, 
(int) Math.ceil(sorted.size() * 0.99) - 1)) / 1_000_000.0;
+    }
+
+    private final class Fixture implements AutoCloseable {
+
+        private final JdbcTemplate jdbc;
+
+        private final TransactionTemplate transactions;
+
+        private final MeasuredListener listener;
+
+        private final DataChangedEventDispatcher dispatcher;
+
+        private final List<Key> keys = new ArrayList<>();
+
+        private final AtomicInteger queries = new AtomicInteger();
+
+        private final Map<String, String> expected = new ConcurrentHashMap<>();
+
+        private final Map<String, Long> committed = new ConcurrentHashMap<>();
+
+        private int sequence;
+
+        private Fixture(final int namespaces, final int groups, final int 
window) {
+            JdbcDataSource source = new JdbcDataSource();
+            source.setURL("jdbc:h2:mem:" + UUID.randomUUID() + 
";DB_CLOSE_DELAY=-1");
+            jdbc = new JdbcTemplate(source);
+            transactions = new TransactionTemplate(new 
DataSourceTransactionManager(source));
+            jdbc.execute("CREATE TABLE config (namespace_id VARCHAR, 
group_name VARCHAR, id INT, name VARCHAR, handle VARCHAR, PRIMARY 
KEY(namespace_id, group_name, id))");
+            for (int namespace = 0; namespace < namespaces; namespace++) {
+                for (ConfigGroupEnum group : List.of(ConfigGroupEnum.RULE, 
ConfigGroupEnum.SELECTOR).subList(0, groups)) {
+                    Key key = new Key("namespace-" + namespace, group);
+                    keys.add(key);
+                    List<Object[]> batch = new ArrayList<>();
+                    for (int row = 0; row < rows; row++) {
+                        batch.add(new Object[]{key.namespace, group.name(), 
row, "initial", "x".repeat(handleChars)});
+                    }
+                    jdbc.batchUpdate("INSERT INTO config VALUES (?, ?, ?, ?, 
?)", batch);
+                }
+            }
+            HttpSyncProperties properties = new HttpSyncProperties();
+            properties.setCacheRebuildWindow(Duration.ofMillis(window));
+            listener = new MeasuredListener(properties, committed);
+            listener.clearCache();
+            RuleService rules = mock(RuleService.class);
+            
when(rules.listAllByNamespaceId(anyString())).thenAnswer(invocation -> 
loadRules(invocation.getArgument(0)));
+            SelectorService selectors = mock(SelectorService.class);
+            
when(selectors.listAllByNamespaceId(anyString())).thenAnswer(invocation -> 
loadSelectors(invocation.getArgument(0)));
+            ReflectionTestUtils.setField(listener, "ruleService", rules);
+            ReflectionTestUtils.setField(listener, "selectorService", 
selectors);
+            dispatcher = new 
DataChangedEventDispatcher(mock(ApplicationContext.class));
+            ReflectionTestUtils.setField(dispatcher, "listeners", 
List.of(listener));
+            ReflectionTestUtils.setField(dispatcher, "clusterProperties", new 
ClusterProperties());
+        }
+
+        private List<RuleData> loadRules(final String namespace) {
+            queries.incrementAndGet();
+            return jdbc.query("SELECT * FROM config WHERE namespace_id = ? AND 
group_name = 'RULE' ORDER BY id", (row, index) -> {
+                RuleData data = new RuleData();
+                data.setId(row.getString("id"));
+                data.setName(row.getString("name"));
+                data.setHandle(row.getString("handle"));
+                data.setNamespaceId(namespace);
+                return data;
+            }, namespace);
+        }
+
+        private List<SelectorData> loadSelectors(final String namespace) {
+            queries.incrementAndGet();
+            return jdbc.query("SELECT * FROM config WHERE namespace_id = ? AND 
group_name = 'SELECTOR' ORDER BY id", (row, index) -> {
+                SelectorData data = new SelectorData();
+                data.setId(row.getString("id"));
+                data.setName(row.getString("name"));
+                data.setHandle(row.getString("handle"));
+                data.setNamespaceId(namespace);
+                return data;
+            }, namespace);
+        }
+
+        private void write(final Key key) {
+            String version = "version-" + ++sequence;
+            transactions.executeWithoutResult(status -> {
+                jdbc.update("UPDATE config SET name = ? WHERE namespace_id = ? 
AND group_name = ? AND id = 0", version, key.namespace, key.group.name());
+                TransactionSynchronizationManager.registerSynchronization(new 
TransactionSynchronization() {
+                    @Override
+                    public void afterCommit() {
+                        committed.put(version, System.nanoTime());
+                        expected.put(key.cacheKey(), version);
+                    }
+                });
+                if (key.group == ConfigGroupEnum.RULE) {
+                    RuleData data = new RuleData();
+                    data.setNamespaceId(key.namespace);
+                    dispatcher.onApplicationEvent(new 
DataChangedEvent(key.group, DataEventTypeEnum.UPDATE, List.of(data)));
+                } else {
+                    SelectorData data = new SelectorData();
+                    data.setNamespaceId(key.namespace);
+                    dispatcher.onApplicationEvent(new 
DataChangedEvent(key.group, DataEventTypeEnum.UPDATE, List.of(data)));
+                }
+            });
+        }
+
+        private RunResult run(final int count, final int pace) throws 
Exception {
+            List<Long> edits = new ArrayList<>();
+            long next = System.nanoTime();
+            for (int index = 0; index < count; index++) {
+                LockSupport.parkNanos(Math.max(0, next - System.nanoTime()));
+                long start = System.nanoTime();
+                write(keys.get(index % keys.size()));
+                edits.add(System.nanoTime() - start);
+                next += TimeUnit.MILLISECONDS.toNanos(pace);
+            }
+            long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10);
+            while (!expected.entrySet().stream().allMatch(entry -> 
entry.getValue().equals(listener.published.get(entry.getKey())))) {
+                assertTrue(System.nanoTime() < deadline, "cache did not 
converge");
+                Thread.sleep(1);
+            }
+            long lag = expected.values().stream().mapToLong(version -> 
listener.publishTimes.get(version) - committed.get(version)).max().orElse(0);
+            return new RunResult(edits, lag);
+        }
+
+        private String measure(final int pace, final int window, final int 
sample) throws Exception {
+            // Drain any redundant generation from warmup before counters are 
reset.
+            ScheduledBarrier.await(listener);
+            queries.set(0);
+            listener.serializations.set(0);
+            listener.maxLag.set(0);
+            AtomicBoolean reading = new AtomicBoolean(true);
+            List<Long> fetches = new ArrayList<>();
+            CountDownLatch readerDone = new CountDownLatch(1);
+            Thread reader = new Thread(() -> {
+                while (reading.get()) {
+                    for (Key key : keys) {
+                        long start = System.nanoTime();
+                        listener.fetchConfig(key.group, key.namespace);
+                        fetches.add(System.nanoTime() - start);
+                    }
+                    LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(2));
+                }
+                try {
+                    readerDone.await();
+                } catch (InterruptedException ex) {
+                    Thread.currentThread().interrupt();
+                }
+            }, "http-benchmark-reader");
+            reader.start();
+            Map<Long, Long> before = threadAllocations();
+            final long cpuBefore = PROCESS.getProcessCpuTime();
+            final long gc = gcCount();
+            final long gcTime = gcMillis();
+            long start = System.nanoTime();
+            RunResult result;
+            try {
+                result = run(events, pace);
+                ScheduledBarrier.await(listener);
+            } catch (Exception | AssertionError ex) {
+                readerDone.countDown();
+                throw ex;
+            } finally {
+                reading.set(false);
+            }
+            final long elapsed = System.nanoTime() - start;
+            // The reader stays alive until sampled, to include its 
allocations and CPU.
+            final long cpu = PROCESS.getProcessCpuTime() - cpuBefore;
+            Map<Long, Long> after = threadAllocations();
+            readerDone.countDown();
+            reader.join(5000);
+            long allocation = 0;
+            for (Map.Entry<Long, Long> entry : after.entrySet()) {
+                allocation += Math.max(0, entry.getValue() - 
before.getOrDefault(entry.getKey(), 0L));
+            }
+            assertEquals(queries.get(), listener.serializations.get());
+            assertTrue(result.finalLag <= TimeUnit.MILLISECONDS.toNanos(250), 
"250ms final publication budget exceeded");
+            return String.format(Locale.ROOT, 
"%d,%d,%d,%d,%d,%d,%d,%d,%d,%d,%d,%d,%.3f,%d,%d,%.3f,%.3f,%.3f,%.3f,%.3f",
+                    keys.stream().map(key -> 
key.namespace).distinct().count(), keys.stream().map(key -> 
key.group).distinct().count(),
+                    pace, window, sample, rows, handleChars, events, 
queries.get(), listener.serializations.get(), listener.serializations.get(),
+                    allocation, cpu / 1_000_000.0, gcCount() - gc, gcMillis() 
- gcTime, elapsed / 1_000_000.0,
+                    p99(result.edits), p99(fetches), listener.maxLag.get() / 
1_000_000.0, result.finalLag / 1_000_000.0);
+        }
+
+        private Map<Long, Long> threadAllocations() {
+            Map<Long, Long> result = new HashMap<>();
+            for (long id : THREADS.getAllThreadIds()) {
+                result.put(id, Math.max(0, 
THREADS.getThreadAllocatedBytes(id)));
+            }
+            return result;
+        }
+
+        @Override
+        public void close() {
+            listener.destroy();
+            listener.clearCache();
+            jdbc.execute("SHUTDOWN");
+        }
+    }
+
+    private static final class ScheduledBarrier {
+
+        private static void await(final MeasuredListener listener) throws 
Exception {
+            HttpCacheRebuildCoordinator coordinator = 
(HttpCacheRebuildCoordinator) ReflectionTestUtils.getField(listener, 
"cacheRebuildCoordinator");
+            if (java.util.Objects.nonNull(coordinator)) {
+                Map<?, ?> pending = (Map<?, ?>) 
ReflectionTestUtils.getField(coordinator, "pending");
+                long deadline = System.nanoTime() + 
TimeUnit.SECONDS.toNanos(10);
+                while (true) {
+                    synchronized (pending) {
+                        if (pending.isEmpty()) {
+                            return;
+                        }
+                    }
+                    assertTrue(System.nanoTime() < deadline, "pending rebuilds 
did not drain");
+                    Thread.sleep(1);
+                }
+            }
+        }
+    }
+
+    private static final class MeasuredListener extends 
HttpLongPollingDataChangedListener {
+
+        private final Map<String, Long> committed;
+
+        private final Map<String, String> published = new 
ConcurrentHashMap<>();
+
+        private final Map<String, Long> publishTimes = new 
ConcurrentHashMap<>();
+
+        private final AtomicInteger serializations = new AtomicInteger();
+
+        private final AtomicLong maxLag = new AtomicLong();
+
+        private MeasuredListener(final HttpSyncProperties properties, final 
Map<String, Long> committed) {
+            super(properties);
+            this.committed = committed;
+        }
+
+        @Override
+        protected <T> void updateCache(final ConfigGroupEnum group, final 
List<T> data, final String namespaceId) {
+            super.updateCache(group, data, namespaceId);
+            serializations.incrementAndGet();
+            String version = group == ConfigGroupEnum.RULE ? ((RuleData) 
data.get(0)).getName() : ((SelectorData) data.get(0)).getName();
+            long now = System.nanoTime();
+            if (java.util.Objects.isNull(publishTimes.putIfAbsent(version, 
now))) {
+                maxLag.accumulateAndGet(now - committed.getOrDefault(version, 
now), Math::max);
+            }
+            published.put(buildCacheKey(namespaceId, group.name()), version);
+        }
+
+        private void clearCache() {
+            CACHE.clear();
+        }
+    }
+
+    private static final class Key {
+
+        private final String namespace;
+
+        private final ConfigGroupEnum group;
+
+        private Key(final String namespace, final ConfigGroupEnum group) {
+            this.namespace = namespace;
+            this.group = group;
+        }
+
+        private String cacheKey() {
+            return HttpLongPollingDataChangedListener.buildCacheKey(namespace, 
group.name());
+        }
+    }
+
+    private static final class RunResult {
+
+        private final List<Long> edits;
+
+        private final long finalLag;
+
+        private RunResult(final List<Long> edits, final long finalLag) {
+            this.edits = edits;
+            this.finalLag = finalLag;
+        }
+    }
+}
diff --git 
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/listener/http/HttpCacheRebuildCoordinatorTest.java
 
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/listener/http/HttpCacheRebuildCoordinatorTest.java
new file mode 100644
index 0000000000..6680e5c196
--- /dev/null
+++ 
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/listener/http/HttpCacheRebuildCoordinatorTest.java
@@ -0,0 +1,296 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.admin.listener.http;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Queue;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.CyclicBarrier;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.FutureTask;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Deterministic tests of deadlines, generations, retries and bounded 
admission.
+ */
+class HttpCacheRebuildCoordinatorTest {
+
+    private ScheduledExecutorService executor;
+
+    private Queue<Runnable> tasks;
+
+    private List<Long> delays;
+
+    private HttpCacheRebuildCoordinator coordinator;
+
+    @BeforeEach
+    void setUp() {
+        executor = mock(ScheduledExecutorService.class);
+        tasks = new ArrayDeque<>();
+        delays = new ArrayList<>();
+        doAnswer(invocation -> {
+            tasks.add(invocation.getArgument(0));
+            delays.add(invocation.getArgument(1));
+            return null;
+        }).when(executor).schedule(any(Runnable.class), anyLong(), 
eq(TimeUnit.MILLISECONDS));
+        coordinator = new HttpCacheRebuildCoordinator(executor, 10, 2);
+    }
+
+    @Test
+    void burstKeepsFirstDeadlineAndReleasesIdleKey() {
+        AtomicInteger rebuilds = new AtomicInteger();
+        for (int index = 0; index < 1000; index++) {
+            assertTrue(coordinator.request("a_RULE", 
rebuilds::incrementAndGet));
+        }
+        verify(executor, times(1)).schedule(any(Runnable.class), eq(10L), 
eq(TimeUnit.MILLISECONDS));
+        tasks.remove().run();
+        assertEquals(1, rebuilds.get());
+        assertTrue(tasks.isEmpty());
+        assertTrue(coordinator.request("a_RULE", rebuilds::incrementAndGet));
+        tasks.remove().run();
+        assertEquals(2, rebuilds.get());
+    }
+
+    @Test
+    void concurrentArrivalDuringRebuildIsNotLost() throws Exception {
+        CountDownLatch reading = new CountDownLatch(1);
+        CountDownLatch release = new CountDownLatch(1);
+        AtomicInteger database = new AtomicInteger(1);
+        AtomicInteger published = new AtomicInteger();
+        AtomicInteger calls = new AtomicInteger();
+        Runnable rebuild = () -> {
+            int snapshot = database.get();
+            if (calls.incrementAndGet() == 1) {
+                reading.countDown();
+                try {
+                    assertTrue(release.await(5, TimeUnit.SECONDS));
+                } catch (InterruptedException ex) {
+                    Thread.currentThread().interrupt();
+                    throw new IllegalStateException(ex);
+                }
+            }
+            published.set(snapshot);
+        };
+        coordinator.request("a_RULE", rebuild);
+        FutureTask<Void> first = new FutureTask<>(tasks.remove(), null);
+        Thread worker = new Thread(first);
+        worker.start();
+        try {
+            assertTrue(reading.await(5, TimeUnit.SECONDS));
+            database.set(2);
+            coordinator.request("a_RULE", rebuild);
+        } finally {
+            release.countDown();
+        }
+        first.get(5, TimeUnit.SECONDS);
+        assertEquals(1, published.get());
+        assertEquals(1, tasks.size());
+        tasks.remove().run();
+        assertEquals(2, published.get());
+        assertEquals(2, calls.get());
+        assertTrue(tasks.isEmpty());
+    }
+
+    @Test
+    void separatesKeysAndBoundsPendingWork() {
+        AtomicInteger rebuilds = new AtomicInteger();
+        assertTrue(coordinator.request("a_RULE", rebuilds::incrementAndGet));
+        assertTrue(coordinator.request("b_RULE", rebuilds::incrementAndGet));
+        assertFalse(coordinator.request("a_SELECTOR", 
rebuilds::incrementAndGet));
+        assertTrue(coordinator.request("a_RULE", rebuilds::incrementAndGet));
+        assertEquals(2, tasks.size());
+        tasks.remove().run();
+        assertTrue(coordinator.request("a_SELECTOR", 
rebuilds::incrementAndGet));
+        tasks.remove().run();
+        tasks.remove().run();
+        assertEquals(3, rebuilds.get());
+        assertTrue(tasks.isEmpty());
+    }
+
+    @Test
+    void failureRetriesWithoutAnotherEventAndYieldsToOtherKeys() {
+        AtomicInteger calls = new AtomicInteger();
+        AtomicInteger other = new AtomicInteger();
+        coordinator.request("a_RULE", () -> {
+            if (calls.incrementAndGet() == 1) {
+                throw new IllegalStateException("temporary database failure");
+            }
+        });
+        coordinator.request("b_RULE", other::incrementAndGet);
+        tasks.remove().run();
+        assertEquals(2, tasks.size());
+        verify(executor).schedule(any(Runnable.class), eq(1000L), 
eq(TimeUnit.MILLISECONDS));
+        tasks.remove().run();
+        assertEquals(1, other.get());
+        tasks.remove().run();
+        assertEquals(2, calls.get());
+        assertTrue(tasks.isEmpty());
+    }
+
+    @Test
+    void rejectedSubmissionDoesNotLeavePendingMarker() {
+        when(executor.schedule(any(Runnable.class), eq(10L), 
eq(TimeUnit.MILLISECONDS)))
+                .thenThrow(new RejectedExecutionException());
+        assertFalse(coordinator.request("a_RULE", () -> { }));
+        assertFalse(coordinator.request("a_RULE", () -> { }));
+        verify(executor, times(2)).schedule(any(Runnable.class), eq(10L), 
eq(TimeUnit.MILLISECONDS));
+    }
+
+    @Test
+    void closeCancelsPendingRebuilds() {
+        AtomicInteger rebuilds = new AtomicInteger();
+        coordinator.request("a_RULE", rebuilds::incrementAndGet);
+        coordinator.close();
+        tasks.remove().run();
+        assertEquals(0, rebuilds.get());
+        verify(executor).shutdownNow();
+        // A commit racing shutdown is told to rebuild synchronously instead 
of failing its hook.
+        assertFalse(coordinator.request("a_RULE", rebuilds::incrementAndGet));
+        assertTrue(tasks.isEmpty());
+    }
+
+    @Test
+    void 
consecutiveFailuresBackOffExponentiallyUpToTheCapAndResetAfterSuccess() {
+        AtomicInteger calls = new AtomicInteger();
+        AtomicInteger failuresLeft = new AtomicInteger(7);
+        Runnable rebuild = () -> {
+            calls.incrementAndGet();
+            if (failuresLeft.getAndDecrement() > 0) {
+                throw new IllegalStateException("database unavailable");
+            }
+        };
+        coordinator.request("a_RULE", rebuild);
+        while (!tasks.isEmpty()) {
+            tasks.remove().run();
+        }
+        assertEquals(8, calls.get());
+        assertEquals(List.of(10L, 1000L, 2000L, 4000L, 8000L, 16000L, 30000L, 
30000L), delays);
+
+        delays.clear();
+        failuresLeft.set(1);
+        coordinator.request("a_RULE", rebuild);
+        while (!tasks.isEmpty()) {
+            tasks.remove().run();
+        }
+        // The previous success reset the failure count, so backoff starts 
again at one second.
+        assertEquals(List.of(10L, 1000L), delays);
+    }
+
+    @Test
+    void retryDelayNeverOverflowsAndNeverUndercutsTheWindow() {
+        assertEquals(HttpCacheRebuildCoordinator.FAILURE_RETRY_MILLIS, 
coordinator.retryDelayMillis(1));
+        assertEquals(HttpCacheRebuildCoordinator.MAX_FAILURE_RETRY_MILLIS, 
coordinator.retryDelayMillis(Integer.MAX_VALUE));
+        HttpCacheRebuildCoordinator slowWindow = new 
HttpCacheRebuildCoordinator(executor, 5000, 2);
+        assertEquals(5000, slowWindow.retryDelayMillis(1));
+        assertEquals(HttpCacheRebuildCoordinator.MAX_FAILURE_RETRY_MILLIS, 
slowWindow.retryDelayMillis(10));
+    }
+
+    @Test
+    void multipleWorkersRebuildDistinctKeysInParallel() throws Exception {
+        ScheduledThreadPoolExecutor workers = new 
ScheduledThreadPoolExecutor(2);
+        HttpCacheRebuildCoordinator parallel = new 
HttpCacheRebuildCoordinator(workers, 1, 16);
+        try {
+            // Each rebuild waits for the other key: this only completes when 
both run at the same time.
+            CyclicBarrier bothRunning = new CyclicBarrier(2);
+            CountDownLatch done = new CountDownLatch(2);
+            Runnable rebuild = () -> {
+                try {
+                    bothRunning.await(5, TimeUnit.SECONDS);
+                    done.countDown();
+                } catch (Exception ex) {
+                    throw new IllegalStateException(ex);
+                }
+            };
+            assertTrue(parallel.request("a_RULE", rebuild));
+            assertTrue(parallel.request("b_RULE", rebuild));
+            assertTrue(done.await(5, TimeUnit.SECONDS));
+        } finally {
+            parallel.close();
+        }
+    }
+
+    @Test
+    void multipleWorkersNeverRebuildTheSameKeyConcurrently() throws Exception {
+        ScheduledThreadPoolExecutor workers = new 
ScheduledThreadPoolExecutor(4);
+        HttpCacheRebuildCoordinator parallel = new 
HttpCacheRebuildCoordinator(workers, 1, 16);
+        AtomicInteger running = new AtomicInteger();
+        AtomicInteger maxRunning = new AtomicInteger();
+        AtomicInteger database = new AtomicInteger();
+        AtomicInteger published = new AtomicInteger(-1);
+        Runnable rebuild = () -> {
+            maxRunning.accumulateAndGet(running.incrementAndGet(), Math::max);
+            try {
+                int snapshot = database.get();
+                Thread.sleep(1);
+                published.set(snapshot);
+            } catch (InterruptedException ex) {
+                Thread.currentThread().interrupt();
+            } finally {
+                running.decrementAndGet();
+            }
+        };
+        ExecutorService writers = Executors.newFixedThreadPool(4);
+        try {
+            List<Future<?>> commits = new ArrayList<>();
+            for (int index = 1; index <= 200; index++) {
+                int value = index;
+                commits.add(writers.submit(() -> {
+                    database.accumulateAndGet(value, Math::max);
+                    if (!parallel.request("a_RULE", rebuild)) {
+                        rebuild.run();
+                    }
+                }));
+            }
+            for (Future<?> commit : commits) {
+                commit.get(5, TimeUnit.SECONDS);
+            }
+            long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
+            while (published.get() != 200 && System.nanoTime() < deadline) {
+                Thread.sleep(5);
+            }
+            assertEquals(200, published.get());
+            assertEquals(1, maxRunning.get());
+        } finally {
+            writers.shutdownNow();
+            parallel.close();
+        }
+    }
+}
diff --git 
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/listener/http/HttpCacheRebuildTest.java
 
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/listener/http/HttpCacheRebuildTest.java
new file mode 100644
index 0000000000..9bfab8b654
--- /dev/null
+++ 
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/listener/http/HttpCacheRebuildTest.java
@@ -0,0 +1,357 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.admin.listener.http;
+
+import jakarta.servlet.AsyncContext;
+import org.apache.shenyu.admin.config.properties.ClusterProperties;
+import org.apache.shenyu.admin.config.properties.HttpSyncProperties;
+import org.apache.shenyu.admin.listener.ConfigDataCache;
+import org.apache.shenyu.admin.listener.DataChangedEvent;
+import org.apache.shenyu.admin.listener.DataChangedEventDispatcher;
+import org.apache.shenyu.admin.listener.DataChangedListener;
+import org.apache.shenyu.admin.service.RuleService;
+import org.apache.shenyu.admin.service.SelectorService;
+import org.apache.shenyu.common.dto.RuleData;
+import org.apache.shenyu.common.dto.SelectorData;
+import org.apache.shenyu.common.enums.ConfigGroupEnum;
+import org.apache.shenyu.common.enums.DataEventTypeEnum;
+import org.h2.jdbcx.JdbcDataSource;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.context.ApplicationContext;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.datasource.DataSourceTransactionManager;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.transaction.support.TransactionTemplate;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Queue;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Exercise committed independent events against H2, the real dispatcher and 
the HTTP cache.
+ */
+class HttpCacheRebuildTest {
+
+    private TestListener listener;
+
+    private JdbcTemplate jdbc;
+
+    private TransactionTemplate transactions;
+
+    private DataChangedEventDispatcher dispatcher;
+
+    private DataChangedListener otherListener;
+
+    private Queue<Runnable> tasks;
+
+    private final AtomicInteger queries = new AtomicInteger();
+
+    @BeforeEach
+    void setUp() {
+        JdbcDataSource source = new JdbcDataSource();
+        source.setURL("jdbc:h2:mem:" + UUID.randomUUID() + 
";DB_CLOSE_DELAY=-1");
+        jdbc = new JdbcTemplate(source);
+        jdbc.execute("CREATE TABLE test_rule (id VARCHAR PRIMARY KEY, 
namespace_id VARCHAR, name VARCHAR)");
+        transactions = new TransactionTemplate(new 
DataSourceTransactionManager(source));
+        HttpSyncProperties properties = new HttpSyncProperties();
+        properties.setCacheRebuildWindow(Duration.ofMillis(10));
+        listener = new TestListener(properties);
+        listener.clearCache();
+        HttpCacheRebuildCoordinator original = (HttpCacheRebuildCoordinator) 
ReflectionTestUtils.getField(listener, "cacheRebuildCoordinator");
+        original.close();
+        ScheduledExecutorService executor = 
mock(ScheduledExecutorService.class);
+        tasks = new ConcurrentLinkedQueue<>();
+        doAnswer(invocation -> {
+            tasks.add(invocation.getArgument(0));
+            return null;
+        }).when(executor).schedule(any(Runnable.class), eq(10L), 
eq(TimeUnit.MILLISECONDS));
+        ReflectionTestUtils.setField(listener, "cacheRebuildCoordinator", new 
HttpCacheRebuildCoordinator(executor, 10, 2));
+        RuleService rules = mock(RuleService.class);
+        when(rules.listAllByNamespaceId(anyString())).thenAnswer(invocation -> 
{
+            queries.incrementAndGet();
+            String namespaceId = invocation.getArgument(0);
+            return jdbc.query("SELECT id, namespace_id, name FROM test_rule 
WHERE namespace_id = ? ORDER BY id",
+                    (row, index) -> rule(row.getString("id"), 
row.getString("namespace_id"), row.getString("name")), namespaceId);
+        });
+        ReflectionTestUtils.setField(listener, "ruleService", rules);
+        SelectorService selectors = mock(SelectorService.class);
+        
when(selectors.listAllByNamespaceId(anyString())).thenReturn(Collections.emptyList());
+        ReflectionTestUtils.setField(listener, "selectorService", selectors);
+        otherListener = mock(DataChangedListener.class);
+        dispatcher = new 
DataChangedEventDispatcher(mock(ApplicationContext.class));
+        ReflectionTestUtils.setField(dispatcher, "clusterProperties", new 
ClusterProperties());
+        ReflectionTestUtils.setField(dispatcher, "listeners", 
List.of(listener, otherListener));
+    }
+
+    @AfterEach
+    void tearDown() {
+        listener.destroy();
+        listener.clearCache();
+        jdbc.execute("SHUTDOWN");
+    }
+
+    @Test
+    void independentConcurrentCommitsCoalesceWithoutDroppingEvents() throws 
Exception {
+        ExecutorService writers = Executors.newFixedThreadPool(4);
+        try {
+            List<Future<?>> commits = new ArrayList<>();
+            for (int index = 0; index < 40; index++) {
+                String id = String.valueOf(index);
+                commits.add(writers.submit(() -> commit(id, "a", 
"committed")));
+            }
+            for (Future<?> commit : commits) {
+                commit.get(10, TimeUnit.SECONDS);
+            }
+        } finally {
+            writers.shutdownNow();
+        }
+        assertEquals(0, queries.get());
+        assertEquals(1, tasks.size());
+        assertEquals(40, listener.callbacks.size());
+        tasks.remove().run();
+        assertEquals(1, queries.get());
+        assertEquals(1, listener.serializations.get());
+        assertEquals(40, listener.fetchConfig(ConfigGroupEnum.RULE, 
"a").getData().size());
+        verify(otherListener, 
org.mockito.Mockito.times(40)).onRuleChanged(any(), 
eq(DataEventTypeEnum.UPDATE), any());
+    }
+
+    @Test
+    void rollbackNeverSchedulesOrPublishesUncommittedData() {
+        transactions.executeWithoutResult(status -> {
+            jdbc.update("INSERT INTO test_rule VALUES (?, ?, ?)", "1", "a", 
"rolled-back");
+            event(rule("1", "a", "rolled-back"), DataEventTypeEnum.UPDATE);
+            assertTrue(tasks.isEmpty());
+            assertEquals(0, queries.get());
+            status.setRollbackOnly();
+        });
+        assertTrue(tasks.isEmpty());
+        assertTrue(listener.callbacks.isEmpty());
+        verify(otherListener, never()).onRuleChanged(any(), any(), any());
+        commit("2", "a", "committed");
+        tasks.remove().run();
+        assertEquals("committed", ((RuleData) 
listener.fetchConfig(ConfigGroupEnum.RULE, "a").getData().get(0)).getName());
+    }
+
+    @Test
+    void deleteThenUpdateUsesAuthoritativeSnapshotAndEmptyGroup() {
+        commit("1", "a", "first");
+        tasks.remove().run();
+        transactions.executeWithoutResult(status -> {
+            jdbc.update("DELETE FROM test_rule WHERE id = '1'");
+            event(rule("1", "a", "first"), DataEventTypeEnum.DELETE);
+        });
+        commit("1", "a", "replacement");
+        tasks.remove().run();
+        assertEquals("replacement", ((RuleData) 
listener.fetchConfig(ConfigGroupEnum.RULE, "a").getData().get(0)).getName());
+        transactions.executeWithoutResult(status -> {
+            jdbc.update("DELETE FROM test_rule");
+            event(rule("1", "a", "replacement"), DataEventTypeEnum.DELETE);
+        });
+        tasks.remove().run();
+        assertTrue(listener.fetchConfig(ConfigGroupEnum.RULE, 
"a").getData().isEmpty());
+        assertEquals(List.of(DataEventTypeEnum.UPDATE, 
DataEventTypeEnum.DELETE, DataEventTypeEnum.UPDATE, DataEventTypeEnum.DELETE), 
listener.callbacks);
+    }
+
+    @Test
+    void namespacesGroupsAndCapacityFallbackRemainIndependent() {
+        commit("1", "a", "a");
+        commit("2", "b", "b");
+        SelectorData selector = new SelectorData();
+        selector.setNamespaceId("a");
+        listener.onSelectorChanged(List.of(selector), 
DataEventTypeEnum.UPDATE);
+        // Both pending slots are occupied. The new selector key rebuilds on 
the caller.
+        assertTrue(listener.fetchConfig(ConfigGroupEnum.SELECTOR, 
"a").getData().isEmpty());
+        assertEquals(2, tasks.size());
+        tasks.remove().run();
+        tasks.remove().run();
+        assertEquals("a", ((RuleData) 
listener.fetchConfig(ConfigGroupEnum.RULE, "a").getData().get(0)).getName());
+        assertEquals("b", ((RuleData) 
listener.fetchConfig(ConfigGroupEnum.RULE, "b").getData().get(0)).getName());
+    }
+
+    @Test
+    void unchangedDigestPreservesVersion() {
+        commit("1", "a", "same");
+        tasks.remove().run();
+        ConfigDataCache before = listener.cached("a");
+        event(rule("1", "a", "same"), DataEventTypeEnum.UPDATE);
+        tasks.remove().run();
+        assertSame(before, listener.cached("a"));
+    }
+
+    @Test
+    void disabledModeStillRebuildsSynchronouslyPerEvent() {
+        ((HttpCacheRebuildCoordinator) ReflectionTestUtils.getField(listener, 
"cacheRebuildCoordinator")).close();
+        ReflectionTestUtils.setField(listener, "cacheRebuildCoordinator", 
null);
+        commit("1", "a", "first");
+        commit("2", "a", "second");
+        assertEquals(2, queries.get());
+        assertEquals(2, listener.serializations.get());
+        assertEquals(2, listener.fetchConfig(ConfigGroupEnum.RULE, 
"a").getData().size());
+        assertTrue(tasks.isEmpty());
+    }
+
+    @Test
+    void commitRacingShutdownRebuildsSynchronouslyWithoutFailingTheCommit() {
+        listener.destroy();
+        commit("1", "a", "after-shutdown");
+        assertTrue(tasks.isEmpty());
+        assertEquals(1, queries.get());
+        assertEquals("after-shutdown", ((RuleData) 
listener.fetchConfig(ConfigGroupEnum.RULE, "a").getData().get(0)).getName());
+        assertEquals(List.of(DataEventTypeEnum.UPDATE), listener.callbacks);
+    }
+
+    @Test
+    void configuredWorkerThreadsSizeTheRebuildExecutor() {
+        HttpSyncProperties properties = new HttpSyncProperties();
+        properties.setCacheRebuildWindow(Duration.ofMillis(10));
+        properties.setCacheRebuildThreads(3);
+        TestListener sized = new TestListener(properties);
+        try {
+            HttpCacheRebuildCoordinator coordinator = 
(HttpCacheRebuildCoordinator) ReflectionTestUtils.getField(sized, 
"cacheRebuildCoordinator");
+            assertEquals(3, 
((java.util.concurrent.ScheduledThreadPoolExecutor) 
ReflectionTestUtils.getField(coordinator, "executor")).getCorePoolSize());
+        } finally {
+            sized.destroy();
+        }
+    }
+
+    @Test
+    void longPollIsWokenOnlyAfterPublicationAndOnlyForItsNamespace() throws 
Exception {
+        AsyncContext client = client("a");
+        final AsyncContext other = client("b");
+        scheduler().submit(() -> { }).get(5, TimeUnit.SECONDS);
+        commit("1", "a", "visible-before-notification");
+        scheduler().submit(() -> { }).get(5, TimeUnit.SECONDS);
+        verify(client, never()).complete();
+        tasks.remove().run();
+        scheduler().submit(() -> { }).get(5, TimeUnit.SECONDS);
+        verify(client).complete();
+        verify(other, never()).complete();
+        assertTrue(((MockHttpServletResponse) 
client.getResponse()).getContentAsString().contains("RULE"));
+        assertEquals(1, listener.fetchConfig(ConfigGroupEnum.RULE, 
"a").getData().size());
+    }
+
+    private AsyncContext client(final String namespace) {
+        AsyncContext context = mock(AsyncContext.class);
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setParameter("namespaceId", namespace);
+        for (ConfigGroupEnum group : ConfigGroupEnum.values()) {
+            listener.seedCache(group, namespace);
+            request.setParameter(group.name(), "seed,0");
+        }
+        when(context.getRequest()).thenReturn(request);
+        when(context.getResponse()).thenReturn(new MockHttpServletResponse());
+        scheduler().execute(listener.new LongPollingClient(context, 
"127.0.0.1", 30000, namespace));
+        return context;
+    }
+
+    @Test
+    void registrationRechecksChangesPublishedAfterTheInitialComparison() 
throws Exception {
+        commit("1", "a", "changed-before-registration");
+        tasks.remove().run();
+        scheduler().submit(() -> { }).get(5, TimeUnit.SECONDS);
+        AsyncContext lateClient = client("a");
+        scheduler().submit(() -> { }).get(5, TimeUnit.SECONDS);
+        verify(lateClient).complete();
+        assertTrue(((MockHttpServletResponse) 
lateClient.getResponse()).getContentAsString().contains("RULE"));
+    }
+
+    private ScheduledExecutorService scheduler() {
+        return (ScheduledExecutorService) 
ReflectionTestUtils.getField(listener, "scheduler");
+    }
+
+    private void commit(final String id, final String namespace, final String 
name) {
+        transactions.executeWithoutResult(status -> {
+            jdbc.update("INSERT INTO test_rule VALUES (?, ?, ?)", id, 
namespace, name);
+            event(rule(id, namespace, name), DataEventTypeEnum.UPDATE);
+        });
+    }
+
+    private void event(final RuleData rule, final DataEventTypeEnum type) {
+        dispatcher.onApplicationEvent(new 
DataChangedEvent(ConfigGroupEnum.RULE, type, List.of(rule)));
+    }
+
+    private static RuleData rule(final String id, final String namespace, 
final String name) {
+        RuleData rule = new RuleData();
+        rule.setId(id);
+        rule.setNamespaceId(namespace);
+        rule.setName(name);
+        return rule;
+    }
+
+    private static final class TestListener extends 
HttpLongPollingDataChangedListener {
+
+        private final AtomicInteger serializations = new AtomicInteger();
+
+        private final List<DataEventTypeEnum> callbacks = 
Collections.synchronizedList(new ArrayList<>());
+
+        private TestListener(final HttpSyncProperties properties) {
+            super(properties);
+        }
+
+        @Override
+        protected <T> void updateCache(final ConfigGroupEnum group, final 
List<T> data, final String namespaceId) {
+            serializations.incrementAndGet();
+            super.updateCache(group, data, namespaceId);
+        }
+
+        @Override
+        protected void afterRuleChanged(final List<RuleData> changed, final 
DataEventTypeEnum eventType, final String namespaceId) {
+            callbacks.add(eventType);
+            super.afterRuleChanged(changed, eventType, namespaceId);
+        }
+
+        private ConfigDataCache cached(final String namespaceId) {
+            return CACHE.get(buildCacheKey(namespaceId, 
ConfigGroupEnum.RULE.name()));
+        }
+
+        private void seedCache(final ConfigGroupEnum group, final String 
namespaceId) {
+            String key = buildCacheKey(namespaceId, group.name());
+            CACHE.putIfAbsent(key, new ConfigDataCache(key, "[]", "seed", 0, 
namespaceId));
+        }
+
+        private void clearCache() {
+            CACHE.clear();
+        }
+    }
+}

Reply via email to