SeekquarrySeekquarry - Yioop Repo - Seekquarry

/ src / library / data_structures / UndoLog.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\library\data_structures;

use seekquarry\yioop\library as L;

/**
 * UndoLog lets a step that changes several files of an index be undone
 * when it did not finish, as when a crawl is stopped or a process is killed
 * part way through. Before the step changes a file, it records how to put
 * the file back: the length of a file it appends to, a copy of a file it
 * rewrites, or that a file did not exist yet. The log is synced before the
 * step goes on. A step that finishes appends a record saying so. undo,
 * run when an index is opened, puts back every file of each step that did
 * not finish, and returns the names of those steps.
 *
 * @author Chris Pollett
 */
class UndoLog
{
    /**
     * Name of the log file kept in the folder the log guards
     */
    const LOG_NAME = "undo_log.txt";
    /**
     * Name of the folder, beside the log, that holds copies of files a
     * step rewrites
     */
    const COPIES_NAME = "undo_copies";
    /**
     * Name of the checkpoint file, beside the log, that holds for each file
     * a finished step wrote the length known to be on the disk
     */
    const CHECKPOINT_NAME = "undo_checkpoint.txt";
    /**
     * Folder whose files this log guards; the log is kept in it
     * @var string
     */
    public $folder;
    /**
     * What the last undo found on comparing files with the checkpoint, a
     * line for each file it cut back or found short, for the caller to log
     * @var array
     */
    public $checkpoint_notes = [];
    /**
     * Makes an undo log for the files of one folder, such as an index
     * bundle's.
     *
     * @param string $folder folder whose files the log guards
     */
    public function __construct($folder)
    {
        $this->folder = $folder;
    }
    /**
     * begin records, before a step changes anything, how to put back each
     * file it will change, and syncs the record to disk. A file that does
     * not exist yet is recorded as new, and is removed on undo. A file in
     * $appended_files is recorded with its length; on undo it is cut back
     * to it. A file in $rewritten_files is copied aside; on undo the copy
     * is put back.
     *
     * @param string $step name of the step, used in the undo's log line
     * @param array $appended_files paths of files the step only appends to
     * @param array $rewritten_files paths of files, or folders, the step
     *      writes anew; a folder is recorded only if the step makes it
     * @return string the id of the step, to pass to finish
     */
    public function begin($step, $appended_files = [],
        $rewritten_files = [])
    {
        $step_id = getmypid() . "-" . hrtime(true);
        $lines = "BEGIN\t$step_id\t" . str_replace(["\t", "\n"], " ", $step) .
            "\n";
        foreach ($appended_files as $path) {
            clearstatcache(true, $path);
            $lines .= file_exists($path) ?
                "LENGTH\t$step_id\t$path\t" . filesize($path) . "\n" :
                "NEW\t$step_id\t$path\n";
        }
        $copies = $this->folder . "/" . self::COPIES_NAME;
        foreach ($rewritten_files as $number => $path) {
            clearstatcache(true, $path);
            if (!file_exists($path)) {
                $lines .= "NEW\t$step_id\t$path\n";
                continue;
            }
            if (is_dir($path)) {
                /* A folder that is already there is left as it is; only
                   one the step makes is removed on undo. */
                continue;
            }
            if (!file_exists($copies)) {
                @mkdir($copies);
            }
            $copy = "$copies/$step_id-$number";
            copy($path, $copy);
            self::syncPath($copy);
            self::syncPath($copies);
            $lines .= "COPY\t$step_id\t$path\t$copy\n";
        }
        $this->append($lines);
        return $step_id;
    }
    /**
     * commit marks the point past which a step is completed rather than
     * undone: its new files are in place, and what remains is to remove
     * old ones, listed in $remove_paths. If the step stops after this, the
     * next undo removes those paths, finishing the step, instead of
     * putting its files back. A tier merge commits once its merged tier is
     * in place, before it removes the tiers it merged.
     *
     * @param string $step_id the id begin returned for the step
     * @param array $remove_paths files or folders the step will remove
     */
    public function commit($step_id, $remove_paths)
    {
        $lines = "COMMIT\t$step_id\n";
        foreach ($remove_paths as $path) {
            $lines .= "REMOVE\t$step_id\t$path\n";
        }
        $this->append($lines);
    }
    /**
     * finish records that a step has written all its files, so undo leaves
     * them as they are. When every step in the log has finished, the log
     * and its copies are removed.
     *
     * @param string $step_id the id begin returned for the step
     * @param array $written_paths files and folders the step wrote, which
     *      are synced to the disk and checkpointed before it is marked
     *      finished
     * @param array $checkpoint_paths the files and folders among
     *      $written_paths whose lengths the checkpoint keeps, or null for
     *      all of them. A file written once and never grown, such as a
     *      dictionary tier's, has no use for a kept length.
     */
    public function finish($step_id, $written_paths = [],
        $checkpoint_paths = null)
    {
        /* Everything the step wrote reaches the disk, with the folders
           that hold it, before the log says the step finished; then the
           checkpoint records how long each file is. */
        self::syncPaths($written_paths);
        $lengths = [];
        foreach ($checkpoint_paths ?? $written_paths as $path) {
            foreach (self::filesUnder($path) as $file) {
                clearstatcache(true, $file);
                $lengths[$file] = filesize($file);
            }
        }
        /* A step that checkpoints nothing leaves the checkpoint alone,
           which saves reading every file it names. */
        if ($checkpoint_paths !== []) {
            $this->recordCheckpoint($lengths);
        }
        $this->append("FINISH\t$step_id\n");
        $steps = $this->readSteps();
        foreach ($steps as $record) {
            if (!$record["FINISHED"]) {
                return;
            }
        }
        $this->clear();
    }
    /**
     * undo puts back the files of every step in the log that did not
     * finish and whose process has stopped: an appended file is cut back
     * to its old length, a rewritten file is copied back, and a new file
     * or folder is removed. A step that had committed is completed instead,
     * by removing what it was to remove. A step whose process still runs
     * is left in the log. An index
     * calls it when it is opened, before it reads any file the log guards.
     *
     * @return array names of the steps that were undone
     */
    public function undo()
    {
        $steps = $this->readSteps();
        $running = false;
        foreach ($steps as $step_id => $record) {
            $holder = intval(explode("-", $step_id)[0]);
            $running = $running || (!$record["FINISHED"] && $holder > 0 &&
                ($holder == getmypid() ||
                L\CrawlDaemon::isProcessAlive($holder)));
        }
        /* A file may grow past its checkpoint only while a step that
           writes it runs, so files are cut back only when none runs. */
        $this->checkpoint_notes = $running ? [] : $this->cutToCheckpoint();
        $undone = [];
        $still_running = "";
        foreach ($steps as $step_id => $record) {
            if ($record["FINISHED"]) {
                continue;
            }
            /* A step whose process still runs, this one included, is in
               the middle of its work, not stopped part way, so its record
               is kept. */
            $holder = intval(explode("-", $step_id)[0]);
            if ($holder > 0 && ($holder == getmypid() ||
                L\CrawlDaemon::isProcessAlive($holder))) {
                $still_running .= $record["LINES"];
                continue;
            }
            if ($record["COMMITTED"]) {
                foreach ($record["FILES"] as [$kind, $path]) {
                    if ($kind == "REMOVE") {
                        $this->removePath($path);
                    }
                }
                $undone[] = $record["STEP"] . " (completed)";
                continue;
            }
            foreach (array_reverse($record["FILES"]) as $file) {
                [$kind, $path, $detail] = $file;
                if ($kind == "NEW") {
                    $this->removePath($path);
                } else if ($kind == "LENGTH" && file_exists($path)) {
                    $handle = fopen($path, "r+");
                    ftruncate($handle, intval($detail));
                    fclose($handle);
                } else if ($kind == "COPY" && file_exists($detail)) {
                    copy($detail, $path);
                }
            }
            $undone[] = $record["STEP"];
        }
        $this->clear();
        if ($still_running != "") {
            $this->append($still_running);
        }
        return $undone;
    }
    /**
     * Reads the log into its steps, each with its name, whether it
     * finished, its files as [kind, path, length or copy], and its lines
     * as written. A last line
     * cut short by a crash is ignored; the step it belongs to did not get
     * past writing its log, so it changed no file.
     *
     * @return array steps keyed by step id, in the order they began
     */
    protected function readSteps()
    {
        $path = $this->folder . "/" . self::LOG_NAME;
        $steps = [];
        if (!file_exists($path)) {
            return $steps;
        }
        $text = file_get_contents($path);
        $end = strrpos($text, "\n");
        $lines = ($end === false) ? [] :
            explode("\n", substr($text, 0, $end));
        foreach ($lines as $line) {
            $fields = explode("\t", $line);
            [$kind, $step_id] = [$fields[0], $fields[1] ?? ""];
            if ($kind == "BEGIN") {
                $steps[$step_id] = ["STEP" => $fields[2] ?? "",
                    "FINISHED" => false, "COMMITTED" => false,
                    "FILES" => [], "LINES" => ""];
            } else if ($kind == "FINISH" && isset($steps[$step_id])) {
                $steps[$step_id]["FINISHED"] = true;
            } else if ($kind == "COMMIT" && isset($steps[$step_id])) {
                $steps[$step_id]["COMMITTED"] = true;
            } else if (isset($steps[$step_id])) {
                $steps[$step_id]["FILES"][] = [$kind, $fields[2] ?? "",
                    $fields[3] ?? ""];
            }
            if (isset($steps[$step_id])) {
                $steps[$step_id]["LINES"] .= "$line\n";
            }
        }
        return $steps;
    }
    /**
     * Appends lines to the log and syncs them to disk before returning, so
     * a step never changes a file its log does not yet record.
     *
     * @param string $lines the records to add, each ending in a newline
     */
    protected function append($lines)
    {
        $handle = fopen($this->folder . "/" . self::LOG_NAME, "a");
        flock($handle, LOCK_EX);
        fwrite($handle, $lines);
        fsync($handle);
        flock($handle, LOCK_UN);
        fclose($handle);
    }
    /**
     * syncPath asks the operating system to write a file, or a folder's
     * list of names, to the disk before returning, with fsync, which on
     * Windows is the C runtime's _commit. A folder is synced so a rename
     * or a new file in it lasts through a power cut; PHP cannot open a
     * folder on Windows, where NTFS keeps names in its own journal, so
     * there a folder is left as it is.
     *
     * @param string $path the file or folder
     */
    public static function syncPath($path)
    {
        $handle = @fopen($path, is_dir($path) ? "r" : "r+");
        if ($handle === false) {
            return;
        }
        @fsync($handle);
        fclose($handle);
    }
    /**
     * syncPaths makes every file under $paths, and the folders holding
     * them, reach the disk before returning. UndoLog::finish and
     * LSMTree::mergeTier call it for the files a step or a tier merge
     * wrote. Each file is synced with syncPath, then each folder holding
     * them once, using PHP's own fsync and starting no other program.
     *
     * @param array $paths files and folders whose files, and the folders
     *      holding them, are synced
     */
    public static function syncPaths($paths)
    {
        if (empty($paths)) {
            return;
        }
        $folders = [];
        foreach ($paths as $path) {
            foreach (self::filesUnder($path) as $file) {
                self::syncPath($file);
            }
            $folders[is_dir($path) ? $path : dirname($path)] = true;
        }
        /* Each folder is synced once, however many of the files it
           holds. */
        foreach (array_keys($folders) as $folder) {
            self::syncPath($folder);
        }
    }
    /**
     * filesUnder lists $path itself when it is a file, or every file in
     * it, at any depth, when it is a folder.
     *
     * @param string $path a file or folder
     * @return array the files
     */
    public static function filesUnder($path)
    {
        if (!is_dir($path)) {
            return file_exists($path) ? [$path] : [];
        }
        $files = [];
        $entries = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($path,
            \FilesystemIterator::SKIP_DOTS));
        foreach ($entries as $entry) {
            if ($entry->isFile()) {
                $files[] = $entry->getPathname();
            }
        }
        return $files;
    }
    /**
     * recordCheckpoint adds the lengths of files a step has synced to the
     * checkpoint file, keeping each file's latest length and dropping
     * files no longer there. The file is written to a new name, synced and
     * renamed into place, so the old checkpoint stands until the new one
     * is whole on the disk. finish calls it. A process that has removed
     * files the checkpoint names, as a tier merge in the dictionary
     * removes the tiers it merged, calls it with no lengths, so an undo
     * does not later report those files as lost.
     *
     * @param array $lengths file path => length known to be on the disk
     */
    public function recordCheckpoint($lengths)
    {
        $checkpoint = $this->folder . "/" . self::CHECKPOINT_NAME;
        $known = $this->readCheckpoint();
        if (empty($known) && empty($lengths)) {
            return;
        }
        foreach ($lengths as $file => $length) {
            $known[$file] = $length;
        }
        $text = "";
        $dropped = false;
        foreach ($known as $file => $length) {
            if (file_exists($file)) {
                $text .= "$file\t$length\n";
            } else {
                $dropped = true;
            }
        }
        if (empty($lengths) && !$dropped) {
            return;
        }
        $writing = "$checkpoint.writing-" . getmypid();
        file_put_contents($writing, $text);
        self::syncPath($writing);
        rename($writing, $checkpoint);
        self::syncPath($this->folder);
    }
    /**
     * Reads the checkpoint file into file path => length known to be on the
     * disk. A last line cut short is ignored.
     *
     * @return array the lengths, or none when there is no checkpoint
     */
    protected function readCheckpoint()
    {
        $checkpoint = $this->folder . "/" . self::CHECKPOINT_NAME;
        $lengths = [];
        if (!file_exists($checkpoint)) {
            return $lengths;
        }
        $text = file_get_contents($checkpoint);
        $end = strrpos($text, "\n");
        foreach (($end === false) ? [] : explode("\n", substr($text, 0,
            $end)) as $line) {
            $fields = explode("\t", $line);
            if (count($fields) == 2) {
                $lengths[$fields[0]] = intval($fields[1]);
            }
        }
        return $lengths;
    }
    /**
     * cutToCheckpoint compares each checkpointed file with the length the
     * checkpoint says reached the disk. A file longer than that holds bytes
     * a stopped step wrote after it, and is cut back to it. A file shorter
     * than that, or gone, lost bytes the disk was asked to keep, as a drive
     * that holds writes in its own cache can on a power cut; it cannot be
     * mended here, so it is named, for the caller to log. undo calls it
     * first.
     *
     * @return array a line for each file cut back or found short
     */
    protected function cutToCheckpoint()
    {
        $notes = [];
        foreach ($this->readCheckpoint() as $file => $length) {
            clearstatcache(true, $file);
            $size = file_exists($file) ? filesize($file) : -1;
            if ($size > $length) {
                $handle = fopen($file, "r+");
                ftruncate($handle, $length);
                fsync($handle);
                fclose($handle);
                $notes[] = "$file cut back from $size to $length bytes";
            } else if ($size < $length) {
                $notes[] = "$file is " . (($size < 0) ? "gone" :
                    "$size bytes") . ", not the $length checkpointed";
            }
        }
        return $notes;
    }
    /**
     * Removes a file, or a folder with everything in it, as undo does for
     * a file or folder a step made and for what a committed step was to
     * remove.
     *
     * @param string $path the file or folder to remove
     */
    protected function removePath($path)
    {
        if (!is_dir($path)) {
            @unlink($path);
            return;
        }
        $entries = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($path,
            \FilesystemIterator::SKIP_DOTS),
            \RecursiveIteratorIterator::CHILD_FIRST);
        foreach ($entries as $entry) {
            $entry->isDir() ? @rmdir($entry->getPathname()) :
                @unlink($entry->getPathname());
        }
        @rmdir($path);
    }
    /**
     * Removes the log and the copies of old files it kept.
     */
    protected function clear()
    {
        @unlink($this->folder . "/" . self::LOG_NAME);
        $copies = $this->folder . "/" . self::COPIES_NAME;
        foreach (glob("$copies/*") ?: [] as $copy) {
            @unlink($copy);
        }
        @rmdir($copies);
    }
}