Surviving a Core Upgrade

4 views Markdown

What the upgrade run does step by step, what it can take back, and the checks to run before and after.

Overview

An upgrade is a sequence of steps with a time budget, plus a ledger of everything it overwrote. A health gate sits between the last write and the version stamp.

Prerequisites

  • A second installation you can break. Not the system that serves customers, and not a copy sharing its database.
  • A real backup taken before the run, and verified.
  • The current version from coremio/VERSION, written down before you start.
  • Working Without Touching the Core: every check below assumes your code sits behind a seam.

Structure

The Run

One version is five steps in a fixed order. A chained upgrade applies each fully and in order, by the code of the version before it.

StepWhat it doesCan it be taken back?
backupOptional. Database, files and uploads, before anything is touchedNot applicable, it only reads
downloadFetches the package into the work directoryYes, nothing outside the work directory changed
extractUnpacks it, a batch of entries per sliceYes, same reason
databaseRuns the release's schema statements once. Already-applied statements are skipped, out-of-order ones are replayed at the endNo. Recorded as forward-only and named to the operator
configurationRuns the release's configuration script, then merges language files and notification templatesMerges yes, script no: arbitrary code cannot be pre-imaged
applyCopies the package's files over the system, then the delete manifest, health gate and version stampYes, from the ledger, once
finishSweeps the work directoryNot applicable

Each step runs until its time budget expires, writes its position into a cursor, and returns. A web request gets sixty percent of the configured execution limit, clamped between 5 and 45 seconds. The command-line slice the minutely cron uses gets 300 seconds. The cron handler stops starting new slices after 1440 seconds of its own 1800 second ceiling.

The Pre-Image Ledger

One directory per run under coremio/storage/updates-preimage, outside the work directory so it outlives the run. Every overwrite and manifest deletion is copied here first, and the copy kept is the pre-run state.

EntryWhat is in itWhat reads it
meta.jsonSource version, versions applied, whether it was a re-apply, the state beforeThe rollback button, deciding whether it can be offered
files/The pre-run copy of every file overwritten or deletedThe restore, which copies all of it back
added.listPaths the run created that did not exist beforeThe restore, which deletes them (undoing an addition is a removal)
changed.listFiles whose contents actually changed, by checksumThe health gate, which lints exactly this list and nothing else
forward.listWhat cannot be undone: the schema migration, the release configuration scriptThe rollback summary, which names them to the operator
baseline.json, gate.json, restored.jsonHealth before, health after, evidence of a restoreYou, when you need to know what the gate saw

The Health Gate

Health is measured twice: a baseline before the first byte of the apply step, then the gate. The verdict is relative, so a broken baseline rolls nothing back.

Three questions are asked, cheapest first. Do the changed PHP files still parse? Does a fresh process boot and reach the database? Does the system answer its own home page?

FieldMeaningEffect on the gate
errorsA check ran and failedTriggers the rollback, and the run fails as a health check failure
skippedA check could not run at allNo verdict. Recorded in the evidence file and otherwise ignored
httpA state, not a boolean: ok, unreachable, or the failureCompared against the baseline, and asked twice before it is believed

Walkthrough

Before

  1. Write your dependency surface as a file, not as a memory. Every hook name you listen to. Every class and method called from outside your directory. Every core table, column and configuration key you read.
  2. Grep your own tree for core paths and confirm you edited none. The ledger restores the previous release, not your version.
  3. Check that your module carries a manifest.json if it is meant to be updatable. Without one the updater never touches the directory.
  4. Run the whole upgrade on the copy first, from the panel and not only from cron.

After

  1. Run your compatibility probe before you open a page. Does every class and method you call still exist?
  2. Exercise each hook you depend on and confirm your canary recorded a hit. There is no runtime registry of hook names.
  3. Read the error log. An exception inside a listener is caught, logged and swallowed, so a broken listener looks like a hook that never fired.
  4. Check the configuration keys your code reads. The apply step skips an existing configuration file, and the module updater preserves your config.php. New keys are never created for you.
  5. Re-run your own schema guard if your module owns tables or added columns. The core upgrade knows nothing about them.
  6. Tell the operator the rollback window is one run, once.

When a Seam Moved

SymptomCheckUsually means
Your listener no longer runs and nothing is loggedSearch the hook catalogue for the name, then for a near neighbour of itThe hook was renamed, or its call site was removed. Your registration is still perfectly valid and attached to nothing
Your listener runs but the change is ignoredWhether the point is fired by value or by referenceIt became by-reference. Your return value is discarded, and the caller wants the argument modified in place
Fatal: call to an undefined methodThe class in the source, not the logThe name held, the method did not. Guard the call and degrade instead of throwing
Too few or too many argumentsThe parameters table on the hook's pageThe call site's argument list changed. Declare only the parameters you use and give the rest defaults
Your admin page returns not foundThe module type, before the routesA silently rejected admin area registration. Or a slug that now collides with a core route
It works from cron and dies from the panelfunction_exists('exec') in both contextsNot an upgrade regression. Shared hosting disables the process functions in the web pool; the command-line binary keeps them

Shipping Your Own Update

  1. Put a manifest.json in the module or theme directory.
  2. Keep the version there and nowhere else. The one inside config.php freezes at first install, because an update never writes that file.
  3. Never reuse or edit a version number after publishing it. It is the identity every installation compares against.
  4. Two things survive on the customer's disk that you did not send. Their config.php, skipped wherever one exists. And any file you removed from the package: an update overwrites but never deletes.
  5. Write the manifest last in your own tooling too. A half-finished apply must never claim a version that is not fully on disk.

Reference

The Upgrade API

coremio/helpers/UpdateRunner.php
const VERSION_STEPS  = ['download', 'extract', 'database', 'configuration', 'apply'];
const PREIMAGE_DIR   = 'updates-preimage';   // under coremio/storage/
const PREIMAGE_KEEP  = 3;                    // runs retained, pruned when a new run opens
const LINT_SECONDS   = 60.0;                 // gate lint budget, time not file count
const LINT_MAX_FILES = 2000;                 // safety brake behind the time budget
const HTTP_SETTLE_SECONDS = 5;               // waited before asking, and after restoring

public static function open(array $opts, string $workerId): int;
public static function tick(int $runId, string $workerId): array;
public static function health_probe(): array;
public static function restorable(): array;
public static function restore_last(): array;
public static function parked(array $run): bool;
coremio/helpers/updates.php (selected)
public static function check_state(): array;
public static function check_new_version(): array;
public static function next_versions(string|int $ver = 0): array;
public static function run_active(string $kind = 'core'): array;
public static function last_completed_run(string $kind = 'core'): array;
public static function run_get(int $id): array;
public static function sc_state(): array;

// coremio/helpers/license.php - what the installation currently claims to be.
public static function version($realtime = false): string;
UpdateRunner::health_probe() The same three questions asked outside an upgrade, with an empty changed-file list so the lint pass skips itself. Returns ['ok' => bool, 'errors' => string[], 'skipped' => string[], 'http' => string]. Use it as the shape your own probe copies.
UpdateRunner::restorable() Returns the run that can be undone, or an empty array. Empty when a run is active, when there is no completed run, or when the ledger has no metadata. Also when a restore already happened. Rollback is offered once per run.
Updates::run_active() A non-empty array while an upgrade is in flight. Worth checking at the top of your own scheduled work: a run in progress means the file tree is a mixture.
License::version() Reads the stamp the runner writes last, so it only reports a version that finished. Compare with version_compare(): as text, a two-digit minor sorts below a one-digit one.

The Update Manifest

It sits next to the module class, or next to the theme definition rather than replacing it.

manifest.json
{"type":"marketplace","name":"AcmeBilling","version":"1.2.0","last_updated":"2026-07-28"}
FieldRequiredWhat it does
typeAlwaysWhich publisher answers for this directory. An unknown value makes the whole manifest invalid rather than half read
nameStore productsThe product key. The directory name is not used; it is yours to choose
idMarketplace listingsThe listing identity, for the same reason: two developers can pick the same directory name
versionAlwaysThe installed version. This and only this drives the comparison
last_updatedOptionalShown to the operator. It takes part in no decision
Detection is automatic, installation is not

The daily task only looks for new versions and notifies the operator. No scheduled task installs an update.

Example

A compatibility probe your module can ship.

coremio/modules/Addons/Acme/src/Compat.php
namespace WISECP\Modules\Addons\Acme\Src;

class Compat
{
    // The dependency surface, as data. Everything this module touches outside its
    // own directory is listed here, so the probe is the list and not a rewrite.
    private const NEEDS_METHOD = [
        'Checkout::payment_methods',
        'Services::get',
        'Invoices::create',
    ];

    private const NEEDS_HOOK = [
        'filter:order.cart_totals',
        'action:order.checkout_completed',
    ];

    private const CORE_MIN = '5.0.0';
    private const CORE_MAX = '6.0.0';   // exclusive: a major bump is opt-in, never assumed

    /** @return array{ok:bool,errors:string[],skipped:string[],version:string} */
    public static function check(): array
    {
        $errors = $skipped = [];
        $core   = \License::version();

        if (version_compare($core, self::CORE_MIN, '<'))
            $errors[] = 'core ' . $core . ' is below the minimum ' . self::CORE_MIN;

        if (version_compare($core, self::CORE_MAX, '>='))
            $skipped[] = 'core ' . $core . ' is newer than this module was tested against';

        foreach (self::NEEDS_METHOD as $ref) {
            [$class, $method] = explode('::', $ref, 2);
            if (!class_exists($class))            $errors[] = 'missing class: ' . $class;
            elseif (!method_exists($class, $method)) $errors[] = 'missing method: ' . $ref;
        }

        // A capability, not a result. Under FPM these are usually disabled while the
        // same server's CLI keeps them, so "cannot ask" must never read as "failed".
        if (!function_exists('exec')) $skipped[] = 'subprocess probes (exec unavailable)';

        foreach (self::NEEDS_HOOK as $hook)
            if (!self::hook_seen($hook)) $skipped[] = 'hook not observed yet: ' . $hook;

        return ['ok' => !$errors, 'errors' => $errors, 'skipped' => $skipped, 'version' => $core];
    }

    /*
     * There is no runtime registry of hook NAMES: hooks are fired, not declared, and the
     * store is private. So the only honest answer is observational - the canary below
     * records a timestamp when the point actually fires, and "not seen" is skipped,
     * never an error, because it may simply not have been reached yet.
     */
    public static function canary(string $hook): void
    {
        $seen = (array) \Utility::jdecode((string) \Config::getd('acme_hook_seen'), true);
        $seen[$hook] = time();

        \Config::setd('acme_hook_seen', \Utility::jencode($seen));
    }

    private static function hook_seen(string $hook): bool
    {
        $seen = (array) \Utility::jdecode((string) \Config::getd('acme_hook_seen'), true);

        return (int) ($seen[$hook] ?? 0) > 0;
    }
}

The canary is registered next to the listener it watches, at a priority that puts it first.

coremio/modules/Addons/Acme/hooks.php
use WISECP\Modules\Addons\Acme\Src\Compat;

include_once __DIR__ . DS . 'src' . DS . 'Compat.php';

// Priority 1: ahead of the real listener, so the observation is independent of it.
Hook::add('filter:order.cart_totals', 1, function (&$summary, $items, $subtotal) {
    Compat::canary('filter:order.cart_totals');
});

// The gate the module itself sits behind. An incompatible core disables the feature
// instead of throwing inside somebody's checkout.
$acme_compat = Compat::check();

if ($acme_compat['ok']) {
    Hook::add('filter:order.cart_totals', 20, function (&$summary, $items, $subtotal) {
        // ... the real work
    });
}
else {
    Hook::add('ui:admin.body.end', 90, function () use ($acme_compat) {
        return '<!-- acme disabled: ' . htmlspecialchars(implode('; ', $acme_compat['errors'])) . ' -->';
    });
}

Two commands worth running by hand on the copy, right after the run.

post-upgrade checks
# What the gate actually saw, including everything it could not measure.
cat coremio/storage/updates-preimage/*/gate.json

# Errors since the run, where a swallowed listener exception is the only trace.
php coremio/errlog.php list --limit=20

Pitfalls

Passes from cron, fatals from the panel

Shared hosting pools disable the process functions in the web configuration, while the same server's command-line binary keeps them. A disabled function raises an error, not a warning, so the silencing operator does not help. Check the capability before you spawn a subprocess. A missing one is skipped, not failed.

Files roll back, the schema does not

Restoring the ledger puts every overwritten file back and deletes everything the run created. But the migration already moved the schema, and the configuration script already ran. If your module reads a core column, check the column rather than the version number.

A half-applied core is a real state

The file tree can be a mixture of two versions for minutes. Scheduled work started in that window can load one class from the old release and another from the new.

Rollback is one run, once, and only the last one

The button appears only for the most recent completed run, and only while nothing is running. It disappears once used. Anything older needs a full backup.

Was this helpful?

Thanks for your feedback!

Still Need Help?

Our support team is here around the clock for anything you can't find above.