@@ -277,6 +277,160 @@ public void get_noError_lifeCycle() throws Exception {
277277 inOrder .verifyNoMoreInteractions ();
278278 }
279279
280+ @ Test
281+ public void asyncRefresh_sendsStaleHeaderData () throws Exception {
282+ setUpRlsLbClient ();
283+ RlsProtoData .RouteLookupRequestKey routeLookupRequestKey =
284+ RlsProtoData .RouteLookupRequestKey .create (
285+ ImmutableMap .of (
286+ "server" , "bigtable.googleapis.com" , "service-key" , "foo" , "method-key" , "bar" ));
287+ rlsServerImpl .setLookupTable (
288+ ImmutableMap .of (
289+ routeLookupRequestKey ,
290+ RouteLookupResponse .create (ImmutableList .of ("target" ), "stale-header-v1" )));
291+
292+ // Initial lookup: cache miss
293+ CachedRouteLookupResponse resp = getInSyncContext (routeLookupRequestKey );
294+ assertThat (resp .isPending ()).isTrue ();
295+
296+ // RLS server response arrives
297+ fakeClock .forwardTime (SERVER_LATENCY_MILLIS , TimeUnit .MILLISECONDS );
298+ resp = getInSyncContext (routeLookupRequestKey );
299+ assertThat (resp .hasData ()).isTrue ();
300+ assertThat (resp .getHeaderData ()).isEqualTo ("stale-header-v1" );
301+ assertThat (rlsServerImpl .routeLookupReason ).isEqualTo (
302+ io .grpc .lookup .v1 .RouteLookupRequest .Reason .REASON_MISS );
303+
304+ // Advance fake clock past staleAge
305+ fakeClock .forwardTime (ROUTE_LOOKUP_CONFIG .staleAgeInNanos (), TimeUnit .NANOSECONDS );
306+
307+ rlsServerImpl .routeLookupReason = null ;
308+ rlsServerImpl .routeLookupStaleHeaderData = null ;
309+
310+ // Lookup on stale entry: returns cached response immediately
311+ resp = getInSyncContext (routeLookupRequestKey );
312+ assertThat (resp .hasData ()).isTrue ();
313+ assertThat (resp .getHeaderData ()).isEqualTo ("stale-header-v1" );
314+
315+ // Async refresh finishes
316+ fakeClock .forwardTime (SERVER_LATENCY_MILLIS , TimeUnit .MILLISECONDS );
317+
318+ assertThat (rlsServerImpl .routeLookupReason ).isEqualTo (
319+ io .grpc .lookup .v1 .RouteLookupRequest .Reason .REASON_STALE );
320+ assertThat (rlsServerImpl .routeLookupStaleHeaderData ).isEqualTo ("stale-header-v1" );
321+ }
322+
323+ @ Test
324+ public void updatedHeaderData_replacesCachedHeaderData () throws Exception {
325+ setUpRlsLbClient ();
326+ RlsProtoData .RouteLookupRequestKey routeLookupRequestKey =
327+ RlsProtoData .RouteLookupRequestKey .create (
328+ ImmutableMap .of (
329+ "server" , "bigtable.googleapis.com" , "service-key" , "foo" , "method-key" , "bar" ));
330+ rlsServerImpl .setLookupTable (
331+ ImmutableMap .of (
332+ routeLookupRequestKey ,
333+ RouteLookupResponse .create (ImmutableList .of ("target" ), "stale-header-v1" )));
334+
335+ // Initial lookup
336+ getInSyncContext (routeLookupRequestKey );
337+ fakeClock .forwardTime (SERVER_LATENCY_MILLIS , TimeUnit .MILLISECONDS );
338+
339+ // RLS server updated to return new header data
340+ rlsServerImpl .setLookupTable (
341+ ImmutableMap .of (
342+ routeLookupRequestKey ,
343+ RouteLookupResponse .create (ImmutableList .of ("target" ), "updated-header-v2" )));
344+
345+ // Advance fake clock past staleAge
346+ fakeClock .forwardTime (ROUTE_LOOKUP_CONFIG .staleAgeInNanos (), TimeUnit .NANOSECONDS );
347+
348+ // Stale lookup triggers background refresh
349+ getInSyncContext (routeLookupRequestKey );
350+ fakeClock .forwardTime (SERVER_LATENCY_MILLIS , TimeUnit .MILLISECONDS );
351+
352+ // Verify cache entry is updated with new header data
353+ CachedRouteLookupResponse resp = getInSyncContext (routeLookupRequestKey );
354+ assertThat (resp .getHeaderData ()).isEqualTo ("updated-header-v2" );
355+
356+ // Advance past staleAge again
357+ fakeClock .forwardTime (ROUTE_LOOKUP_CONFIG .staleAgeInNanos (), TimeUnit .NANOSECONDS );
358+ rlsServerImpl .routeLookupStaleHeaderData = null ;
359+
360+ // Next stale refresh sends updated stale_header_data
361+ getInSyncContext (routeLookupRequestKey );
362+ fakeClock .forwardTime (SERVER_LATENCY_MILLIS , TimeUnit .MILLISECONDS );
363+
364+ assertThat (rlsServerImpl .routeLookupStaleHeaderData ).isEqualTo ("updated-header-v2" );
365+ }
366+
367+ @ Test
368+ public void expiredEntry_cacheMiss_clearsStaleHeaderData () throws Exception {
369+ setUpRlsLbClient ();
370+ RlsProtoData .RouteLookupRequestKey routeLookupRequestKey =
371+ RlsProtoData .RouteLookupRequestKey .create (
372+ ImmutableMap .of (
373+ "server" , "bigtable.googleapis.com" , "service-key" , "foo" , "method-key" , "bar" ));
374+ rlsServerImpl .setLookupTable (
375+ ImmutableMap .of (
376+ routeLookupRequestKey ,
377+ RouteLookupResponse .create (ImmutableList .of ("target" ), "stale-header-v1" )));
378+
379+ // Initial lookup
380+ getInSyncContext (routeLookupRequestKey );
381+ fakeClock .forwardTime (SERVER_LATENCY_MILLIS , TimeUnit .MILLISECONDS );
382+
383+ // Advance fake clock past maxAge (expiration)
384+ fakeClock .forwardTime (ROUTE_LOOKUP_CONFIG .maxAgeInNanos (), TimeUnit .NANOSECONDS );
385+
386+ rlsServerImpl .routeLookupReason = null ;
387+ rlsServerImpl .routeLookupStaleHeaderData = null ;
388+
389+ // Expired entry triggers cache miss
390+ CachedRouteLookupResponse resp = getInSyncContext (routeLookupRequestKey );
391+ assertThat (resp .isPending ()).isTrue ();
392+
393+ fakeClock .forwardTime (SERVER_LATENCY_MILLIS , TimeUnit .MILLISECONDS );
394+
395+ assertThat (rlsServerImpl .routeLookupReason ).isEqualTo (
396+ io .grpc .lookup .v1 .RouteLookupRequest .Reason .REASON_MISS );
397+ assertThat (rlsServerImpl .routeLookupStaleHeaderData ).isEmpty ();
398+ }
399+
400+ @ Test
401+ public void rlsPicker_attachesHeaderDataToPickedRpcs () throws Exception {
402+ setUpRlsLbClient ();
403+ RlsProtoData .RouteLookupRequestKey routeLookupRequestKey =
404+ RlsProtoData .RouteLookupRequestKey .create (
405+ ImmutableMap .of (
406+ "server" , "bigtable.googleapis.com" , "service-key" , "service1" ,
407+ "method-key" , "create" ));
408+ rlsServerImpl .setLookupTable (
409+ ImmutableMap .of (
410+ routeLookupRequestKey ,
411+ RouteLookupResponse .create (
412+ ImmutableList .of ("primary.cloudbigtable.googleapis.com" ),
413+ "header-rls-data-value" )));
414+
415+ // Populate cache and wait for server response
416+ getInSyncContext (routeLookupRequestKey );
417+ fakeClock .forwardTime (SERVER_LATENCY_MILLIS , TimeUnit .MILLISECONDS );
418+
419+ ArgumentCaptor <SubchannelPicker > pickerCaptor =
420+ ArgumentCaptor .forClass (SubchannelPicker .class );
421+ verify (helper , times (3 ))
422+ .updateBalancingState (any (ConnectivityState .class ), pickerCaptor .capture ());
423+
424+ Metadata headers = new Metadata ();
425+ headers .put (RLS_DATA_KEY , "old-header-data" );
426+
427+ PickResult pickResult = getPickResultForCreate (pickerCaptor , headers );
428+
429+ assertThat (pickResult .getStatus ().isOk ()).isTrue ();
430+ assertThat (headers .get (RLS_DATA_KEY )).isEqualTo ("header-rls-data-value" );
431+ }
432+
433+
280434 @ Test
281435 public void rls_withCustomRlsChannelServiceConfig () throws Exception {
282436 Map <String , ?> routeLookupChannelServiceConfig =
@@ -1120,6 +1274,7 @@ private static final class StaticFixedDelayRlsServerImpl
11201274 private Map <RlsProtoData .RouteLookupRequestKey , RouteLookupResponse > lookupTable =
11211275 ImmutableMap .of ();
11221276 io .grpc .lookup .v1 .RouteLookupRequest .Reason routeLookupReason ;
1277+ String routeLookupStaleHeaderData ;
11231278
11241279 public StaticFixedDelayRlsServerImpl (
11251280 long responseDelayNano , ScheduledExecutorService scheduledExecutorService ) {
@@ -1143,6 +1298,7 @@ public void routeLookup(final io.grpc.lookup.v1.RouteLookupRequest request,
11431298 @ Override
11441299 public void run () {
11451300 routeLookupReason = request .getReason ();
1301+ routeLookupStaleHeaderData = request .getStaleHeaderData ();
11461302 RouteLookupResponse response =
11471303 lookupTable .get (
11481304 RlsProtoData .RouteLookupRequestKey .create (
0 commit comments