<?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;
use seekquarry\yioop\configs as C;
/**
* Used for crawlLog and crawlHash
*/
require_once __DIR__ . '/Utility.php';
/**
* A DoubleIndexBundle encapsulates and provided methods for two
* IndexDocumentBundle used to store a repeating crawl. One one thse bundles
* is used to handle current search queries, while the other is used to store
* an ongoing crawl, once the crawl time has been reach the roles of the two
* bundles are swapped
*
* @author Chris Pollett
*/
class DoubleIndexBundle implements CrawlConstants
{
/**
* How frequency the live and ongoing archive should be swapped
* in seconds
* @var int
*/
public $repeat_frequency;
/**
* Last time live and ongoing archives were switched
* @var int
*/
public $repeat_time;
/**
* The number of times live and ongoing archives have swapped
* @var int
*/
public $swap_count;
/**
* The internal IndexDocumentBundle which is active
* @var IndexDocumentBundle
*/
public $active_archive;
/**
* The number of the internal IndexDocumentBundle which is active
* @var IndexDocumentBundle
*/
public $active_archive_num;
/**
* A short text name for this DoubleIndexBundle
* @var string
*/
public $description;
/**
* Makes or initializes an DoubleIndexBundle with the provided parameters
*
* @param string $dir_name folder name to store this bundle
* @param bool $read_only_archive whether to open archive only for reading
* or reading and writing
* @param string $description a text name/serialized info about this
* IndexDocumentBundle
* @param int $num_docs_per_partition the number of pages to be stored
* in a single shard
* @param int $repeat_frequency how often the crawl should be redone in
* seconds (has no effect if $read_only_archive is true)
* @return bool false when an existing on-disk bundle could not
* be deserialized; the instance is fully initialised on the
* normal path
*/
public function __construct(public $dir_name,
$read_only_archive = true, $description = null,
public $num_docs_per_partition = C\NUM_DOCS_PER_PARTITION,
$repeat_frequency = 3600)
{
$index_archive_exists = false;
$is_dir = is_dir($this->dir_name);
if (!$is_dir && !$read_only_archive) {
mkdir($this->dir_name);
$this->active_archive = new IndexDocumentBundle($dir_name .
"/bundle0", false, null, $num_docs_per_partition);
$bundle = new IndexDocumentBundle($dir_name . "/bundle1",
false, null, $num_docs_per_partition);
} else if (!$is_dir) {
return false;
}
$read_status = false;
if (file_exists($this->dir_name . "/status.txt")) {
$status = unserialize(
file_get_contents($this->dir_name . "/status.txt"));
$read_status = true;
}
$this->repeat_frequency = (empty($status["repeat_frequency"])) ?
$repeat_frequency : $status["repeat_frequency"];
$this->repeat_time = (empty($status["repeat_time"])) ?
time() : $status["repeat_time"];
$this->swap_count = (empty($status["swap_count"])) ?
0 : $status["swap_count"];
$this->active_archive_num = $this->swap_count % 2;
$this->description = (empty($status["DESCRIPTION"])) ?
$description : $status["DESCRIPTION"];
if (!$read_status && !$read_only_archive) {
$status = ["repeat_frequency" => $this->repeat_frequency,
"repeat_time" => $this->repeat_time,
"swap_count" => $this->swap_count,
"DESCRIPTION" => $this->description
];
file_put_contents($this->dir_name . "/status.txt",
serialize($status));
}
if (empty($this->active_archive)) {
$this->active_archive = new IndexDocumentBundle($dir_name .
"/bundle" . $this->active_archive_num, $read_only_archive, null,
$num_docs_per_partition);
}
}
/**
* Switches which of the two bundles is the the one new index data will
* be written. Before switching closes old bundle properly.
*/
public function swapActiveBundle()
{
if(!$this->active_archive->updateDictionary()) {
$this->active_archive->forceSave();
}
$this->swap_count++;
$this->active_archive_num = $this->swap_count % 2;
$bundle_name = $this->dir_name . "/bundle" . $this->active_archive_num;
$db_class = C\NS_DATASOURCES . ucfirst(C\p('DBMS')) . "Manager";
$db = new $db_class();
$db->unlinkRecursive($bundle_name, true);
$this->active_archive = new IndexDocumentBundle($bundle_name,
false, null, $this->num_docs_per_partition);
$this->repeat_time = time();
$status = ["repeat_frequency" => $this->repeat_frequency,
"repeat_time" => $this->repeat_time,
"swap_count" => $this->swap_count,
"DESCRIPTION" => $this->description
];
file_put_contents($this->dir_name . "/status.txt",
serialize($status));
}
/**
* Used when a crawl stops to perform final dictionary operations
* to produce a working stand-alone index.
*/
public function stopIndexing()
{
$this->forceSave();
$bundle_name = $this->dir_name . "/bundle" . $this->active_archive_num;
/* we haven't swapped yet, so want to serve results from bundle
that exists. i.e., the not active one
*/
$status = ["repeat_frequency" => $this->repeat_frequency,
"repeat_time" => $this->repeat_time,
"swap_count" => $this->swap_count,
"DESCRIPTION" => $this->description
];
file_put_contents($this->dir_name . "/status.txt",
serialize($status));
}
/**
* Checks if the amount of time since the two IndexDocumentBundles in
* this DoubleIndexBundle roles have been swapped has exceeded the
* swap time for this buundle.
*
* @return bool true if the swap time has been exceeded
*/
public function swapTimeReached()
{
return ($this->repeat_time + $this->repeat_frequency < time());
}
/**
* Returns a document summary from the arctive archive associated with
* the supplied key
* @param string $doc_key key (usually based on url of where document
* came from) associated with documnent want summary of
* @return array desired summary
*/
public function getSummary($doc_key)
{
return $this->active_archive->getSummary($doc_key);
}
/**
* Returns a full page cache (usually the web page downloaded as opposed
* to a summary of the web page) associated with a supplied key.
* @param string $doc_key key (usually based on url of where document
* came from) associated with documnent want summary of
* @return array desired cache
*/
public function getCachePage($doc_key)
{
return $this->active_archive->getCachePage($doc_key);
}
/**
* Add the array of $pages to the active IndexDocumentBundle,
* storing in the partition $generation and the field used
* to store the resulting offsets given by $offset_field.
*
* @param array &$pages data to store
* @param int $visited_urls_count number to add to the count of visited urls
* (visited urls is a smaller number than the total count of objects
* stored in the index).
* @return mixed whatever the underlying IndexDocumentBundle::addPages
* returns (typically the post-add visited URL count)
*/
public function addPages(&$pages, $visited_urls_count)
{
return $this->active_archive->addPages($pages,
$visited_urls_count);
}
/**
* Checks if there is enough data in the active partition of the active
* archive to warrant storing in the dictionary, if so it builds an
* inverted index for the active partition of the active archive and
* adds the postings to the dictionary
* @param string $taking_too_long_touch name of file to touch if
* checking the update takes longer than LOG_TIMEOUT. To prevent
* a crawl from stopping because nothing is happening the
* file usually supplied is C\SCHEDULES_DIR . "/{$this->channel}-" .
* self::crawl_status_file
* @return mixed whatever IndexDocumentBundle::updateDictionary returns
* (typically true on success, false if not enough data yet)
*/
public function updateDictionary($taking_too_long_touch = null)
{
return $this->active_archive->updateDictionary($taking_too_long_touch);
}
/**
* Given a $site array of information about a web page/document. Use
* CrawlConstant::URL and CrawlConstant::HASH fields to compute a
* unique doc id for the array.
*
* @param array $site site to compute doc_id for
* @return string the computed doc_id from the active archive (binary
* hash bytes per IndexDocumentBundle::computeDocId)
*/
public function computeDocId($site)
{
return $this->active_archive->computeDocId($site);
}
/**
* Forces the current shard to be saved
*/
public function forceSave()
{
$this->active_archive->forceSave();
}
/**
* The start schedule is the first schedule a queue server makes
* when a crawl is just started. To facilitate switching between
* IndexDocumentBundles when doing a crawl with a DoubleIndexBundle
* this start schedule is stored in the DoubleIndexBundle, when the
* IndexDocumentBundles' roles (query and crawl) are swapped,
* the DoubleIndexBundle copy is used to start the crawl from the beginning
* again. This method copies the start schedule from the schedule folder
* to the DoubleIndexBundle at the start of a crawl for later use to do
* this swapping
*
* @param string $dir_name folder in the bundle where the schedule
* should be stored
* @param int $channel channel that is being used to do the current
* double index crawl. Typical yioop instance might have several
* ongoing crawls each with a different channel
*/
public static function setStartSchedule($dir_name, $channel)
{
$start_schedule = C\SCHEDULES_DIR . "/$channel-" .
self::schedule_start_name;
if (file_exists($dir_name) && is_dir($dir_name) &&
file_exists($start_schedule)) {
copy($start_schedule, $dir_name . "/" . self::schedule_start_name);
}
}
/**
* The start schedule is the first schedule a queue server makes
* when a crawl is just started. To facilitate switching between
* IndexDocumentBundles when doing a crawl with a DoubleIndexBundle
* this start schedule is stored in the DoubleIndexBundle, when the
* IndexDocumentBundles' roles (query and crawl) are swapped,
* this method copies the start schedule from the DoubleIndexBundle
* to the schedule folder to restart the crawl
*
* @param string $dir_name folder in the bundle where the schedule
* is stored
* @param int $channel channel that is being used to do the current
* double index crawl. Typical yioop instance might have several
* ongoing crawls each with a different channel
*/
public static function getStartSchedule($dir_name, $channel)
{
$start_schedule = C\SCHEDULES_DIR . "/$channel-" .
self::schedule_start_name;
if (file_exists($dir_name) && is_dir($dir_name)) {
copy($dir_name . "/" . self::schedule_start_name, $start_schedule);
}
}
/**
* Gets information about a DoubleIndexBundle out of its status.txt
* file
*
* @param string $dir_name folder name of the DoubleIndexBundle to get
* info for
* @return array containing the name (description) of the
* DouleIndexBundle, the number of items stored in it, and the
* number of WebArchive file partitions it uses.
*/
public static function getArchiveInfo($dir_name)
{
$info = unserialize(file_get_contents($dir_name . "/status.txt"));
$swap_count = intval($info['swap_count']);
$active = $swap_count % 2;
$inactive = 1 - $active;
$bundle_name = $dir_name . "/bundle$active";
$bundle_class_name = C\NS_LIB . "IndexDocumentBundle";
if (file_exists($bundle_name . "/summaries")) {
$bundle_class_name = C\NS_LIB . "IndexArchiveBundle";
}
$count_info = $bundle_class_name::getArchiveInfo($bundle_name);
$info['COUNT'] = $count_info['COUNT'] +
($count_info['ACTIVE_COUNT'] ?? 0);
$info['VISITED_URLS_COUNT'] = $count_info['VISITED_URLS_COUNT'];
$bundle_name = $dir_name . "/bundle$inactive";
$count_info = $bundle_class_name::getArchiveInfo($bundle_name);
$info['QUERY_COUNT'] = $count_info['COUNT'] +
($count_info['ACTIVE_COUNT'] ?? 0);
$info['QUERY_VISITED_URLS_COUNT'] = $count_info['VISITED_URLS_COUNT'];
return $info;
}
/**
* Sets the archive info struct for the index archive and web archive
* bundles associated with this double index bundle. This struct has fields
* like: DESCRIPTION (serialied store of global parameters of the crawl
* like seed sites, timestamp, etc), COUNT (num urls seen +
* pages seen stored for the index archive in use for crawling),
* VISITED_URLS_COUNT (number of pages seen for the index archive in use for
* crawling), QUERY_COUNT (num urls seen +
* pages seen stored for the index archive in use for querying, not
* crawling), QUERY_VISITED_URLS_COUNT number of pages seen for the
* index archive in use for querying not crawling),
* NUM_DOCS_PER_PARTITION (how many doc/web archive in bundle).
*
* @param string $dir_name folder with archive bundle
* @param array $info struct with above fields
*/
public static function setArchiveInfo($dir_name, $info)
{
file_put_contents($dir_name . "/status.txt",
serialize($info));
}
/**
* Returns the last time the archive info of the bundle was modified.
*
* @param string $dir_name folder with archive bundle
* @return int Unix timestamp: the later of status.txt's mtime and
* the active inner bundle's count time
*/
public static function getParamModifiedTime($dir_name)
{
$info = unserialize(file_get_contents($dir_name . "/status.txt"));
$swap_count = intval($info['swap_count']);
$active = $swap_count % 2;
$bundle_name = $dir_name . "/bundle" . $active;
$count_time =
IndexDocumentBundle::getParamModifiedTime($bundle_name);
clearstatcache();
return max(filemtime($dir_name . "/status.txt"), $count_time);
}
}