Invalidate cache based on event dates

Hello Kirby Forum!

I’m currently working on a site that will have pages with events. The events are categorized by future, current and past. Could anyone of you recommend me a good solution to handle the cache of those pages? In my mind the ideal is to only invalidate the cache when an event will transition into a new category.

What I’m currently working with looks like this:

<?php
$cache = kirby()->cache('pages');
$cacheKey = 'home-events';
if (!$html = $cache->get($cacheKey)) {
    ob_start();
    ?>
<?php snippet('layout', slots: true) ?>
<!-- Template code -->
<?php endsnippet() ?>

<?php
$html = ob_get_clean();

// Calculate cache expiration until next status change
$nextTransition = site()->getNextEventTransition();

if ($nextTransition) {
    // Set next tranisition
    $minutesUntilTransition = ceil(($nextTransition - time()) / 60);
    $cacheMinutes = max(5, $minutesUntilTransition);
} else {
    // No upcoming transitions
    $cacheMinutes = 60 * 24 * 365; // cache for a 1 year
}

$cache->set($cacheKey, $html, $cacheMinutes);

}
echo $html;?>

I’m a bit of a rookie when it comes to caching solutions so I’m wondering if there are better/smarter ways of doing this? Another solution is of course a cron job but I’m not sure yet if I will be available on the server the site will be deployed to.

Thanks!

I’m also rookie on caching and I’m not sure I get exactly what is going on but it seems like a interesting solution I wouldn’t have thought of. As long as it works and you are not getting side effects (maybe the getNextEventTransition is expensive?) or it messes to much with your templates and become hard to scale with the output buffering etc.

I have a site with events grouped in a similiar way (past, upcoming: today, this week, next week, later etc).

I have a daily maintenance script that flushes the sites pages cache at midnight. You can do that most elegantly with a cron job as you say. I would go for that if you can.

In my case cron was not available on my host, so I did a poor-mans cron solution. On affected routes, I call a function that checks a file that stores a timestamp of the last time the script flushed the cache. If that timestamp is before “today midnight”, it flushes the cache and updates the last run time.

If you can make use of real cron maybe you could also rebuild the cache programatically after. In contrast, poor-mans cron is triggered by regular visit to a route, so I wouldn’t put any heavy operations there).

As an alternative to real cron and better than poor-mans, you could look into using Zapier to trigger a route/webhook on a given interval, where you do your thing.

Thank you for taking the time answer me so thoroughly! And sorry for this late answer!

I think your poor-mans cron solution sounds interesting! It seems like I won’t be able to use cron sadly. So Ill look into if a poor mans cron solution is a better than my current soluton. It isn’t possible to regenerate the cache directly after you flush it with the poor-mans cron?

Here is my code for the getNextEventTransition() If you would like to try something similar, I have it in a custom plugin. Currently it goes through all events which could become expensive over time. I’m think of changing the page template to a different one when the endDate have passed. Then I could only check pages with a template that would guarantee them to be either upcoming/current, which in my case will be 3-5 pages.

    'siteMethods' => [
        'getNextEventTransition' => function() {
            $timezone = new DateTimeZone('Europe/Copenhagen');
            $now = new DateTime('now', $timezone);
            $nowTimestamp = $now->getTimestamp();
            $transitions = [];
            
            $exhibitions = site()->find('exhibitions')->children()->listed();
            $projects = site()->find('projects')->children()->listed();
            
            if (!$exhibitions && !$projects) {
                return null;
            }

            $allEvents = new Pages();
            if ($exhibitions) {
                $allEvents = $allEvents->add($exhibitions);
            }
            if ($projects) {
                $allEvents = $allEvents->add($projects);
            }
            
            foreach ($allEvents as $event) {
                
                $startDate = new DateTime($event->startDate()->value(), $timezone);
                $endDate = new DateTime($event->endDate()->value() . ' 23:59:59', $timezone);
                
                $startTimestamp = $startDate->getTimestamp();
                $endTimestamp = $endDate->getTimestamp();
                
                // Add transition points
                if ($startTimestamp > $nowTimestamp) {
                    $transitions[] = $startTimestamp; // upcoming -> current
                }
                if ($endTimestamp > $nowTimestamp) {
                    $transitions[] = $endTimestamp + 1; // current -> past (1 second after end)
                }
            }
            
            // Return the earliest future transition
            return !empty($transitions) ? min($transitions) : null;
        }
    ]

Hi @Norreco

I think that, like everything, the answer will be, it depends, unfortunately.
But may be I can help you with your decision to achieve your problem.

First of all, the main question (I guess) is:

When should the cache be cleared?

  1. Only one time per day.

If that’s the case, may be you don’t really need to be worry about it and use the expiration third parameter in the set method;

$timezone = new DateTimeZone("Europe/Copenhagen");
$now = new DateTime("now", $timezone);
$end = (new DateTime("today", $timezone))->setTime(23, 59, 59);

$diff = $now->diff($end);
$minutes = $diff->h * 60 + $diff->i;

$cache->set('key', $content, $minutes);

With the example, the cache will only last for the current day, and the next day, the first user to consume the content will cache it for the current day.

  1. If the events change multiple times in the same day

If that’s the case, you need to invalidate the cache when an event is created, updated, duplicate or deleted.
For this case, hooks are your best friends.

// site/config/config.php

return [
  'hooks' => [
    'page.create:after' => function (Kirby\Cms\Page $page) {
        $this->cache('pages')->remove($key);
     },
    'page.update:after' => function (Kirby\Cms\Page $newPage, Kirby\Cms\Page $oldPage){
        $this->cache('pages')->remove($key);
    },
    'page.duplicate:after' => function (Kirby\Cms\Page $duplicatePage, Kirby\Cms\Page $originalPage) {
        $this->cache('pages')->remove($key);
    }
    'page.delete:after' => function (bool $status, Kirby\Cms\Page $page) {
        $this->cache('pages')->remove($key);
    }
  ]
];

Now the hooks will keep your cache cleared when there is a page change. In the past example, every time there is a page change, it will clear the cache; you should only dispatch when the exhibitions or projects are updated.

The next question for me will be:

Where should I have to put the code?

Here you have a couple of places too:

  1. Model

For me every type of page should have a cache, and every page, should be responsible of their content.

// site/models/exhibitions.php
<?php

use Kirby\Cms\Page;

class TravelPage extends Page
{
    public function getExhibitionChildrenCache()
    {
        $exhibitions = $this->children()->listed();
        //your logic for events
        kirby()->cache('pages')->set('exhibitions', $content, $minutes);
    }
}

And the same for your `projects`, now in your page you can access them with:

$content = site()->find('exhibitions')->getExhibitionChildrenCache();
  1. Controller

If a model is confusing, you can always use this in a controller:

<?php

use Kirby\Cms\App;
use Kirby\Cms\Page;
use Kirby\Cms\Site;

return function (Page $page, Site $site, App $kirby) {
        $exhibitions = $site->find('exhibitions')->children()->listed();
        //your logic for events
        kirby()->cache('pages')->set('exhibitions', $content, $minutes);

    return [
        'exhibitions' => $content,
    ];
};

Now in your template will have an $exhibitions variable with your cache.

For me I will do (for model or controller) a validation, to get always the cache data:

if (! $kirby->cache('pages')->exists('exhibitions')) {
     //your logic for events
     $kirby->cache('pages')->set('exhibitions', $content, $minutes);
}

$exhibitions = $kirby->cache('pages')->get('exhibitions')

If you have control of everything

What I mean is that if you can install a cron job on the server, you can use composer for your Kirby installation, and you can also install Kirby CLI as a global dependency.

If you can do this, you can use Kirby Scheduler

With that, the cache and clearance could be trivial.

// site/config/config.php
use Beebmx\KirbScheduler\Schedule;
use Carbon\Carbon;

return [
  'beebmx.scheduler' => [
      'timezone' => 'Europe/Copenhagen',
      'schedule' => function (Schedule $schedule) {
         $schedule->call(function() {
             $now = Carbon::now('Europe/Copenhagen');
             $end = $now->copy()->endOfDay();
             $minutes = (int) ceil($now->diffInSeconds($end) / 60);

             //your logic for events

             kirby()->cache('pages')->set('exhibitions', $content, $minutes);
         })->daily()->at('5:00');
      },
   ],
];

Notes

  • I would try to avoid mixing code (logic with templates).
  • Define when the cache will be stored and when it will be cleared.
  • Kirby offers multiple ways to implement code; use the one you feel comfortable with.