SeekquarrySeekquarry - Yioop Repo - Seekquarry

/ tests / LSMTreeTest.php⤓
<?php
/**
 * SeekQuarry/Yioop --
 * Open Source Pure PHP Search Engine, Crawler, and Indexer
 *
 * Copyright (C) 2009 - 2026  Chris Pollett chris@pollett.org
 *
 * LICENSE:
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 * END LICENSE
 *
 * This file contains unit tests of the LSMTree class used to keep track of
 *
 * @author Chris Pollett chris@pollett.org
 * @license https://www.gnu.org/licenses/ GPL3
 * @link https://www.seekquarry.com/
 * @copyright 2009 - 2026
 * @filesource
 */
namespace seekquarry\yioop\tests;

use seekquarry\yioop\configs as C;
use seekquarry\yioop\library as L;
use seekquarry\yioop\models\Model;
use seekquarry\yioop\library\data_structures\LSMTree;
use seekquarry\yioop\library\data_structures\PackedTableTools;
use seekquarry\yioop\library\data_structures\UndoLog;
use seekquarry\yioop\library\UnitTest;

/**
 * Yioop LSMTree Unit Class
 *
 * Used to test insert, lookup, and deletion of key-value pairs in a
 * Log-structured Merge Tree.
 * @author Chris Pollett
 */
class LSMTreeTest extends UnitTest
{
    /**
     * Test directory to hold LSMTree used for these unit tests
     */
    const TEST_DIR = '/test_files/lsm_tree_test';
    /**
     * Folder names to use for test LSMTree
     * @var array
     */
    public $table_dirs;
    /**
     * Minimum degree is set to 2 and the number of key-value pairs is set to 25
     */
    public function setUp()
    {
        $this->table_dirs = [];
    }
    /**
     * Delete the LSMTree files created during the test
     */
    public function tearDown()
    {
        $model = new Model();
        foreach ($this->table_dirs as $table_dir) {
            $model->db->unlinkRecursive($table_dir, true);
        }
        $this->table_dirs = [];
    }
    /**
     * Creates a single LSM tree under a per-parameter directory
     * derived from key size, file size, block factor, and suffix.
     * Each call tracks the directory so tearDown can clean it up.
     *
     * @param array $format column definitions for the tree
     * @param int $max_file_size maximum on-disk size of any single
     *      file in the tree
     * @param int $block_factor number of records per disk block
     * @param string $suffix optional suffix appended to the test
     *      directory name to disambiguate concurrent test trees
     * @return LSMTree freshly-constructed tree backed by the
     *      per-test directory
     */
    public function createTree($format, $max_file_size, $block_factor,
        $suffix = "")
    {
        $key_size = $format["PRIMARY KEY"][1];
        $table_dir = __DIR__ . self::TEST_DIR .
            "_{$key_size}_{$max_file_size}_{$block_factor}_$suffix";
        $this->table_dirs[] = $table_dir;
        return new LSMTree($table_dir, $format, $max_file_size,
            $block_factor);
    }
    /**
     * NUM_SIMPLE_ENTRIES is how many entries simplePutGetTierTestCase puts
     * into each tree. Each entry is longer than either file size the case
     * tries, so every tree spreads its entries over many files.
     */
    const NUM_SIMPLE_ENTRIES = 12;
    /**
     * SIMPLE_TREE_SETTINGS lists the trees simplePutGetTierTestCase
     * makes, each a file size in bytes, a block factor, a key length and
     * a number of extra columns. Each value of each setting appears in at
     * least one tree, rather than every combination of them.
     */
    const SIMPLE_TREE_SETTINGS = [[20, 1, 16, 0], [50, 2, 8, 1],
        [20, 2, 8, 2], [50, 1, 16, 2]];
    /**
     * simplePutGetTierTestCase puts NUM_SIMPLE_ENTRIES entries into each
     * tree SIMPLE_TREE_SETTINGS lists, flushes the tree into one tier,
     * and checks that every entry can be read back.
     */
    public function simplePutGetTierTestCase()
    {
        foreach (self::SIMPLE_TREE_SETTINGS as
            [$max_file_size, $block_factor, $key_len, $cols]) {
            $format = ["PRIMARY KEY" => ["KEY", $key_len],
                "VALUE" => "TEXT"];
            for ($j = 0; $j < $cols; $j++) {
                $format["COL$j"] = "INT";
            }
            $lsm_tree = $this->createTree($format, $max_file_size,
                $block_factor, "{$key_len}_$cols");
            for ($i = 0; $i < self::NUM_SIMPLE_ENTRIES; $i++) {
                $key = str_pad("key$i", $key_len, "0", STR_PAD_LEFT);
                $entry = ["KEY" => $key, "VALUE" => "value$i"];
                for ($j = 0; $j < $cols; $j++) {
                    $entry["COL$j"] = $i;
                }
                $lsm_tree->put($entry);
            }
            $lsm_tree->flush();
            for ($i = 0; $i < self::NUM_SIMPLE_ENTRIES; $i++) {
                $key = str_pad("key$i", $key_len, "0", STR_PAD_LEFT);
                $values = $lsm_tree->get($key);
                $this->assertEqual("value$i", $values[0]["VALUE"],
                    "{$i}th insert key length $key_len, $cols extra " .
                    "columns, into a single tier is read back");
            }
        }
    }
    /**
     * Checks that the correct number of block folders are created when
     * inserting several items into am LSMTree with a small block factor
     * and max file size
     */
    public function blockFactorTestCase()
    {
        $max_file_size = 20;
        $block_factor = 2;
        $key_len = 16;
        $cols = 2;
        $format = ["PRIMARY KEY" => ["KEY", $key_len],
            "VALUE" => "TEXT"];
        $lsm_tree = $this->createTree($format, $max_file_size,
            $block_factor, "{$key_len}");
        for ($i = 0; $i < 15; $i++) {
            $key = str_pad("key$i", $key_len, "0",
                STR_PAD_LEFT);
            $entry = ["KEY" => $key, "VALUE" => "value$i"];
            $lsm_tree->put($entry);
        }
        $this->assertTrue(file_exists($lsm_tree->folder .
            "/Tier0000/A/F00000000000key12") && !file_exists($lsm_tree->folder .
                "/Tier0000/A/F00000000000key14"),
            "Correct number of block folders created");
    }
    /**
     * Checks that the correct number of data files are created when
     * inserting several items into am LSMTree with a small max file size
     */
    public function maxFileSizeTestCase()
    {
        $max_file_size = 50;
        $block_factor = 10000;
        $key_len = 16;
        $cols = 2;
        $format = ["PRIMARY KEY" => ["KEY", $key_len],
            "VALUE" => "TEXT"];
        $lsm_tree = $this->createTree($format, $max_file_size,
            $block_factor, "{$key_len}");
        for ($i = 0; $i < 15; $i++) {
            $key = str_pad("key$i", $key_len, "0",
                STR_PAD_LEFT);
            $entry = ["KEY" => $key, "VALUE" => "value$i"];
            $lsm_tree->put($entry);
        }
        $block_folder = $lsm_tree->folder . "/Tier0000/A/F000000000000key0";
        $this->assertTrue(file_exists("$block_folder/D00000000000key13") &&
            !file_exists("$block_folder/D00000000000key14"),
            "Correct number of data files created");
    }
    /**
     * Tests that values for identical keys are correctly merged when merge
     * two slots within a tier.
     */
    public function mergeRecordsTierTestCase()
    {
        $max_file_size = 50;
        $block_factor = 2;
        $key_len = 16;
        $cols = 2;
        $format = ["PRIMARY KEY" => ["KEY", $key_len],
            "VALUE" => "TEXT"];
        for ($j = 0; $j < $cols; $j++) {
            $format["COL$j"] = "INT";
        }
        $lsm_tree = $this->createTree($format, $max_file_size,
            $block_factor, "{$key_len}_$cols");
        foreach ([["A", 0, 10], ["B", 5, 15]] as $slot_info) {
            $lsm_tree->selectPutSlot($slot_info[0]);
            for ($i = $slot_info[1]; $i < $slot_info[2]; $i++) {
                $key = str_pad("key$i", $key_len, "0",
                    STR_PAD_LEFT);
                $entry = ["KEY" => $key, "VALUE" => "value{$slot_info[0]}$i"];
                for ($j = 0; $j < $cols; $j++) {
                    $entry["COL$j"] = $i;
                }
                $lsm_tree->put($entry);
            }
            $lsm_tree->flush();
        }
        $lsm_tree->mergeTiers();
        $key = str_pad("key10", $key_len, "0", STR_PAD_LEFT);
        $values = $lsm_tree->get($key);
        for ($i = 0; $i < 15; $i++) {
            $key = str_pad("key$i", $key_len, "0", STR_PAD_LEFT);
            $values = $lsm_tree->get($key);
            if ($i < 10) {
                $this->assertEqual("valueA$i", $values[0]["VALUE"],
                    "Able to retrieve Slot A value $i");
            }
            if ($i > 4 && $i < 10) {
                $this->assertEqual("valueB$i", $values[1]["VALUE"],
                    "Able to retrieve Slot B value $i");
            }
            if ($i >= 10) {
                $this->assertEqual("valueB$i", $values[0]["VALUE"],
                    "Able to retrieve Slot B value $i");
            }
        }
    }
    /**
     * NUM_MERGE_ENTRIES is how many entries mergeTiersTestCase puts into
     * its tree, ten for each merge.
     */
    const NUM_MERGE_ENTRIES = 30;
    /**
     * mergeTiersTestCase checks that tiers are made and merged correctly
     * when NUM_MERGE_ENTRIES entries go into a tree with a small block
     * factor and file size, merging after every ten. The tiers left
     * holding data follow the number of merges in binary, and each value
     * can be read back with its key.
     */
    public function mergeTiersTestCase()
    {
        $max_file_size = 50;
        $block_factor = 2;
        $key_len = 16;
        $cols = 3;
        $format = ["PRIMARY KEY" => ["KEY", $key_len],
            "VALUE" => "TEXT"];
        for ($j = 0; $j < $cols; $j++) {
            $format["COL$j"] = "INT";
        }
        $lsm_tree = $this->createTree($format, $max_file_size,
            $block_factor, "{$key_len}_$cols");
        $slot = "B";
        for ($i = 0; $i < self::NUM_MERGE_ENTRIES; $i++) {
            if ($i % 5 == 0) {
                $slot = ($slot == "B") ? "A" : "B";
                $lsm_tree->flush();
                $lsm_tree->selectPutSlot($slot);
            }
            if ($i > 0 && $i % 10 == 0) {
                $lsm_tree->mergeTiers();
            }
            $k = 10 * floor($i / 10);
            $k += ($slot == "A") ? 2 * ($i % 10) : 2 * (($i % 10) - 5) + 1;
            $key = str_pad("key$k", $key_len, "0", STR_PAD_LEFT);
            $entry = ["KEY" => $key, "VALUE" => "value$k"];
            for ($j = 0; $j < $cols; $j++) {
                $entry["COL$j"] = $k;
            }
            $lsm_tree->put($entry);
        }
        $lsm_tree->flush();
        $lsm_tree->mergeTiers();
        /* Each merge after ten entries adds one more tier-0 group, and
           merging carries like adding one in binary, so tier t holds data
           when bit t - 1 of the number of groups is set. */
        $groups = intdiv(self::NUM_MERGE_ENTRIES, 10);
        $this->assertEqual($lsm_tree->getMaxTier(),
            strlen(decbin($groups)), "Final maxTiers is correct");
        for ($i = 0; $i <= strlen(decbin($groups)); $i++) {
            $occupied = ($i > 0) && (($groups >> ($i - 1)) & 1) == 1;
            $this->assertEqual($lsm_tree->occupiedTier($i), $occupied,
                "{$i} th tier correctly occupied");
        }
        for ($i = 0; $i < self::NUM_MERGE_ENTRIES; $i++) {
            $key = str_pad("key$i", $key_len, "0", STR_PAD_LEFT);
            $values = $lsm_tree->get($key);
            $this->assertEqual("value$i", $values[0]["VALUE"],
                "Able to retrieve {$i}th value");
        }
    }
    /**
     * mergedTierIsNotCheckpointedTestCase checks that a tier merge leaves
     * no checkpoint of the tier files in the tree's folder, and that a
     * checkpoint an earlier version left is dropped when the tree is
     * opened for writing. The checkpoint listed every tier file, and each
     * open read the size of every one, which took up to 28 seconds on a
     * large crawl's dictionary.
     */
    public function mergedTierIsNotCheckpointedTestCase()
    {
        $format = ["PRIMARY KEY" => ["KEY", 16], "VALUE" => "TEXT"];
        $lsm_tree = $this->createTree($format, 50, 2, "checkpoint");
        foreach (["A", "B"] as $slot) {
            $lsm_tree->selectPutSlot($slot);
            for ($i = 0; $i < 6; $i++) {
                $key = str_pad("key$slot$i", 16, "0", STR_PAD_LEFT);
                $lsm_tree->put(["KEY" => $key, "VALUE" => "$slot$i"]);
            }
            $lsm_tree->flush();
        }
        $lsm_tree->mergeTiers();
        $checkpoint = $lsm_tree->folder . "/" . UndoLog::CHECKPOINT_NAME;
        $this->assertFalse(file_exists($checkpoint),
            "a merge records no checkpoint of the tier files");
        file_put_contents($checkpoint, $lsm_tree->folder . "/gone\t5\n");
        $reopened = new LSMTree($lsm_tree->folder, $format, 50, 2);
        $this->assertFalse(file_exists($checkpoint),
            "an open for writing drops an old checkpoint");
        $key = str_pad("keyB3", 16, "0", STR_PAD_LEFT);
        $this->assertEqual($reopened->get($key)[0]["VALUE"] ?? "", "B3",
            "the merged rows read back");
    }
    /**
     * mergedTierIsDeletedLaterTestCase checks that mergeTier moves the
     * merged tier aside rather than deleting it while the merge waits,
     * that lookups still find every row, and that removeAsideTiers then
     * deletes the moved folder, a call with no time left deleting only
     * part of it.
     */
    public function mergedTierIsDeletedLaterTestCase()
    {
        $format = ["PRIMARY KEY" => ["KEY", 16], "VALUE" => "TEXT"];
        $lsm_tree = $this->createTree($format, 50, 2, "aside");
        foreach (["A", "B"] as $slot) {
            $lsm_tree->selectPutSlot($slot);
            for ($i = 0; $i < 20; $i++) {
                $key = str_pad("key$slot$i", 16, "0", STR_PAD_LEFT);
                $lsm_tree->put(["KEY" => $key, "VALUE" => "$slot$i"]);
            }
            $lsm_tree->flush();
        }
        $lsm_tree->mergeTier(0);
        $aside = glob($lsm_tree->folder . "/" . LSMTree::REMOVING_PREFIX .
            "*", GLOB_ONLYDIR);
        $this->assertEqual(count($aside), 1,
            "the merged tier waits aside after the merge");
        $key = str_pad("keyA7", 16, "0", STR_PAD_LEFT);
        $this->assertEqual($lsm_tree->get($key)[0]["VALUE"] ?? "", "A7",
            "a lookup finds a row of the merged tier");
        $this->assertTrue($lsm_tree->removeAsideTiers(0),
            "a call with no time left leaves some of it");
        $this->assertTrue(file_exists($aside[0]),
            "the folder is still there after that call");
        $this->assertFalse($lsm_tree->removeAsideTiers(60),
            "a call with time to spare deletes the rest");
        $this->assertFalse(file_exists($aside[0]),
            "the moved folder is gone");
    }
    /**
     * mergeKeepsKeysWithEncodedBytesTestCase checks a tier merge of keys
     * holding the bytes 254 and 255, which tier files store encoded.
     * mergeTier copies a row as stored and decodes only its key, so the
     * case checks every key is found after the merge with its value, and
     * a key both tiers hold with both its rows.
     */
    public function mergeKeepsKeysWithEncodedBytesTestCase()
    {
        $format = ["PRIMARY KEY" => ["KEY", 16], "VALUE" => "TEXT"];
        $lsm_tree = $this->createTree($format, 200, 4, "encoded");
        $keys = [];
        for ($i = 0; $i < 40; $i++) {
            $keys[] = str_pad(chr(250 + $i % 6) . "\xFF\xFE" . chr($i),
                16, ($i % 2) ? "\xFF" : "\xFE");
        }
        sort($keys, SORT_STRING);
        foreach (["A" => [0, 25], "B" => [15, 40]] as $slot => $span) {
            $lsm_tree->selectPutSlot($slot);
            for ($i = $span[0]; $i < $span[1]; $i++) {
                $lsm_tree->put(["KEY" => $keys[$i], "VALUE" => "$slot$i"]);
            }
            $lsm_tree->flush();
        }
        $lsm_tree->mergeTier(0);
        $found = 0;
        $both = 0;
        foreach ($keys as $i => $key) {
            $values = array_column($lsm_tree->get($key), "VALUE");
            $found += (in_array("A$i", $values) || in_array("B$i", $values))
                ? 1 : 0;
            $both += (in_array("A$i", $values) && in_array("B$i", $values))
                ? 1 : 0;
        }
        $this->assertEqual($found, 40, "every key is found after the merge");
        $this->assertEqual($both, 10,
            "the ten keys both tiers held keep both rows");
    }
    /**
     * spreadMergeTakesTierZeroAndOneMoreTestCase fills both slots of tiers
     * 0 and 3 and one slot of tier 1. mergeTiersSpread merges tier 0 into
     * tier 1, then the lowest full tier, tier 1, into tier 2, and leaves
     * full tier 3 for a later call, where mergeTiers would merge it too.
     * Every row is still found.
     */
    public function spreadMergeTakesTierZeroAndOneMoreTestCase()
    {
        $format = ["PRIMARY KEY" => ["KEY", 16], "VALUE" => "TEXT"];
        $lsm_tree = $this->createTree($format, 50, 2, "spread");
        $slots = [3 => ["A", "B"], 1 => ["A"], 0 => ["A", "B"]];
        $keys = [];
        foreach ($slots as $tier => $tier_slots) {
            $folder = $lsm_tree->getTierFolder($tier);
            foreach ($tier_slots as $slot) {
                $lsm_tree->selectPutSlot($slot);
                for ($i = 0; $i < 3; $i++) {
                    $key = str_pad("key$tier$slot$i", 16, "0",
                        STR_PAD_LEFT);
                    $lsm_tree->put(["KEY" => $key, "VALUE" => $key]);
                    $keys[] = $key;
                }
                $lsm_tree->flush();
                if ($tier > 0) {
                    @mkdir($folder, 0777, true);
                    rename($lsm_tree->getTierFolder(0) . "/$slot",
                        "$folder/$slot");
                }
            }
        }
        $lsm_tree->getMaxTier(true);
        $lsm_tree->mergeTiersSpread();
        $this->assertFalse($lsm_tree->tierIsFull(0), "tier 0 is merged");
        $this->assertFalse($lsm_tree->tierIsFull(1),
            "tier 1, full after taking tier 0, is merged");
        $this->assertTrue($lsm_tree->tierIsFull(3),
            "full tier 3 waits for a later call");
        $found = 0;
        foreach ($keys as $key) {
            $found += empty($lsm_tree->get($key)) ? 0 : 1;
        }
        $this->assertEqual($found, count($keys), "every row is found");
    }
    /**
     * walkCrossedByMergeIsDoneAgainTestCase checks that a lookup whose
     * walk of the tiers a merge's renames fell inside walks again. The
     * first walk here sees the merge count rise by two, as a whole merge
     * makes it, and gets a key's row twice, as a walk between the renames
     * can; the lookup gives the row once.
     */
    public function walkCrossedByMergeIsDoneAgainTestCase()
    {
        $format = ["PRIMARY KEY" => ["KEY", 16], "VALUE" => "TEXT"];
        $plain = $this->createTree($format, 50, 2, "crossed");
        $key = str_pad("key1", 16, "0", STR_PAD_LEFT);
        $plain->put(["KEY" => $key, "VALUE" => "one"]);
        $plain->flush();
        $lsm_tree = new CrossedWalkTree($plain->folder, $format, 50, 2);
        $values = array_column($lsm_tree->get($key), "VALUE");
        $this->assertEqual($values, ["one"], "the row is given once");
        $this->assertEqual($lsm_tree->walks, 2, "the tiers are walked again");
    }
    /**
     * Checks a lookup whose file has gone for good gives up quietly,
     * returning no rows and logging nothing. The failed read used to
     * warn on every lookup, filling a live error log.
     */
    public function keyWhoseFileHasGoneIsQuietTestCase()
    {
        $format = ["PRIMARY KEY" => ["KEY", 16], "VALUE" => "TEXT"];
        $lsm_tree = $this->createTree($format, 20, 1, "gone");
        for ($i = 0; $i < 30; $i++) {
            $key = str_pad("key$i", 16, "0", STR_PAD_LEFT);
            $lsm_tree->put(["KEY" => $key, "VALUE" => "value$i"]);
        }
        $lsm_tree->flush();
        $key = str_pad("key7", 16, "0", STR_PAD_LEFT);
        $this->assertTrue(!empty($lsm_tree->get($key)),
            "the key is found while its file is there");
        $data_files = [];
        foreach (glob($lsm_tree->folder . "/*/A/*/" .
            LSMTree::DATA_FILE_PREFIX . "*") as $data_file) {
            $data_files[] = $data_file;
        }
        $this->assertTrue(!empty($data_files),
            "the tree has data files to take away");
        foreach ($data_files as $data_file) {
            unlink($data_file);
        }
        $warnings = 0;
        set_error_handler(function ($number) use (&$warnings) {
            /* a handler sees suppressed diagnostics too, so count only
               what the error log would have been given */
            if (error_reporting() & $number) {
                $warnings++;
            }
            return true;
        });
        $rows = $lsm_tree->get($key);
        restore_error_handler();
        $this->assertEqual([], $rows,
            "a key whose file has gone comes back as no rows");
        $this->assertEqual(0, $warnings,
            "and puts no warning in the error log while doing so");
    }
    /**
     * Checks a key is still found after another process has replaced
     * the file it sits in. A merge elsewhere writes new data files and
     * a new list naming them; this process's cached list still names
     * the old file, so every lookup used to fail three times over and
     * give up.
     */
    public function keyFoundAfterAnotherProcessMovesItsFileTestCase()
    {
        $format = ["PRIMARY KEY" => ["KEY", 16], "VALUE" => "TEXT"];
        $lsm_tree = $this->createTree($format, 20, 1, "moved");
        $key = str_pad("key7", 16, "0", STR_PAD_LEFT);
        for ($i = 0; $i < 30; $i++) {
            $this_key = str_pad("key$i", 16, "0", STR_PAD_LEFT);
            $lsm_tree->put(["KEY" => $this_key, "VALUE" => "value$i"]);
        }
        $lsm_tree->flush();
        $rows = $lsm_tree->get($key);
        $this->assertEqual("value7", $rows[0]["VALUE"],
            "the key is found, and the file lists are now cached");
        /* do on disk what a merge in another process does: each data
           file gets a new name, still sorting at or below the keys it
           holds, and the list naming it is rewritten */
        $lists = glob($lsm_tree->folder . "/*/A/F*/" . LSMTree::INDEX_FILE);
        $this->assertTrue(!empty($lists), "there are file lists to rewrite");
        foreach ($lists as $list) {
            $folder = dirname($list);
            $names = array_filter(explode("\n", file_get_contents($list)));
            $renamed = [];
            foreach ($names as $name) {
                $new_name = str_replace("key", "jey", $name);
                rename("$folder/$name", "$folder/$new_name");
                $renamed[] = $new_name;
            }
            file_put_contents($list, implode("\n", $renamed));
        }
        $rows = $lsm_tree->get($key);
        $this->assertEqual("value7", $rows[0]["VALUE"] ?? "",
            "and is found again though its file has moved");
    }
    /**
     * A merge empties a tier and later writes a tier of the same name
     * with other rows. The record lists of the first tier were cached
     * by file name, so the merge that came later read the first tier's
     * rows in place of the new ones and every key came back with its
     * first two partitions repeated. The tree is driven here the way the
     * index bundle drives it: one slot per partition, a merge after
     * each, so that Tier 1 is emptied and written again.
     */
    public function tierRewrittenUnderSameNameMergesNewRowsTestCase()
    {
        $format = ["PRIMARY KEY" => ["TERM", 16], "PARTITION" => "INT"];
        $lsm_tree = $this->createTree($format, 100000, 2, "rewrite");
        $key = str_pad("00000002", 16, "_");
        $partitions = 6;
        for ($partition = 0; $partition < $partitions; $partition++) {
            $lsm_tree->selectPutSlot(file_exists($lsm_tree->folder .
                "/Tier0000/A") ? "B" : "A");
            for ($term = 1; $term <= 3; $term++) {
                $lsm_tree->put(["TERM" => str_pad(sprintf("%08d", $term),
                    16, "_"), "PARTITION" => $partition]);
            }
            $lsm_tree->flush();
            $lsm_tree->mergeTiers();
        }
        $seen = array_map('intval', array_column($lsm_tree->get($key),
            "PARTITION"));
        sort($seen);
        $this->assertEqual(range(0, $partitions - 1), $seen,
            "Every partition's row survives merges that rewrite a tier");
    }

    /**
     * A dictionary buffer can hold two rows for one term, since a term
     * that is on pages of two partitions is put once for each. Written
     * as two records with the same key, a read finds one of them and the
     * other row is lost, so a term answers for fewer partitions than it
     * was given. The two rows are merged in the buffer instead, and this
     * case reads the key back and asks for both.
     */
    public function twoRowsForOneKeyInABufferBothSurviveTestCase()
    {
        $format = ["PRIMARY KEY" => ["TERM", 16], "PARTITION" => "INT",
            "NUM_DOCS" => "INT"];
        $lsm_tree = $this->createTree($format, 100000, 2, "two_rows");
        $key = str_pad("00000002", 16, "_");
        $lsm_tree->put(["TERM" => $key, "PARTITION" => 0,
            "NUM_DOCS" => 5]);
        $lsm_tree->put(["TERM" => $key, "PARTITION" => 1,
            "NUM_DOCS" => 6]);
        $lsm_tree->flush();
        $rows = $lsm_tree->get($key);
        $seen = array_map('intval', array_column($rows, "PARTITION"));
        sort($seen);
        $this->assertEqual([0, 1], $seen,
            "Both rows put for one key in a buffer are read back");
        $docs = array_map('intval', array_column($rows, "NUM_DOCS"));
        sort($docs);
        $this->assertEqual([5, 6], $docs,
            "Each row keeps the values it was put with");
    }
    /**
     * A data file is named for the first key it holds, and the lowest
     * term of a crawl's dictionary starts nearly every partition's list,
     * so two flushes can name the same file. Writing over the file drops
     * every row already in it, which is a term answering for the last
     * partitions only. This case flushes twice into one slot without a
     * merge between, which is what a run interrupted mid-merge leaves,
     * and asks for every partition back.
     */
    public function flushOverAFileOfTheSameNameKeepsItsRowsTestCase()
    {
        $format = ["PRIMARY KEY" => ["TERM", 16], "PARTITION" => "INT",
            "NUM_DOCS" => "INT"];
        $lsm_tree = $this->createTree($format, 100000, 2, "same_file");
        $partitions = 12;
        for ($partition = 0; $partition < $partitions; $partition++) {
            $lsm_tree->selectPutSlot(file_exists($lsm_tree->folder .
                "/Tier0000/A") ? "B" : "A");
            for ($term = 1; $term <= 3; $term++) {
                $lsm_tree->put(["TERM" => str_pad(sprintf("%08d", $term),
                    16, "_"), "PARTITION" => $partition,
                    "NUM_DOCS" => $partition]);
            }
            $lsm_tree->flush();
            if ($partition % 3 == 2) {
                $lsm_tree->mergeTiers();
            }
        }
        $lsm_tree->mergeTiers();
        for ($term = 1; $term <= 3; $term++) {
            $key = str_pad(sprintf("%08d", $term), 16, "_");
            $seen = array_map('intval', array_column($lsm_tree->get($key),
                "PARTITION"));
            sort($seen);
            $this->assertEqual(range(0, $partitions - 1), $seen,
                "Term $term answers for every partition it was put for");
        }
    }
    /**
     * keysWhoseNamesSortOutOfKeyOrderAreFoundTestCase checks that every key
     * can be looked up after a merge when the URL-encoded names of the
     * files holding the keys sort in another order than the keys. Here =
     * is written as %3D, which sorts before - and the digits, and a key's
     * \xFE and \xFF bytes are escaped where the key is stored.
     */
    public function keysWhoseNamesSortOutOfKeyOrderAreFoundTestCase()
    {
        $format = ["PRIMARY KEY" => ["KEY", 16], "VALUE" => "TEXT"];
        $lsm_tree = $this->createTree($format, 50, 2, "key_order");
        $keys = [];
        foreach (["ab", "ab=", "ab-", "abc"] as $start) {
            foreach (["-", ".", "0", "=", "c"] as $letter) {
                $keys[] = str_pad("$start{$letter}x", 16, "_");
            }
        }
        $keys[] = str_pad("ab\xFEx", 16, "\xFF");
        $keys[] = str_pad("ab\xFFx", 16, "\xFE");
        sort($keys, SORT_STRING);
        foreach (["A", "B"] as $at => $slot) {
            $lsm_tree->selectPutSlot($slot);
            foreach ($keys as $i => $key) {
                if ($i % 2 == $at) {
                    $lsm_tree->put(["KEY" => $key, "VALUE" => "value$i"]);
                }
            }
            $lsm_tree->flush();
        }
        $lsm_tree->mergeTiers();
        foreach ($keys as $i => $key) {
            $values = $lsm_tree->get($key);
            $this->assertEqual("value$i", $values[0]["VALUE"] ?? "",
                "Found the value of key $i");
        }
    }
}
/**
 * CrossedWalkTree is an LSMTree whose first walk of the tiers is crossed
 * by a merge: while reading tier 0 it adds two to the merge count, as
 * mergeTier does around its renames, and gives the tier's rows twice.
 */
class CrossedWalkTree extends LSMTree
{
    /**
     * $walks counts the walks of tier 0, one for each walk of the tree.
     * @var int
     */
    public $walks = 0;
    /**
     * getTier gives tier $tier's rows for $key, twice on the first walk of
     * tier 0, after adding two to the merge count.
     *
     * @param int $tier the tier to read
     * @param string $key the key to look up
     * @return mixed the rows, as LSMTree::getTier gives them
     */
    public function getTier($tier, $key)
    {
        $rows = parent::getTier($tier, $key);
        if ($tier == 0) {
            $this->walks++;
            if ($this->walks == 1) {
                $this->addToMergeGeneration();
                $this->addToMergeGeneration();
                $rows = array_merge($rows ?? [], $rows ?? []);
            }
        }
        return $rows;
    }
}