SeekquarrySeekquarry - Yioop Repo - Seekquarry

/ tests / PartitionDocumentBundleTest.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
 *
 * @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\CrawlConstants;
use seekquarry\yioop\library\storage_formats\PartitionDocumentBundle;
use seekquarry\yioop\library\UnitTest;

/**
 * PartitionDocumentBundleTest checks how PartitionDocumentBundle fills
 * its partitions and reads a blob out of a partition file, where the
 * length comes from the bundle's own index. A read asks the memory manager for
 * the whole length before it reads anything, so a length that is wrong
 * asks for that much memory whatever the file holds, and a long running
 * server near its memory limit dies of one such read.
 *
 * @author Chris Pollett
 */
class PartitionDocumentBundleTest extends UnitTest
{
    /**
     * $folder stores where a case writes the partition file it reads.
     * @var string
     */
    public $folder = "";
    /**
     * $bundle stores the bundle a case reads through.
     * @var object
     */
    public $bundle = null;
    /**
     * setUp makes a partition file holding a hundred bytes, which is the
     * file every case here reads from.
     */
    public function setUp()
    {
        $this->folder = C\TEST_DIR . "/test_files/blobread" . getmypid();
        if (!file_exists($this->folder)) {
            mkdir($this->folder, 0777, true);
            @chmod($this->folder, 0777);
        }
        $this->bundle = new PartitionDocumentBundle($this->folder,
            ["PRIMARY KEY" => "KEY", "VALUE" => "BLOB"]);
        file_put_contents($this->folder . "/partition0.txt",
            str_repeat("small", 20));
        @chmod($this->folder . "/partition0.txt", 0777);
    }
    /**
     * tearDown takes away the partition file and the folder a case made.
     */
    public function tearDown()
    {
        if (!file_exists($this->folder)) {
            return;
        }
        $entries = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($this->folder,
            \FilesystemIterator::SKIP_DOTS),
            \RecursiveIteratorIterator::CHILD_FIRST);
        foreach ($entries as $entry) {
            if ($entry->isDir()) {
                rmdir($entry->getPathname());
            } else {
                unlink($entry->getPathname());
            }
        }
        rmdir($this->folder);
    }
    /**
     * trueLengthIsReadWholeTestCase checks that a length matching
     * what the file holds is read as it always was, so the guard on the
     * other lengths does not cost an ordinary read anything.
     */
    public function trueLengthIsReadWholeTestCase()
    {
        $file = $this->folder . "/partition0.txt";
        $said = PartitionDocumentBundle::lengthWorthReading($file, 0, 100);
        $this->assertEqual(100, $said,
            "a length the file can hold is left as it is");
        $said = PartitionDocumentBundle::lengthWorthReading($file, 50, 50);
        $this->assertEqual(50, $said,
            "and so is one that reaches exactly the end");
    }
    /**
     * lengthPastTheFileIsCutToWhatIsThereTestCase checks that a length
     * running past the end of the file is cut to what is left, so the
     * read asks for that much and no more.
     */
    public function lengthPastTheFileIsCutToWhatIsThereTestCase()
    {
        $file = $this->folder . "/partition0.txt";
        $said = PartitionDocumentBundle::lengthWorthReading($file, 0, 400);
        $this->assertEqual(100, $said,
            "a length past the end is cut to what the file holds");
        $said = PartitionDocumentBundle::lengthWorthReading($file, 200, 50);
        $this->assertEqual(0, $said,
            "and an offset past the end asks for nothing at all");
    }
    /**
     * lengthPastWhatABlobMayBeIsRefusedTestCase checks that a length
     * larger than any blob is refused outright. Such a length asks the
     * memory manager for that much before the file is touched, which is
     * how a server near its limit is ended by one corrupt entry.
     */
    public function lengthPastWhatABlobMayBeIsRefusedTestCase()
    {
        $file = $this->folder . "/partition0.txt";
        $said = PartitionDocumentBundle::lengthWorthReading($file, 0,
            C\MAX_BLOB_READ_LEN + 1);
        $this->assertEqual(0, $said,
            "a length past the most a blob may be asks for nothing");
        $said = PartitionDocumentBundle::lengthWorthReading($file, 0,
            291504352);
        $this->assertEqual(0, $said,
            "and so does the length that ended the live server");
    }
    /**
     * blobIsReadWithoutAskingForMoreThanTheFileHoldsTestCase reads
     * through the bundle itself and checks how much memory the read
     * asks for. A read of two hundred and seventy-eight megabytes from
     * a file of a hundred bytes used to ask for all of it.
     */
    public function blobIsReadWithoutAskingForMoreThanTheFileHoldsTestCase()
    {
        $file = $this->folder . "/partition0.txt";
        $before = memory_get_peak_usage(true);
        $said = $this->bundle->getArchive($file, 0, 291504352);
        $grew = memory_get_peak_usage(true) - $before;
        $this->assertTrue($grew < C\MAX_BLOB_READ_LEN,
            "the read asks for less than the most a blob may be, and " .
            "asked for " . $grew . " bytes");
        $this->assertTrue($said === false || strlen($said) <= 100,
            "and hands back nothing more than the file holds");
    }
    /**
     * pageSentAgainIsNotCountedAsNewTestCase checks that the count of new
     * keys a put reports counts each key once, whether the repeat comes
     * in the same put or in a later one against the same partition. The
     * visited-pages tally on the crawl status screen rests on this
     * count, so a page the fetchers send twice must add one, not two.
     */
    public function pageSentAgainIsNotCountedAsNewTestCase()
    {
        $folder = C\TEST_DIR . "/test_files/newkeys" . getmypid();
        if (!file_exists($folder)) {
            mkdir($folder, 0777, true);
        }
        $bundle = new PartitionDocumentBundle($folder,
            ["PRIMARY KEY" => ["KEY", 8], "VALUE" => "BLOB"]);
        $rows = [];
        for ($i = 0; $i < 20; $i++) {
            $rows[] = ["KEY" => sprintf("key%05d", $i), "VALUE" => "v$i"];
        }
        $rows[] = ["KEY" => "key00003", "VALUE" => "again"];
        $bundle->put($rows);
        $this->assertEqual($bundle->new_keys_put, 20,
            "twenty distinct keys count as twenty, a repeat within the " .
            "put as none");
        $bundle->put([["KEY" => "key00007", "VALUE" => "later"],
            ["KEY" => "key99999", "VALUE" => "fresh"]]);
        $this->assertEqual($bundle->new_keys_put, 1,
            "a key already in the partition counts as none in a later put");
        $second = new PartitionDocumentBundle($folder,
            ["PRIMARY KEY" => ["KEY", 8], "VALUE" => "BLOB"]);
        $second->put([["KEY" => "key00001", "VALUE" => "x"],
            ["KEY" => "key99998", "VALUE" => "y"]]);
        $this->assertEqual($second->new_keys_put, 1,
            "a fresh object rebuilds the set from the partition on disk");
        $this->removeFolder($folder);
    }
    /**
     * deferredIndexIsSavedOnceTestCase checks that with
     * $defer_index_save set, as the indexer sets it for the slices of one
     * fetcher's file, put leaves the partition index on disk as it was and
     * keeps adding to the copy in memory, and that saveIndex then writes
     * every slice's records, which a fresh bundle reads back. Before, put
     * read the index from disk and saved it again for each slice.
     */
    public function deferredIndexIsSavedOnceTestCase()
    {
        $folder = C\TEST_DIR . "/test_files/deferred" . getmypid();
        if (!file_exists($folder)) {
            mkdir($folder, 0777, true);
        }
        $format = ["PRIMARY KEY" => ["KEY", 8], "VALUE" => "BLOB"];
        $bundle = new PartitionDocumentBundle($folder, $format);
        $bundle->put([["KEY" => "key00000", "VALUE" => "first"]]);
        $index_file = $bundle->getPartitionIndex(0);
        $before = file_get_contents($index_file);
        $bundle->defer_index_save = true;
        $bundle->put([["KEY" => "key00001", "VALUE" => "second"]]);
        $bundle->put([["KEY" => "key00002", "VALUE" => "third"]]);
        $this->assertEqual(file_get_contents($index_file), $before,
            "the index on disk is unchanged while saving is put off");
        $bundle->saveIndex();
        $bundle->defer_index_save = false;
        $reader = new PartitionDocumentBundle($folder, $format);
        $values = [];
        foreach (["key00000", "key00001", "key00002"] as $key) {
            $row = $reader->get($key, 0, ["VALUE"]);
            $values[] = $row["VALUE"] ?? "";
        }
        $this->assertEqual($values, ["first", "second", "third"],
            "every slice's record is in the saved index");
        $this->removeFolder($folder);
    }
    /**
     * pageCompressedByFetcherReadsBackAsSentTestCase checks that a page
     * a fetcher sends already serialized and gzip compressed reads back
     * as the page itself, the same as a page sent plain. A bundle that
     * gzip compresses its pages, as an index bundle does, stores the
     * bytes as sent, so its partition file grows by exactly their
     * length. A bundle that stores pages another way unpacks the page
     * first.
     */
    public function pageCompressedByFetcherReadsBackAsSentTestCase()
    {
        $page = "<html><body>" . str_repeat("mary pollett ", 50) .
            "</body></html>";
        $sent_bytes = gzencode(serialize($page),
            C\ARCHIVE_COMPRESSION_LEVEL);
        $compressors = ["gzip" => C\NS_COMPRESSORS . "GzipCompressor",
            "plain" => C\NS_COMPRESSORS . "NonCompressor"];
        foreach ($compressors as $name => $compressor) {
            $folder = C\TEST_DIR . "/test_files/precompressed" .
                getmypid() . $name;
            if (!file_exists($folder)) {
                mkdir($folder, 0777, true);
            }
            $bundle = new PartitionDocumentBundle($folder,
                ["PRIMARY KEY" => ["KEY", 8], "PAGE" => "SERIAL"],
                PartitionDocumentBundle::MAX_ITEMS_PER_FILE,
                PartitionDocumentBundle::PARTITION_SIZE_THRESHOLD,
                PartitionDocumentBundle::DEFAULT_COMPRESSOR, $compressor);
            $bundle->put([["KEY" => "packed01", "PAGE" =>
                [CrawlConstants::PRECOMPRESSED_PAGE => $sent_bytes]],
                ["KEY" => "plain001", "PAGE" => $page]]);
            $packed_row = $bundle->get("packed01", 0);
            $plain_row = $bundle->get("plain001", 0);
            $this->assertEqual($packed_row["PAGE"] ?? null, $page,
                "$name bundle: a page compressed by the fetcher reads " .
                "back as the page");
            $this->assertEqual($plain_row["PAGE"] ?? null, $page,
                "$name bundle: a page sent plain reads back as the page");
            if ($name == "gzip") {
                $archive = $bundle->getPartition(0);
                clearstatcache();
                $this->assertEqual(substr(file_get_contents($archive), 0,
                    strlen($sent_bytes)), $sent_bytes,
                    "gzip bundle: the bytes the fetcher sent are stored " .
                    "as sent");
            }
            $this->removeFolder($folder);
        }
    }
    /**
     * keyHoldingEscapedBytesIsFoundAgainTestCase checks that a key whose
     * bytes are the two the packed index escapes is still recognized
     * when it comes again. Those bytes take two bytes each in the index
     * string, so reading a key back from an entry has to undo the
     * escaping before comparing.
     */
    public function keyHoldingEscapedBytesIsFoundAgainTestCase()
    {
        $folder = C\TEST_DIR . "/test_files/esckeys" . getmypid();
        if (!file_exists($folder)) {
            mkdir($folder, 0777, true);
        }
        $bundle = new PartitionDocumentBundle($folder,
            ["PRIMARY KEY" => ["KEY", 8], "VALUE" => "BLOB"]);
        $odd_key = "\xFF\xFE\xFF\xFEabcd";
        $bundle->put([["KEY" => $odd_key, "VALUE" => "one"],
            ["KEY" => "plainkey", "VALUE" => "two"]]);
        $this->assertEqual($bundle->new_keys_put, 2,
            "two distinct keys, one with escaped bytes, count as two");
        $second = new PartitionDocumentBundle($folder,
            ["PRIMARY KEY" => ["KEY", 8], "VALUE" => "BLOB"]);
        $second->put([["KEY" => $odd_key, "VALUE" => "three"]]);
        $this->assertEqual($second->new_keys_put, 0,
            "the escaped key read back from disk matches the one sent");
        $this->removeFolder($folder);
    }
    /**
     * removeFolder takes away a folder a case made and everything in it.
     *
     * @param string $folder path of the folder to remove
     */
    public function removeFolder($folder)
    {
        $paths = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($folder,
            \FilesystemIterator::SKIP_DOTS),
            \RecursiveIteratorIterator::CHILD_FIRST);
        foreach ($paths as $path) {
            if ($path->isDir()) {
                rmdir($path->getPathname());
            } else {
                unlink($path->getPathname());
            }
        }
        rmdir($folder);
    }
    /**
     * partitionHoldsAtMostItsLimitTestCase checks that a partition holds
     * at most MAX_ITEMS_PER_FILE items, the limit WordIterator reads a
     * partition's postings up to. It puts seven rows into a bundle whose
     * limit is three, so the rows fill partitions 0 and 1 with three each
     * and leave one in partition 2. When a partition was started only once
     * the active one held more than the limit, partition 0 took four rows.
     */
    public function partitionHoldsAtMostItsLimitTestCase()
    {
        $folder = $this->folder . "/limit";
        $bundle = new PartitionDocumentBundle($folder,
            ["PRIMARY KEY" => "KEY", "VALUE" => "BLOB"], 3);
        $rows = [];
        for ($i = 0; $i < 7; $i++) {
            $rows[] = ["KEY" => str_pad("$i", 8, "0", STR_PAD_LEFT),
                "VALUE" => "row $i"];
        }
        $bundle->put($rows);
        $this->assertEqual($bundle->parameters["SAVE_PARTITION"], 2,
            "seven rows at three a partition end in partition 2");
        $this->assertEqual($bundle->parameters["ACTIVE_COUNT"], 1,
            "partition 2 holds the seventh row alone");
    }
}