Add PreRenderDynamicImagesCron for dynamic image variant caching and idempotency guard for order bookings.

This commit is contained in:
Jeppe Bundgaard
2026-03-18 13:59:48 +01:00
parent 90acf50b55
commit 6b928cfce7
4 changed files with 429 additions and 1 deletions
+377
View File
@@ -11,11 +11,14 @@ use classes\xlvask;
use classes\slack as Slack;
use classes\email as Email;
use classes\gatewayapi as GatewayAPI;
use dynamicimages\images\machine_1;
use goals\classes\goals_criteria;
use goals\services\goals_progress_alert_renderer;
use goals\helpers\goals_criteria_progress_alert_destination as Dest;
use goals\helpers\goals_criteria_progress_alert_frequency as Freq;
use objects\department_lanes_o;
use objects\department_goals_o;
use objects\department_selfserve_tasks_o;
use objects\departments_o;
use objects\bookings_o;
use objects\logs_o;
@@ -96,6 +99,12 @@ $cron_tasks = [
'next_run' => 0,
'function' => 'SystemSearchCacheMaintenanceCron',
],
'PreRenderDynamicImagesCron' => [
'interval' => 900, // 15 minutes
'last_run' => 0,
'next_run' => 0,
'function' => 'PreRenderDynamicImagesCron',
],
'GoalsProgressAlertsCron' => [
'interval' => 60, // check every minute
'last_run' => 0,
@@ -249,6 +258,374 @@ function SystemSearchCacheMaintenanceCron(): void
}
}
function PreRenderDynamicImagesCron(): void
{
$start = microtime(true);
$cacheTtlSeconds = 86400;
$maxRendersPerRun = 500;
if (!defined('redis')) {
warn('PreRenderDynamicImagesCron skipped: Redis is unavailable.');
return;
}
if (!extension_loaded('imagick')) {
warn('PreRenderDynamicImagesCron skipped: Imagick extension is not loaded.');
return;
}
try {
$laneRows = (new department_lanes_o())->getFieldsWhere([
'deleted_at' => null,
'dynamic_image_id' => '!null',
], [
'id',
'department',
'dynamic_image_id',
'machine_type_id',
]);
} catch (Throwable $e) {
warn('PreRenderDynamicImagesCron failed to read lanes: ' . $e->getMessage());
return;
}
if (!is_array($laneRows) || count($laneRows) === 0) {
echo "[" . date('Y-m-d H:i:s') . "][CRON] PreRenderDynamicImagesCron: no dynamic-image lanes found.\n";
return;
}
$variantsByCacheKey = [];
$unsupportedImageIds = [];
foreach ($laneRows as $laneRow) {
$dynamicImageId = (int)($laneRow['dynamic_image_id'] ?? 0);
if ($dynamicImageId <= 0) {
continue;
}
if ($dynamicImageId !== 1) {
$unsupportedImageIds[$dynamicImageId] = true;
continue;
}
$taskGroups = collectDynamicImageTaskGroupsForLane($laneRow);
if ($taskGroups === []) {
$taskGroups = [[
'vehicle_type' => null,
'rows' => [],
]];
}
foreach ($taskGroups as $group) {
$groupVariants = buildDynamicImageVariantsForTaskGroup(
$dynamicImageId,
normalizeDynamicImageVehicleType($group['vehicle_type'] ?? null),
is_array($group['rows'] ?? null) ? $group['rows'] : []
);
foreach ($groupVariants as $variant) {
$cacheKey = buildDynamicImageCacheKey($variant);
if ($cacheKey === '') {
continue;
}
$variantsByCacheKey[$cacheKey] = $variant + ['cache_key' => $cacheKey];
}
}
}
$discovered = count($variantsByCacheKey);
$rendered = 0;
$alreadyCached = 0;
$failed = 0;
$skippedByCap = 0;
$attempted = 0;
foreach ($variantsByCacheKey as $cacheKey => $variant) {
if (redis->exists($cacheKey)) {
$alreadyCached++;
continue;
}
if ($attempted >= $maxRendersPerRun) {
$skippedByCap++;
continue;
}
$attempted++;
$imageData = renderDynamicImageVariant(
(int)$variant['dynamic_image_id'],
$variant['buttons'],
(int)$variant['current_step'],
(bool)$variant['only_current_step']
);
if ($imageData === null) {
$failed++;
continue;
}
redis->setEx($cacheKey, $imageData, $cacheTtlSeconds);
$rendered++;
}
$duration = round(microtime(true) - $start, 2);
$unsupportedList = empty($unsupportedImageIds) ? 'none' : implode(', ', array_keys($unsupportedImageIds));
echo "[" . date('Y-m-d H:i:s') . "][CRON] PreRenderDynamicImagesCron completed. "
. "discovered=$discovered rendered=$rendered cached=$alreadyCached failed=$failed "
. "skipped_by_cap=$skippedByCap unsupported_image_ids=$unsupportedList duration={$duration}s\n";
}
/**
* @return array<int,array{vehicle_type:int|null,rows:array<int,array<string,mixed>>}>
*/
function collectDynamicImageTaskGroupsForLane(array $laneRow): array
{
$laneId = (int)($laneRow['id'] ?? 0);
$departmentId = (int)($laneRow['department'] ?? 0);
$machineTypeId = normalizeDynamicImageVehicleType($laneRow['machine_type_id'] ?? null);
$tasksObject = new department_selfserve_tasks_o();
$rows = [];
if ($machineTypeId !== null) {
try {
$rows = $tasksObject->getTasksForMachineType($machineTypeId);
} catch (Throwable $e) {
warn('PreRenderDynamicImagesCron: failed reading machine-type tasks for lane #' . $laneId . ': ' . $e->getMessage());
$rows = [];
}
}
if (!is_array($rows) || $rows === []) {
try {
$rows = $tasksObject->getFieldsWhere([
'department' => $departmentId,
'lane' => $laneId,
'deleted_at' => null,
], [
'id',
'product',
'order_priority',
'buttons',
'dynamic_images_vehicle_type',
]);
} catch (Throwable $e) {
warn('PreRenderDynamicImagesCron: failed reading legacy tasks for lane #' . $laneId . ': ' . $e->getMessage());
$rows = [];
}
}
if (!is_array($rows) || $rows === []) {
return [];
}
$grouped = [];
foreach ($rows as $row) {
$vehicleType = normalizeDynamicImageVehicleType($row['dynamic_images_vehicle_type'] ?? null);
if ($vehicleType === null) {
$productVehicleType = normalizeDynamicImageVehicleType($row['product'] ?? null);
if ($productVehicleType !== null && $productVehicleType > 0) {
$vehicleType = $productVehicleType;
}
}
$groupKey = $vehicleType === null ? 'null' : 'v' . $vehicleType;
if (!isset($grouped[$groupKey])) {
$grouped[$groupKey] = [
'vehicle_type' => $vehicleType,
'rows' => [],
];
}
$grouped[$groupKey]['rows'][] = $row;
}
return array_values($grouped);
}
/**
* @param array<int,array<string,mixed>> $taskRows
* @return array<int,array{dynamic_image_id:int,buttons:array<int>|null,current_step:int,only_current_step:bool,vehicle_type:int|null}>
*/
function buildDynamicImageVariantsForTaskGroup(int $dynamicImageId, ?int $vehicleType, array $taskRows): array
{
$variants = [];
usort($taskRows, static function (array $a, array $b): int {
$priorityA = (int)($a['order_priority'] ?? 0);
$priorityB = (int)($b['order_priority'] ?? 0);
if ($priorityA !== $priorityB) {
return $priorityA <=> $priorityB;
}
return ((int)($a['id'] ?? 0)) <=> ((int)($b['id'] ?? 0));
});
$buttonSets = [null];
$runningButtons = [];
foreach ($taskRows as $row) {
$buttons = parseDynamicImageButtons($row['buttons'] ?? null);
if ($buttons !== []) {
$buttonSets[] = $buttons;
$runningButtons = mergeUniqueIntValues($runningButtons, $buttons);
$buttonSets[] = $runningButtons;
}
}
$dedupedButtonSets = [];
foreach ($buttonSets as $buttonSet) {
$signature = $buttonSet === null ? 'null' : json_encode(array_values($buttonSet));
if ($signature === false || isset($dedupedButtonSets[$signature])) {
continue;
}
$dedupedButtonSets[$signature] = $buttonSet;
}
foreach ($dedupedButtonSets as $buttons) {
$buttonCount = is_array($buttons) ? count($buttons) : 0;
$maxStep = min(max(0, $buttonCount + 3), 15);
for ($currentStep = 0; $currentStep <= $maxStep; $currentStep++) {
$variants[] = [
'dynamic_image_id' => $dynamicImageId,
'buttons' => $buttons,
'current_step' => $currentStep,
'only_current_step' => false,
'vehicle_type' => $vehicleType,
];
$variants[] = [
'dynamic_image_id' => $dynamicImageId,
'buttons' => $buttons,
'current_step' => $currentStep,
'only_current_step' => true,
'vehicle_type' => $vehicleType,
];
}
}
return $variants;
}
/**
* @param array<int>|null $buttons
*/
function buildDynamicImageCacheKey(array $variant): string
{
$cacheParams = [
'dynamic_image_id' => (int)($variant['dynamic_image_id'] ?? 0),
'buttons' => $variant['buttons'] ?? null,
'current_step' => (int)($variant['current_step'] ?? 0),
'only_current_step' => (bool)($variant['only_current_step'] ?? false),
'vehicle_type' => $variant['vehicle_type'] ?? null,
];
$json = json_encode($cacheParams);
if ($json === false) {
return '';
}
return 'dynamic_image:' . md5($json);
}
/**
* @param array<int>|null $buttons
*/
function renderDynamicImageVariant(int $dynamicImageId, ?array $buttons, int $currentStep, bool $onlyCurrentStep): ?string
{
$image = null;
try {
switch ($dynamicImageId) {
case 1:
$image = new machine_1();
break;
default:
warn('PreRenderDynamicImagesCron: unsupported dynamic_image_id=' . $dynamicImageId);
return null;
}
if (is_array($buttons)) {
$image->highlighted_buttons = $buttons;
}
$image->current_step = max(0, $currentStep);
$image->only_generate_current_step = $onlyCurrentStep;
$image->setup();
$dataUri = $image->exportAsBase64('png');
if (!preg_match('/^data:image\/png;base64,(.*)$/', $dataUri, $matches)) {
return null;
}
$imageData = base64_decode($matches[1], true);
if ($imageData === false) {
return null;
}
return $imageData;
} catch (Throwable $e) {
warn('PreRenderDynamicImagesCron: render failed for dynamic_image_id=' . $dynamicImageId . ': ' . $e->getMessage());
return null;
} finally {
if (is_object($image) && method_exists($image, 'clearImage')) {
try {
$image->clearImage();
} catch (Throwable) {
}
}
}
}
/**
* @param mixed $value
* @return array<int>
*/
function parseDynamicImageButtons(mixed $value): array
{
if ($value === null || $value === '') {
return [];
}
try {
$normalized = department_selfserve_tasks_o::normalizeButtonsInput($value);
return array_values(array_map('intval', $normalized));
} catch (Throwable) {
return [];
}
}
/**
* @param mixed $value
*/
function normalizeDynamicImageVehicleType(mixed $value): ?int
{
if ($value === null) {
return null;
}
if (is_string($value)) {
$value = trim($value);
if ($value === '' || strtolower($value) === 'null') {
return null;
}
}
if (!is_numeric($value)) {
return null;
}
$normalized = (int)$value;
if ($normalized < 0) {
return null;
}
return $normalized;
}
/**
* @param array<int> $base
* @param array<int> $append
* @return array<int>
*/
function mergeUniqueIntValues(array $base, array $append): array
{
$result = $base;
foreach ($append as $value) {
$intValue = (int)$value;
if (!in_array($intValue, $result, true)) {
$result[] = $intValue;
}
}
return array_values($result);
}
/**
* GoalsProgressAlertsCron
*
@@ -561,7 +561,7 @@ class orderBookingRoute
'reg_1' => (string)($data['reg_1'] ?? ''),
'reg_2' => $this->normalizeNullableString($data['reg_2'] ?? null),
'reg_3' => $this->normalizeNullableString($data['reg_3'] ?? null),
'datetime' => (string)($data['datetime'] ?? ''),
'datetime' => $this->normalizeDatetimeForFingerprint((string)($data['datetime'] ?? '')),
'note' => $this->normalizeNullableString($data['note'] ?? null),
'reference' => $this->normalizeNullableString($data['reference'] ?? null),
'po' => $this->normalizeNullableString($data['po'] ?? null),
@@ -619,6 +619,25 @@ class orderBookingRoute
return $normalized;
}
private function normalizeDatetimeForFingerprint(string $datetime): string
{
$value = trim($datetime);
if ($value === '') {
return '';
}
$formats = ['Y-m-d H:i:s', 'Y-m-d H:i', 'Y-m-d'];
foreach ($formats as $format) {
$parsed = \DateTime::createFromFormat($format, $value);
if ($parsed !== false) {
// Use minute precision so rapid retries do not bypass idempotency because of second-level drift.
return $parsed->format('Y-m-d H:i');
}
}
return $value;
}
private function getBookingFromIdempotencyCache(string $fingerprint): ?order_bookings_o
{
try {
@@ -0,0 +1,18 @@
<?php
it('guards order booking creation with redis-backed idempotency', function (): void {
$routeFile = app_path('routes/orderBookingRoute.php');
expect(is_file($routeFile))->toBeTrue();
$code = (string)file_get_contents($routeFile);
$normalized = preg_replace('/\s+/', ' ', $code);
expect($normalized)->toContain('$fingerprint = $this->buildBookingCreationFingerprint($data);');
expect($normalized)->toContain("'datetime' => \$this->normalizeDatetimeForFingerprint((string)(\$data['datetime'] ?? ''))");
expect($normalized)->toContain("return \$parsed->format('Y-m-d H:i');");
expect($normalized)->toContain('reserveBookingCreationSlot($fingerprint)');
expect($normalized)->toContain('storeBookingIdempotencyResult($fingerprint, (int)$order_bookings_o->id)');
expect($normalized)->toContain('clearBookingCreationSlot($fingerprint)');
expect($normalized)->toContain("order_booking:idempotency:lock:");
expect($normalized)->toContain("order_booking:idempotency:result:");
});
@@ -0,0 +1,14 @@
<?php
it('registers dynamic image pre-render cron task and related helpers', function (): void {
$content = file_get_contents(app_path('cron/Cron.php'));
expect($content)->not->toBeFalse();
expect($content)->toContain('PreRenderDynamicImagesCron');
expect($content)->toContain("'interval' => 900");
expect($content)->toContain('collectDynamicImageTaskGroupsForLane');
expect($content)->toContain('buildDynamicImageVariantsForTaskGroup');
expect($content)->toContain('renderDynamicImageVariant');
expect($content)->toContain('dynamic_image:');
expect($content)->toContain('machine_1');
});