@@ -423,6 +423,122 @@ public void testWrongVersionKeyIndexFileResultsInCleanStartup() throws Exception
423423 assertDocCount (indexName + "-copy" , 50L );
424424 }
425425
426+ // ── Tiered-mode coverage ─────────────────────────────────────────────────
427+
428+ /**
429+ * Tiered mode (default): both the data tier and the metadata tier each persist
430+ * their own {@code key_index.json} on graceful warm-node restart.
431+ *
432+ * <p>With {@code block_cache.foyer.metadata_cache_ratio > 0} (5% by default) the
433+ * Foyer block cache splits into two independent instances under
434+ * {@code foyer-block-cache/data} and {@code foyer-block-cache/metadata}. Each
435+ * tier has its own Drop impl writing its own snapshot. This test guards against
436+ * a regression where one tier's persistence wiring is broken while the other
437+ * appears healthy — the previous helper would only see the data tier and miss
438+ * a metadata-tier failure entirely.
439+ */
440+ public void testTieredBothTiersWriteKeyIndexOnShutdown () throws Exception {
441+ final Client client = client ();
442+ final String indexName = "test-tiered-shutdown" ;
443+
444+ setupWarmNodeWithIndex (client , indexName );
445+ assertDocCount (indexName + "-copy" , 50L );
446+
447+ logger .info ("[tiered-shutdown-01] restarting warm node '{}'" , getWarmNodeName ());
448+ internalCluster ().restartNode (getWarmNodeName ());
449+ ensureGreen ();
450+
451+ Path dataDir = findFoyerCacheDir ();
452+ Path metaDir = findFoyerMetadataCacheDir ();
453+ assertNotNull ("data-tier cache directory must exist on warm node after restart" , dataDir );
454+ assertNotNull (
455+ "metadata-tier cache directory must exist on warm node after restart "
456+ + "(check block_cache.foyer.metadata_cache_ratio is > 0)" ,
457+ metaDir
458+ );
459+
460+ // Both tiers wrote a key_index.json.
461+ Path dataKeyIndex = dataDir .resolve (KEY_INDEX_FILENAME );
462+ Path metaKeyIndex = metaDir .resolve (KEY_INDEX_FILENAME );
463+ assertTrue ("data-tier key_index.json must exist after restart" , Files .exists (dataKeyIndex ));
464+ assertTrue ("metadata-tier key_index.json must exist after restart" , Files .exists (metaKeyIndex ));
465+
466+ // No leftover .tmp files on either tier.
467+ assertFalse (
468+ "data-tier .key_index.json.tmp must not exist after successful rename" ,
469+ Files .exists (dataDir .resolve (KEY_INDEX_TMP_FILENAME ))
470+ );
471+ assertFalse (
472+ "metadata-tier .key_index.json.tmp must not exist after successful rename" ,
473+ Files .exists (metaDir .resolve (KEY_INDEX_TMP_FILENAME ))
474+ );
475+
476+ // Both snapshots are valid JSON with version=1.
477+ String dataContent = Files .readString (dataKeyIndex );
478+ String metaContent = Files .readString (metaKeyIndex );
479+ assertFalse ("data-tier key_index.json must not be empty" , dataContent .isBlank ());
480+ assertFalse ("metadata-tier key_index.json must not be empty" , metaContent .isBlank ());
481+ assertTrue ("data-tier key_index.json must contain version:1" , dataContent .contains ("\" version\" :1" ));
482+ assertTrue ("metadata-tier key_index.json must contain version:1" , metaContent .contains ("\" version\" :1" ));
483+
484+ // Cluster healthy after restart.
485+ assertDocCount (indexName + "-copy" , 50L );
486+ }
487+
488+ /**
489+ * Tiered mode: the periodic persist task fires independently on both tiers
490+ * and writes {@code key_index.json} within the configured interval.
491+ *
492+ * <p>Each tier owns its own persist task, so this test catches the case where
493+ * one tier's task is starved or never spawned — symptoms that would otherwise
494+ * only surface on shutdown.
495+ */
496+ public void testTieredBothTiersPeriodicPersistFires () throws Exception {
497+ final Client client = client ();
498+ final String indexName = "test-tiered-periodic" ;
499+
500+ setupWarmNodeWithIndex (client , indexName );
501+ assertDocCount (indexName + "-copy" , 50L );
502+
503+ Path dataDir = findFoyerCacheDir ();
504+ Path metaDir = findFoyerMetadataCacheDir ();
505+ assertNotNull ("data-tier cache directory must exist on warm node" , dataDir );
506+ assertNotNull (
507+ "metadata-tier cache directory must exist on warm node " + "(check block_cache.foyer.metadata_cache_ratio is > 0)" ,
508+ metaDir
509+ );
510+
511+ logger .info ("[tiered-periodic-01] waiting up to 30s for periodic persist (interval=5s) on data and metadata tiers" );
512+
513+ // Persist interval is 5s (set in nodeSettings); allow up to 30s for both tiers.
514+ assertBusy (() -> {
515+ assertTrue (
516+ "data-tier key_index.json must be written by periodic persist task within interval" ,
517+ Files .exists (dataDir .resolve (KEY_INDEX_FILENAME ))
518+ );
519+ assertTrue (
520+ "metadata-tier key_index.json must be written by periodic persist task within interval" ,
521+ Files .exists (metaDir .resolve (KEY_INDEX_FILENAME ))
522+ );
523+ }, 30 , TimeUnit .SECONDS );
524+
525+ // No leftover .tmp files after successful rename on either tier.
526+ assertFalse (
527+ "data-tier .key_index.json.tmp must not exist after successful periodic persist" ,
528+ Files .exists (dataDir .resolve (KEY_INDEX_TMP_FILENAME ))
529+ );
530+ assertFalse (
531+ "metadata-tier .key_index.json.tmp must not exist after successful periodic persist" ,
532+ Files .exists (metaDir .resolve (KEY_INDEX_TMP_FILENAME ))
533+ );
534+
535+ // Both tiers' content is valid JSON with version=1.
536+ String dataContent = Files .readString (dataDir .resolve (KEY_INDEX_FILENAME ));
537+ String metaContent = Files .readString (metaDir .resolve (KEY_INDEX_FILENAME ));
538+ assertTrue ("data-tier key_index.json must contain version:1" , dataContent .contains ("\" version\" :1" ));
539+ assertTrue ("metadata-tier key_index.json must contain version:1" , metaContent .contains ("\" version\" :1" ));
540+ }
541+
426542 // ── Helper methods ────────────────────────────────────────────────────────
427543
428544 /**
@@ -520,7 +636,44 @@ private long getWarmNodeUsedBytes(Client client) {
520636 * different node ordinals. Fails the test with a descriptive message if the directory is
521637 * not found — this ensures file-level assertions are never silently skipped.
522638 */
639+ /**
640+ * Locates the Foyer block cache directory that holds {@code key_index.json}.
641+ *
642+ * <p>In tiered mode (default — {@code block_cache.foyer.metadata_cache_ratio > 0})
643+ * the layout is {@code foyer-block-cache/data/} and {@code foyer-block-cache/metadata/};
644+ * each subdir has its own {@code key_index.json}. This helper returns the data
645+ * subdir in that case. In single-cache mode the file lives directly under
646+ * {@code foyer-block-cache/}, which is what gets returned then.
647+ *
648+ * <p>Use {@link #findFoyerMetadataCacheDir()} for the metadata-tier subdir.
649+ */
523650 private Path findFoyerCacheDir () {
651+ Path root = findFoyerCacheRoot ();
652+ if (root == null ) {
653+ return null ;
654+ }
655+ Path dataSubdir = root .resolve ("data" );
656+ if (Files .isDirectory (dataSubdir )) {
657+ return dataSubdir ; // tiered mode
658+ }
659+ return root ; // single-cache mode
660+ }
661+
662+ /**
663+ * Returns the metadata-tier subdir ({@code foyer-block-cache/metadata}) when
664+ * the cache is in tiered mode, or {@code null} in single-cache mode.
665+ */
666+ private Path findFoyerMetadataCacheDir () {
667+ Path root = findFoyerCacheRoot ();
668+ if (root == null ) {
669+ return null ;
670+ }
671+ Path metaSubdir = root .resolve ("metadata" );
672+ return Files .isDirectory (metaSubdir ) ? metaSubdir : null ;
673+ }
674+
675+ /** Locates the {@code foyer-block-cache} parent directory on the warm node. */
676+ private Path findFoyerCacheRoot () {
524677 String warmName = getWarmNodeName ();
525678 assertNotNull ("A warm node must be present in the cluster" , warmName );
526679
0 commit comments