Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/runtime-regressions.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: Runtime regressions
on: [push, pull_request]
jobs:
runtime:
runs-on: ubuntu-latest
strategy:
matrix:
php: ['8.4', '8.5']
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v4
with:
repository: evolution-cms/evolution
ref: 3.6.x
path: .test-core
- uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: mbstring, dom, xml, sqlite3, pdo_sqlite, gd, intl, zip
coverage: none
- run: composer install --working-dir=.test-core/core --no-interaction --no-scripts --prefer-dist
- name: Run package regression scripts
env:
SGALLERY_AUTOLOAD: ${{ github.workspace }}/.test-core/core/vendor/autoload.php
SCOMMERCE_TEST_AUTOLOAD: ${{ github.workspace }}/.test-core/core/vendor/autoload.php
PACKAGE_AUTOLOAD: ${{ github.workspace }}/.test-core/core/vendor/autoload.php
run: |
for test in tests/runtime/*-test.php; do php "$test"; done
php tests/resource-aliases.php
18 changes: 7 additions & 11 deletions plugins/sMultisitePlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,21 +32,17 @@
* migrations have created the table. SQLite fails immediately in that
* state, so runtime listeners must stay in default-mode until migrations
* finish.
* The result is memoized for the current PHP request to avoid repeated
* information_schema queries from product-card rendering.
* Positive checks use a database-scoped shared cache invalidated by
* migrations and cache flush. Missing tables are never cached, so
* installation can create the table and retry in the same PHP process.
*
* @return bool
*/
function ms_multisite_table_ready(): bool {
static $ready;
if (isset($ready)) {
return $ready;
}

try {
return $ready = Schema::hasTable('s_multisites');
return \Seiger\sMultisite\Support\ActiveDomainCache::tableReady();
} catch (\Throwable) {
return $ready = false;
return false;
}
}
}
Expand Down Expand Up @@ -212,7 +208,7 @@ function ms_sso_current_session_id(): string {
* @return void
*/
Event::listen('evolution.OnLoadSettings', function($params) {
$host = $_SERVER['HTTP_HOST'];
$host = $_SERVER['HTTP_HOST'] ?? null;
if (isset($params['config']['setHost']) && trim($params['config']['setHost']) !== '') {
$host = trim($params['config']['setHost']);
}
Expand All @@ -222,7 +218,7 @@ function ms_sso_current_session_id(): string {
if (!ms_multisite_table_ready()) {
return;
}
$domain = \Seiger\sMultisite\Models\sMultisite::whereDomain($host)->whereActive(1)->first();
$domain = \Seiger\sMultisite\Support\ActiveDomainCache::find($host);
if ($domain) {
evo()->setConfig('site_key', $domain->key);
evo()->setConfig('site_name', $domain->site_name);
Expand Down
155 changes: 155 additions & 0 deletions src/Support/ActiveDomainCache.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
<?php namespace Seiger\sMultisite\Support;

use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Database\Events\TransactionCommitted;
use Illuminate\Database\Events\TransactionRolledBack;
use Illuminate\Support\Facades\Cache;
use Seiger\sMultisite\Models\sMultisite;

/**
* Cache exact-host active-domain lookups without serializing model dates.
* Generation keys prevent in-flight reads from restoring invalidated entries.
*
* @since 1.2.0
*/
final class ActiveDomainCache
{
/** @var \WeakMap<\Illuminate\Database\Connection, string>|null Isolate ephemeral SQLite databases. */
private static ?\WeakMap $memoryDatabases = null;

/**
* Scope generations to the actual database and prefixed package table.
*
* @param \Illuminate\Database\Connection $connection
* @return string
* @since 1.2.0
*/
private static function key($connection): string
{
$database = (string)$connection->getDatabaseName();
$memoryScope = null;
if ($connection->getDriverName() === 'sqlite'
&& ($database === '' || str_contains($database, ':memory:') || str_contains($database, 'mode=memory'))) {
self::$memoryDatabases ??= new \WeakMap();
$memoryScope = self::$memoryDatabases[$connection] ??= bin2hex(random_bytes(16));
}
return 'sMultisite.active-domain.v1.' . hash('sha256', serialize([
$connection->getName(), $connection->getConfig('driver'), $connection->getConfig('host'),
$connection->getConfig('port'), $connection->getConfig('unix_socket'),
$connection->getDatabaseName(), $connection->getTablePrefix(), $memoryScope,
]));
}

/**
* Observe model, builder and raw SQL writes, including comments and CTE prefixes.
* Clear again after the outer transaction ends to discard concurrent old reads.
*
* @param \Illuminate\Contracts\Events\Dispatcher $events
* @return void
* @since 1.2.0
*/
public static function listen($events): void
{
$dirty = [];
foreach ([\Illuminate\Database\Events\MigrationEnded::class,
\Illuminate\Database\Events\MigrationsEnded::class] as $event) {
$events->listen($event, static function (): void {
$connection = (new sMultisite())->getConnection();
Cache::forget(self::key($connection) . '.table_ready');
Cache::forget(self::key($connection));
});
}
$events->listen(QueryExecuted::class, static function ($event) use (&$dirty): void {
$table = preg_quote($event->connection->getTablePrefix() . 's_multisites', '~');
if (!preg_match('~\b(?:insert|update|delete|replace|truncate|create|alter|drop|rename)\b~i', $event->sql)
|| !preg_match('~(?<![a-z0-9_])' . $table . '(?![a-z0-9_])~i', $event->sql)) {
return;
}
if (preg_match('~\b(?:create|alter|drop|rename)\b~i', $event->sql)) {
Cache::forget(self::key($event->connection) . '.table_ready');
}
Cache::forget(self::key($event->connection));
if ($event->connection->transactionLevel() > 0) {
$dirty[spl_object_id($event->connection)] = true;
}
});
foreach ([TransactionCommitted::class, TransactionRolledBack::class] as $event) {
$events->listen($event, static function ($event) use (&$dirty): void {
$id = spl_object_id($event->connection);
if (isset($dirty[$id]) && $event->connection->transactionLevel() === 0) {
Cache::forget(self::key($event->connection));
Cache::forget(self::key($event->connection) . '.table_ready');
unset($dirty[$id]);
}
});
}
}

/**
* Cache only a successful schema check, scoped to the model's database.
* Missing tables are checked again on the next call, including installation
* in the same process. Transactions never publish uncommitted schema state.
* Read the shared readiness entry on each call so direct Cache::flush() takes
* effect even when the backend emits no flush event. Migrations and observed
* DDL remove the positive entry; TTL bounds externally changed schemas.
*
* @return bool Whether the multisite table currently exists.
* @since 1.2.0
*/
public static function tableReady(): bool
{
$connection = (new sMultisite())->getConnection();
$key = self::key($connection) . '.table_ready';
$transaction = $connection->transactionLevel() > 0;
if (!$transaction && Cache::get($key) === true) {
return true;
}
$ready = $connection->getSchemaBuilder()->hasTable('s_multisites');
if ($ready && !$transaction) {
Cache::put($key, true, 300);
}
return $ready;
}

/**
* Resolve the caller's unchanged host, including OnLoadSettings setHost overrides.
* Cache misses in an envelope; bypass shared state inside transactions. A five
* minute TTL also bounds staleness for writes made outside this application.
*
* CLI may have no host: retain the original whereDomain(null) lookup and
* bypass shared host caching rather than treating null as an empty domain.
* Custom global scopes and retrieved observers retain their original database path.
*
* @param string|null $host Exact host used by the existing domain query, or null in CLI.
* @return sMultisite|null
* @since 1.2.0
*/
public static function find(?string $host): ?sMultisite
{
$model = new sMultisite();
$connection = $model->getConnection();
$read = static fn () => sMultisite::whereDomain($host)->whereActive(1)->first();
// Custom scopes/observers may depend on context outside the exact host.
$events = $model->getEventDispatcher();
if ($host === null || $connection->transactionLevel() > 0
|| $model->getGlobalScopes() !== []
|| ($events && $events->hasListeners('eloquent.retrieved: ' . sMultisite::class))) {
return $read();
}

$key = self::key($connection);
$generation = Cache::remember($key, 300, static fn () => bin2hex(random_bytes(16)));
$entryKey = $key . '.' . $generation . '.' . hash('sha256', $host);
$entry = Cache::get($entryKey);
if (is_array($entry) && array_key_exists('attributes', $entry)) {
return $entry['attributes'] === null ? null
: $model->newFromBuilder($entry['attributes'], $connection->getName());
}

// Return the queried model on a miss so retrieved observers fire only once.
$domain = $read();
Cache::put($entryKey, ['attributes' => $domain?->getRawOriginal()], 300);
return $domain;
}

}
4 changes: 3 additions & 1 deletion src/sMultisiteServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,14 @@ public function boot()
/**
* Register bindings and services in the container.
*
* This method is used to bind classes or interfaces into the service container.
* Bindings retain the existing package lifecycle; domain cache invalidation observes
* writes from manager, frontend, migrations and CLI before plugins run.
*
* @return void
*/
public function register()
{
\Seiger\sMultisite\Support\ActiveDomainCache::listen($this->app['events']);
// Add plugins to Evolution CMS
$this->loadPluginsFrom(dirname(__DIR__) . '/plugins/');
$this->app->singleton(sMultisite::class);
Expand Down
7 changes: 7 additions & 0 deletions tests/runtime/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Runtime regression checks

Run each `*-test.php` with `PACKAGE_AUTOLOAD` pointing at an installed Evolution CMS 3.6.x `core/vendor/autoload.php`. The test bootstrap loads this package from the checkout ahead of any consumer classmap. Fixtures use isolated SQLite databases and temporary files; no site database is used. The CI matrix executes PHP 8.4 and 8.5.

Shared entries use a five-minute TTL. Observed application writes invalidate immediately; writes made directly outside the application require a cache clear for immediate visibility. Transactions bypass shared entries and invalidate again after the outer commit/rollback.

Custom model scopes and retrieved observers keep the uncached database path, because they may depend on state not represented by the package cache key.
24 changes: 24 additions & 0 deletions tests/runtime/bootstrap.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

// Load a caller-supplied Evolution dependency tree without coupling tests to a site.
$autoload = getenv('PACKAGE_AUTOLOAD') ?: dirname(__DIR__, 2) . '/vendor/autoload.php';
if (!is_file($autoload)) {
throw new RuntimeException('Set PACKAGE_AUTOLOAD to an Evolution CMS core vendor/autoload.php');
}
require $autoload;
spl_autoload_register(static function (string $class): void {
$prefix = 'Seiger\sMultisite\\';
if (str_starts_with($class, $prefix)) {
$file = dirname(__DIR__, 2) . '/src/' . str_replace('\\', '/', substr($class, strlen($prefix))) . '.php';
if (is_file($file)) {
require $file;
}
}
}, true, true);
// Make warnings and PHP-version deprecations visible as regression failures.
set_error_handler(static function (int $severity, string $message, string $file, int $line): bool {
if (!(error_reporting() & $severity)) {
return false;
}
throw new ErrorException($message, 0, $severity, $file, $line);
});
Loading
Loading