235 lines
6.5 KiB
PHP
235 lines
6.5 KiB
PHP
<?php
|
|
|
|
namespace classes;
|
|
|
|
use Throwable;
|
|
|
|
class system_search_cache
|
|
{
|
|
public const PREFIX = 'system_search:v1:';
|
|
public const QUERY_PREFIX = self::PREFIX . 'query:';
|
|
public const INTENT_PREFIX = self::PREFIX . 'intent:';
|
|
public const DIRTY_TABLES_KEY = self::PREFIX . 'dirty_tables';
|
|
public const REBUILD_REQUEST_KEY = self::PREFIX . 'rebuild_request';
|
|
/**
|
|
* Optional runtime adapter for tests.
|
|
*/
|
|
private static ?object $adapter = null;
|
|
|
|
public static function setAdapterForTests(?object $adapter): void
|
|
{
|
|
self::$adapter = $adapter;
|
|
}
|
|
|
|
public static function getQuery(string $hash): ?array
|
|
{
|
|
$raw = self::redisGet(self::QUERY_PREFIX . $hash);
|
|
if ($raw === null) {
|
|
return null;
|
|
}
|
|
$decoded = json_decode($raw, true);
|
|
if (!is_array($decoded)) {
|
|
return null;
|
|
}
|
|
return $decoded;
|
|
}
|
|
|
|
public static function setQuery(string $hash, array $payload, int $ttlSeconds = 120): void
|
|
{
|
|
self::redisSetEx(self::QUERY_PREFIX . $hash, json_encode($payload, JSON_UNESCAPED_UNICODE), $ttlSeconds);
|
|
}
|
|
|
|
public static function getIntent(string $hash): ?array
|
|
{
|
|
$raw = self::redisGet(self::INTENT_PREFIX . $hash);
|
|
if ($raw === null) {
|
|
return null;
|
|
}
|
|
$decoded = json_decode($raw, true);
|
|
if (!is_array($decoded)) {
|
|
return null;
|
|
}
|
|
return $decoded;
|
|
}
|
|
|
|
public static function setIntent(string $hash, array $payload, int $ttlSeconds = 3600): void
|
|
{
|
|
self::redisSetEx(self::INTENT_PREFIX . $hash, json_encode($payload, JSON_UNESCAPED_UNICODE), $ttlSeconds);
|
|
}
|
|
|
|
public static function clearAll(): void
|
|
{
|
|
self::clearPattern(self::PREFIX . '*');
|
|
}
|
|
|
|
public static function clearQueryCaches(): void
|
|
{
|
|
self::clearPattern(self::QUERY_PREFIX . '*');
|
|
}
|
|
|
|
public static function clearIntentCaches(): void
|
|
{
|
|
self::clearPattern(self::INTENT_PREFIX . '*');
|
|
}
|
|
|
|
public static function markDirtyTable(string $table): void
|
|
{
|
|
$table = trim($table, " `\t\n\r\0\x0B");
|
|
if ($table === '') {
|
|
return;
|
|
}
|
|
$tables = self::redisGetArray(self::DIRTY_TABLES_KEY);
|
|
if (!in_array($table, $tables, true)) {
|
|
$tables[] = $table;
|
|
self::redisSetArray(self::DIRTY_TABLES_KEY, $tables);
|
|
// Keep dirty markers briefly in case cron is delayed.
|
|
self::redisExpire(self::DIRTY_TABLES_KEY, 3600);
|
|
}
|
|
// Query cache depends on mutable data and must be invalidated immediately.
|
|
self::clearQueryCaches();
|
|
}
|
|
|
|
public static function consumeDirtyTables(): array
|
|
{
|
|
$tables = self::redisGetArray(self::DIRTY_TABLES_KEY);
|
|
self::redisDelete(self::DIRTY_TABLES_KEY);
|
|
return $tables;
|
|
}
|
|
|
|
public static function enqueueRebuild(string $scope = 'all', array $types = []): array
|
|
{
|
|
$payload = [
|
|
'scope' => in_array($scope, ['all', 'types', 'dirty'], true) ? $scope : 'all',
|
|
'types' => array_values(array_unique(array_filter(array_map('strval', $types)))),
|
|
'requested_at' => time(),
|
|
];
|
|
self::redisSet(self::REBUILD_REQUEST_KEY, json_encode($payload, JSON_UNESCAPED_UNICODE));
|
|
self::redisExpire(self::REBUILD_REQUEST_KEY, 86400);
|
|
return $payload;
|
|
}
|
|
|
|
public static function consumeRebuildRequest(): ?array
|
|
{
|
|
$raw = self::redisGet(self::REBUILD_REQUEST_KEY);
|
|
if ($raw === null) {
|
|
return null;
|
|
}
|
|
self::redisDelete(self::REBUILD_REQUEST_KEY);
|
|
$decoded = json_decode($raw, true);
|
|
return is_array($decoded) ? $decoded : null;
|
|
}
|
|
|
|
private static function clearPattern(string $pattern): void
|
|
{
|
|
try {
|
|
$client = self::redisClient();
|
|
if ($client === null) {
|
|
return;
|
|
}
|
|
$client->clear_keys($pattern);
|
|
} catch (Throwable) {
|
|
// Cache clear must never break request flow.
|
|
}
|
|
}
|
|
|
|
private static function redisSetEx(string $key, string $value, int $ttl): void
|
|
{
|
|
try {
|
|
$client = self::redisClient();
|
|
if ($client === null) {
|
|
return;
|
|
}
|
|
$client->setEx($key, $value, $ttl);
|
|
} catch (Throwable) {
|
|
}
|
|
}
|
|
|
|
private static function redisSet(string $key, string $value): void
|
|
{
|
|
try {
|
|
$client = self::redisClient();
|
|
if ($client === null) {
|
|
return;
|
|
}
|
|
$client->set($key, $value);
|
|
} catch (Throwable) {
|
|
}
|
|
}
|
|
|
|
private static function redisGet(string $key): ?string
|
|
{
|
|
try {
|
|
$client = self::redisClient();
|
|
if ($client === null) {
|
|
return null;
|
|
}
|
|
$value = $client->get($key);
|
|
return is_string($value) ? $value : null;
|
|
} catch (Throwable) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private static function redisDelete(string $key): void
|
|
{
|
|
try {
|
|
$client = self::redisClient();
|
|
if ($client === null) {
|
|
return;
|
|
}
|
|
$client->delete($key);
|
|
} catch (Throwable) {
|
|
}
|
|
}
|
|
|
|
private static function redisSetArray(string $key, array $value): void
|
|
{
|
|
try {
|
|
$client = self::redisClient();
|
|
if ($client === null) {
|
|
return;
|
|
}
|
|
$client->set_array($key, $value);
|
|
} catch (Throwable) {
|
|
}
|
|
}
|
|
|
|
private static function redisGetArray(string $key): array
|
|
{
|
|
try {
|
|
$client = self::redisClient();
|
|
if ($client === null) {
|
|
return [];
|
|
}
|
|
$value = $client->get_array($key);
|
|
return is_array($value) ? $value : [];
|
|
} catch (Throwable) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
private static function redisExpire(string $key, int $ttl): void
|
|
{
|
|
try {
|
|
$client = self::redisClient();
|
|
if ($client === null) {
|
|
return;
|
|
}
|
|
$client->expire($key, $ttl);
|
|
} catch (Throwable) {
|
|
}
|
|
}
|
|
|
|
private static function redisClient(): ?object
|
|
{
|
|
if (self::$adapter !== null) {
|
|
return self::$adapter;
|
|
}
|
|
if (!defined('redis')) {
|
|
return null;
|
|
}
|
|
$client = constant('redis');
|
|
return is_object($client) ? $client : null;
|
|
}
|
|
}
|