Performance: OPcache sizing, asset compression/caching, cover transcoder, disk-read transcoding (#5704)
* Opcache: keep stale cache files from filling the cache
The docker-compose php.ini capped opcache at 72 MB, below PHP's own
128 MB default. UNA's hash-named cache/*.php files (db, menu, template
caches) are regenerated constantly and were unlinked without
opcache_invalidate(), so every deleted file left a dead entry behind:
opcache never evicts, and once the file is gone opcache_invalidate()
can no longer resolve its path. After 11 days a local dev cache held
778 cache/ entries against 196 files on disk, was full with 14% wasted
(below the 20% restart threshold) and recompiled 110 of the home page's
397 scripts on every request: 0.82 s median TTFB against 0.27 s once
everything fits.
- scripts/docker-compose/php.ini: opcache.memory_consumption 72 -> 256 MB,
opcache.interned_strings_buffer 16 -> 32 MB. validate_timestamps and
revalidate_freq stay as they are; measured, they make no difference.
- BxDolCacheFile: invalidate the opcache entry before unlinking a cache
file (delData, removeAllByPrefix, TTL expiry) so its memory becomes
reclaimable wasted space instead of a dead entry. Inherited by
BxDolCacheFileHtml and used by the Studio "clear cache" actions.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Perf: compress and cache static assets, add font-display
Lighthouse on the local stack flagged three delivery problems that are
server-config, not application, issues. The HTML document was served
uncompressed (238 KB), static assets carried no Cache-Control/Expires,
and the Font Awesome faces had no font-display.
- scripts/docker-compose/nginx.conf: enable gzip for text responses and
add a 30-day cache for real static files. Virtual asset-looking paths
(/s/<obj>/<file>.png handled by storage.php, or missing files routed to
r.php) are sent to a @dynamic fallback via try_files, so storage and the
r.php rewrite keep working. The document now gzips 238 KB -> 29 KB.
- .htaccess: mod_deflate + mod_expires equivalents for the Apache
deployment, both IfModule-guarded so they no-op where the modules are
absent. text/html is deliberately excluded from expires.
- scripts/railway/Dockerfile: a2enmod deflate expires so the .htaccess
directives are active on Railway (rewrite was already enabled).
- modules/boonex/fontawesome/template/css/fonts-{all,light,duotone}.css:
font-display: swap on every @font-face so icon text is not blocked on
the webfont.
Local Lighthouse (desktop) 85 -> 88, LCP ~1.8s; the compression,
cache-lifetime, render-blocking and font-display audits now pass. The
remaining opportunities (unused JS/CSS, cover image format) need larger
build-side changes and are left for a follow-up.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Perf: optimize the site cover transcoder output
The homepage cover is produced by the sys_cover transcoder, whose only
filter forced the output type to PNG:
Resize {w:1920, h:720, force_type: png}
force_type does not re-encode, it only renames the file after the resize
(Intervention Image picks the encoder from the temp file's extension,
which is still the source extension at that point). So a JPEG cover came
out as JPEG bytes (GD, quality 90) with a .png name, served as image/png
at 417 KB - larger than needed and with the wrong mime. Module covers
such as bx_posts_cover have no force_type and keep the compressed source
format, which is why only the site cover stood out.
Two changes:
- BxDolTranscoder::applyFilter_Resize now honours an optional `quality`
filter param, applied via BxDolImageResize::setJpegQuality and restored
afterwards (the resizer is a singleton reused for every image in the
request). BxDolImageResize gains a getJpegQuality getter for the
restore. No behaviour change for transcoders that do not set `quality`.
- install/sql/system.sql: sys_cover Resize filter is now
{w:1920, h:720, quality:82} - force_type dropped so a JPEG source is
served as image/jpeg, and quality 82 re-encodes the hero. Measured on
the sample cover: 417 KB -> 285 KB (-32%), same 1372x514 dimensions.
Existing installs (the seed only covers fresh ones):
UPDATE sys_transcoder_filters
SET filter_params='a:3:{s:1:"w";s:4:"1920";s:1:"h";s:3:"720";s:7:"quality";s:2:"82";}'
WHERE transcoder_object='sys_cover';
UPDATE sys_objects_transcoder SET ts=UNIX_TIMESTAMP() WHERE object='sys_cover';
The ts bump makes each cached cover regenerate once at the new quality.
sys_cover_unit_profile and sys_cover_preview carry the same force_type
pattern and can get the same treatment in a follow-up; left out here to
keep the change to the audited homepage cover.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Perf: add static-asset cache headers to the Caddyfile
scripts/Caddyfile compressed responses (encode zstd br gzip) but set no
Cache-Control on static files, so FrankenPHP/Caddy deployments missed the
long-lived caching nginx and Apache now get. Add a matcher for the static
asset extensions with a 30-day public Cache-Control. Storage URLs (/s/*)
are excluded because storage.php sets its own headers, including shorter,
private ones for private files.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Perf: optimize the profile cover transcoder output
Follow-up to the sys_cover fix: sys_cover_unit_profile (profile page
cover) carried the same force_type=png, which only renames and does not
re-encode, so JPEG covers were served as oversized image/png. Switch its
Resize filter to {w:640, h:240, quality:82}, matching sys_cover.
Existing installs:
UPDATE sys_transcoder_filters
SET filter_params='a:3:{s:1:"w";s:3:"640";s:1:"h";s:3:"240";s:7:"quality";s:2:"82";}'
WHERE transcoder_object='sys_cover_unit_profile';
UPDATE sys_objects_transcoder SET ts=UNIX_TIMESTAMP() WHERE object='sys_cover_unit_profile';
sys_cover_preview (120x45) already forces jpg and is left as-is.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Fix + perf: transcode local-storage sources from disk, not over HTTP
The image transcoder fetched its source file over HTTP from the storage
object's public URL (BX_DOL_URL_ROOT) even when that file sits on the
local disk. Every thumbnail/cover/avatar therefore made a self-referential
HTTP round trip, and in any environment where the app cannot reach its own
public URL from inside (the classic containerised dev setup where the app
talks to the web server by service name, not localhost:<published-port>)
transcoding failed outright - uploads stored fine but their thumbnails
came back 404, so profile pictures and covers showed as broken images.
- BxDolStorage: new getFileLocalPath($mixedHandler) returning false by
default (remote engines cannot answer).
- BxDolStorageLocal: override returning the file's absolute on-disk path
(getObjectBaseDir + stored path), or false if it is not present.
- BxDolTranscoder::storeFileLocally_Storage: copy the source straight from
disk when getFileLocalPath resolves, and fall back to the previous HTTP
fetch for remote storage engines or if the local copy fails.
Verified locally (php container cannot reach localhost:8088): bx_persons_thumb,
bx_persons_cover and bx_persons_avatar_big now transcode to real JPEGs
instead of 404. Removing the round trip is also a small speed-up in
production for the common local-storage case.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Review cleanups: upgrade migration, JS mime coverage, base iconset font-display
- upgrade/files/15.0.0.RC1-15.0.0.RC2/sql.sql: migrate existing installs to
the new sys_cover / sys_cover_unit_profile Resize filters (quality 82, no
force_type) and bump their ts so every cached cover regenerates once.
Verified locally: the site cover regenerates through transcode() from
disk at 285 KB.
- .htaccess: list text/javascript alongside application/javascript for
both mod_deflate and mod_expires (Debian-based Apache images map .js to
text/javascript), and switch mod_expires off for sw.js.php, which PHP
emits as text/javascript and which must not get a 30-day lifetime.
- scripts/docker-compose/nginx.conf: add text/javascript to gzip_types so
PHP-emitted JS (sw.js.php) is compressed as well.
- template/css/icons.css: font-display: swap on the base Font Awesome
iconset faces, matching the bx_fontawesome module files.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Sonar: declare getJpegQuality visibility
SonarCloud failed the quality gate on the new code's reliability rating
because of php:S1784 - getJpegQuality() had no explicit visibility. Mark
it public (it is called from BxDolTranscoder::applyFilter_Resize).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Tests: make _t() available to unit tests
The global _t() is declared at the bottom of BxDolLanguages.php, so it
exists only once that class file has been loaded. On CI (fresh install via
install/cmd.php, phpunit 12) nothing in the PHPUnit bootstrap loaded it,
and BxDolServiceMacroTest::testCallMacroMalformed, which compares against
_t('_sys_macros_malformed'), errored five times with "Call to undefined
function _t()". Every master run has failed this way since the
integration tests landed on 2026-09-15.
Load BxDolLanguages explicitly in tests/bootstrap.php, as api.php does.
The full Units suite passes locally before and after (419 tests).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* CI: create PR preview environments by duplicating a template
Railway services are project-scoped. mariadb and una were created by the
first preview and survive environment teardown, so in every later PR
environment `railway add --service una` refused ("already exists"), the
environment held no service instances, `railway variable set` reported
"ServiceInstance not found" and `railway up` failed with 502 before any
build. Every PR preview since #5679 failed this way.
The deploy job now creates the PR environment with `railway environment
new --duplicate pr-template`, and recreates it when a leftover from a
failed run has no una instance (`railway service status` is the probe).
pr-template is a duplicate of a working preview environment: a deployed
mariadb plus an empty una instance carrying the MYSQL* connection
variables and UNA_DB_ENGINE / UNA_HASH_SECRET / UNA_DB_SOCK, so a second
push into the same environment no longer hits the marker-header fatal.
Setting the una variables doubles as the readiness check for the
duplicated instance.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* remove changes in sql upgrade script
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Aleksandr Trofimov <at@unacms.com>
Co-authored-by: Cursor <cursoragent@cursor.com>