Skip to content

Latest commit

 

History

11 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Druker

Druker

Cron for a Drupal site, managed in the admin UI and run by a small Go binary that sits beside the site rather than inside it.

Introduction

The problem it solves is the one every hosted Drupal site has: crontab lives somewhere a developer has to deploy to, the site has no idea what is in it, and nobody can answer "what runs on this box, and did it run" without SSH. Druker moves the schedule into Drupal, where it can be edited, reviewed and audited, and leaves the running to a process that does nothing else.

It is not a replacement for hook_cron. Drupal's cron is one job among the many this schedules, and drush cron is a perfectly good thing to put in it.

Requirements

  • Drupal 11
  • Drush, on any server running the worker
  • Go 1.23 or later, to build the worker. Only on the machine that builds it: the result is a static binary with no runtime dependencies

Installation

Install as you would any Drupal module. See Installing modules.

Then build and start the worker on each server, as described under Running the worker.

Configuration

Everything lives under Configuration, in the Druker section, across three tabs: Dashboard, Cron Jobs and Servers.

  1. Add a Server for each machine that will run a worker, and give it the hostname that machine reports. A worker finds its own schedule by matching its hostname against these.
  2. Add Cron Jobs. A job is a Drush command plus when to run it. Leave the server empty to run a job on every server.
  3. Grant the Administer Druker jobs and servers permission to whoever should be editing the schedule. It is a restricted permission: a job is a command line that runs as the web user.

Jobs can be switched off without being deleted, from the Disable operation on the job list. A disabled job keeps its command, schedule and server assignment, and no worker is told about it until it is enabled again. For differences between environments, see Several environments.

The Dashboard

It answers the question the two lists cannot: what is each machine actually going to do. One card per server showing the jobs its worker will receive — its own, plus the ones that run everywhere — and a grid of the day, hour by hour, which is where you notice that everything you own runs at three in the morning.

It leads with what will not run, because that is the part nobody finds on their own: a schedule the worker cannot read, a shell job on a site that has not enabled them, a job assigned to a server that is disabled or gone. Those are left out of the day as well, so the picture and the warnings never disagree.

A disabled server still shows a card, because its worker does not stop — it stops being matched, and falls through to the jobs that belong to no server. The card says so, and says how many of its own jobs are now picked up by nobody.

Check the same things from a terminal with drush druker:check, which tests every job the way the worker will and exits non-zero if any would be dropped. It also warns about the two that are nobody's fault and easy to miss: a shell job on a site that has not opted in, and a job left naming only servers that are missing or disabled, which no worker anywhere is running.

The two halves

Drupal owns the schedule. Two entity types: a Server, matched to a machine by hostname, and a Cron Job, which is a Drush command plus when to run it. A job with no server runs on every server.

The worker (worker/) is a single static binary. It asks Drupal what this machine should be running, keeps those jobs on their schedules, and asks again every refresh period — so a change made in the UI takes effect without a deploy and without a restart.

The refresh is set per server and defaults to 30 minutes. A schedule is edited by a person, so it changes on the timescale people work at; half an hour is soon enough for an edit to land and rare enough that the asking costs nothing. One minute is the floor, enforced on both sides — the site clamps what it sends and the worker clamps what it is told, so neither a subscriber asking for two seconds nor a hand-edited config YAML can put a server into a loop that spends more time asking what to do than doing it. A payload with no refresh at all falls back to the 30-minute default rather than to zero.

They meet at one command:

drush druker:jobs <hostname>

which prints the schedule as JSON. That payload is the whole contract between the two halves. It is produced by JobManager::formatJob(), consumed by the Job struct in worker/schedule.go, and examples/sample-output.json is a copy of it that the worker's tests parse on every run. If you change one side, change the other, and update the sample — the two drifting apart is silent, and it is the failure this module has already had once.

Running the worker

Build it into the project root — the directory holding vendor/ and composer.json:

cd web/modules/contrib/druker/worker
go build -o /path/to/project/druker .
cd /path/to/project
./druker

Give -o an absolute path. A relative one is counted from worker/, and how far up the project root sits depends on whether the site has a web/ docroot, so the same ../../../.. lands somewhere different on two installs.

That is the one place it needs no arguments. Started from there it finds vendor/bin/drush immediately, and everything below follows from it.

Where jobs run. Not where you started the worker — in the project root, the directory above vendor/, worked out from the drush path. That is where you stand when you run drush by hand, so a relative path in a command means the same thing however the worker was launched. Override it with -dir if your jobs expect somewhere else.

Finding drush. With no -drush it walks up from the working directory and from the binary's own location looking for vendor/bin/drush. That covers running it by hand; it does not cover systemd, which starts services in /. Pass the path explicitly there — the worker says so plainly rather than guessing:

level=ERROR msg="cannot find drush" error="no vendor/bin/drush above [/ /tmp]; pass -drush"
Flag What it does
-dry-run Print what would run and exit. Exits non-zero if any job was unreadable
-drush Path to drush. Required under systemd, optional otherwise
-dir Directory jobs run in. The project root above vendor/ when empty
-host Fetch another server's schedule, for checking what it would do
-state Where completed one-time jobs are remembered
-job-timeout Abandon a job that runs longer than this
-verbose Log every minute it evaluates, not only what it does
-log-format text (default) or json

Logs go to stdout, one line per event, which is what a container platform wants. The default is log/slog text — logfmt, for someone reading journalctl:

time=2026-09-14T10:45:14.214+02:00 level=INFO msg="schedule loaded" server=web13 jobs=7 next_check_in=5m0s

-log-format=json emits the same events and the same keys as one JSON object per line, for a shipper that would otherwise have to parse them back out:

{"time":"2026-09-14T10:45:14.214+02:00","level":"INFO","msg":"schedule loaded","server":"web13","jobs":7,"next_check_in":"5m0s"}

The keys do not change between the two, so a query written against one format still finds things in the other. Every line carries server, and job lines carry job, so one collector can hold several servers and still separate them. Send SIGTERM and the worker stops scheduling and gives running jobs 30 seconds to finish.

Under systemd

# /etc/systemd/system/druker.service
[Unit]
Description=Druker worker
After=network.target mysql.service

[Service]
Type=simple
User=www-data
Group=www-data

# Both given explicitly. systemd starts services in /, so nothing is found
# by walking up, and a job's relative paths would otherwise resolve there.
WorkingDirectory=/var/www/example
ExecStart=/var/www/example/druker \
    -drush /var/www/example/vendor/bin/drush \
    -dir /var/www/example \
    -state /var/lib/druker/state.json

Restart=always
RestartSec=10

# One-time completions live here, and must outlive a deploy.
StateDirectory=druker

# stdout is already one structured line per event.
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now druker
journalctl -u druker -f

Run it as the same user as the site. A job runs as whoever the worker is, and files it creates are owned by them.

Scheduling

A job is one of three kinds, and the form asks which before it asks anything else — Recurring, Interval or One-time. Only the fields belonging to the kind you pick are shown, and only those are saved: the other two are cleared on the way in, so switching a job from one kind to another leaves nothing of the old schedule behind it. The same three names are what the payload calls them, so a job reads the same way in the UI, in drush druker:jobs and in the worker's log.

Recurring jobs take standard five-field cron, an @-shortcut (@daily), or plain English (every 5 minutes). Whatever you type is resolved to cron by JobManager::resolveCronExpression() before it reaches the worker.

Both halves validate the result, with the same rules, on purpose: the form refuses an expression the worker could not read, because a job the worker drops is one that never runs and never says why. drush druker:check runs the same test over everything at once.

Interval jobs repeat every n seconds, and exist for the periods cron cannot say: every 25 seconds, every 75, every 90. Queue processors are what they are mostly for — * * * * * is the finest cron can manage, and a queue that matters is usually worth draining more often than once a minute. Pick Interval on the job form, or add one from CollectJobsEvent when the period itself has to be computed.

Boundaries are anchored to the epoch rather than to when the worker started, so a job set to every 30 seconds runs on :00 and :30 of every minute on every server, whatever time each of them booted, and a restart does not move it. An offset shifts those boundaries, which is how the same queue processor on three servers is kept out of the same second. A tick missed under load delays a run rather than losing it. The floor is 5 seconds: the tick is one, but every run is a process and a Drupal bootstrap, and a job that outlives its own period is skipped rather than queued.

One-time jobs take a date instead, and are remembered once run so a restart does not run them again. That memory is a small JSON file — if the worker runs in a container, point -state at a volume, or a one-time job will run again after every deploy. Completion is recorded when the job finishes, so a worker killed mid-job will re-run it: at least once, rather than at most once.

Dependencies. A job can be set to run after another. It waits while that job is running and starts when it finishes. A dependency chain that loops back on itself is broken at one link, with a warning, rather than being left to deadlock both jobs.

Asynchronous jobs start alongside anything else due in the same minute. A job that is not asynchronous holds the rest of that minute behind it.

Drush or a shell. Every job picks a runner. Drush is the default, and it is what most jobs want — including PHP, since php:script path/to/file.php is a Drush command like any other. The shell runner hands the whole command line to sh -c, so pipes, redirection and any binary on the machine work:

tar -czf /backups/site-$(date +%F).tgz web/sites/default/files

Shell jobs are off unless the site opts in, in settings.php:

$settings['druker_allow_shell'] = TRUE;

Deliberately a setting rather than a permission. Without it, Administer Druker jobs and servers means "run any Drush command as the web user" — serious, but bounded. With shell jobs it means "run anything on this machine", and whoever granted the permission under the first meaning never agreed to the second. A setting puts that decision with the person who has filesystem access.

Until then the option is visible but disabled, with the line to add, and a shell job that exists anyway is simply left out of the payload — so no worker ever sees it. drush druker:check says which jobs are being withheld and why, because a job that silently does not run is the worst way to find out.

No job ever overlaps itself. A job still running when its next turn comes round is skipped for that turn, with a warning in the log.

The failure this prevents is rarely the one people expect. It is a job that takes seven minutes on a five-minute schedule, quietly accumulating another copy every five minutes until the machine runs out of memory or database connections — silent until it is not.

After that it is the jobs that are not queue processors. A command that emails everyone who abandoned a cart in the last hour has no idea another copy of itself is making the same selection, and the customer gets the mail twice. The same goes for an import, a report, or anything that tags the rows it finds.

Queue processors are the safest case rather than the worst. Advanced Queue and core's queue both lease the items they hand out, so two processors normally take different work — which is why running one queue on several servers is throughput and not duplication. What overlap costs you there is narrower: a lease expiring while the first copy is still mid-item, after which a second can claim it and do it again.

The guard is per job, per server. The same job assigned to three servers still runs on all three at once; that is what assigning it to three is for.

Several environments

Servers are config and jobs are content, and that split is what makes one schedule work across production, staging and a laptop.

A Server is a config entity, so it is exported to config/sync and deployed everywhere — and, being config, its hostname and status can be overridden per environment in settings.php. A Cron Job is a content entity, living in each environment's database. A job assignment made by hand on a laptop is gone the next time that database is replaced by a copy of production, which is why per-environment differences belong on the servers rather than on the jobs.

One box standing in for several

The usual shape: production splits the work across three machines, and every other environment runs the lot on one. Add a fourth server for that one, and have each job name its production worker and the fallback — the Servers field takes as many as you like:

Job Servers
Live bid queue worker_1, fallback
Pre-auction lots worker_2, fallback
Mail and marketing worker_3, fallback
Drupal cron none — runs on every server

Then let each environment say which of the two is real. On production, nothing: the three workers are enabled as stored, and fallback is switched off.

// settings.php, production.
$config['druker.server.fallback']['status'] = FALSE;

Everywhere else, the opposite:

// settings.local.php, or the staging environment's settings.
$config['druker.server.worker_1']['status'] = FALSE;
$config['druker.server.worker_2']['status'] = FALSE;
$config['druker.server.worker_3']['status'] = FALSE;

Exactly one of the two servers a job names is live in any given environment, so production splits the work three ways and everywhere else takes all of it:

production        prod-1     Drupal cron, Live bid queue
                  prod-2     Drupal cron, Pre-auction lots
                  prod-3     Drupal cron, Mail and marketing

everywhere else   local-box  Drupal cron, Live bid queue,
                             Pre-auction lots, Mail and marketing

Give fallback a hostname the local machine actually reports. In a container that is usually the container's own name rather than anything memorable, and the worker uses os.Hostname()drush druker:jobs with no argument prints what it believes this machine is called.

Standing in for one machine rather than all of them

To have a staging box behave as one particular production worker, override that worker's hostname instead of its status:

$config['druker.server.worker_2']['hostname'] = 'staging-box';

That machine now gets exactly worker_2's schedule. Only one server can be mapped this way per environment — a hostname resolves to a single server — so for all three at once, either use the fallback above, or run three workers on the one box with -host:

./druker -host worker-1 -state /var/lib/druker/worker-1.json &
./druker -host worker-2 -state /var/lib/druker/worker-2.json &
./druker -host worker-3 -state /var/lib/druker/worker-3.json &

Give each its own -state. One-time completions are recorded by job id, and a shared file would let one worker mark another's job as already done.

What the flags actually mean

Disabling a server does not move its jobs anywhere. It stops that server being matched by hostname, so its machine falls through to the jobs that name no server at all. A job left naming only disabled or missing servers runs nowhere — the dashboard says so, per job and on the server's card, and drush druker:check warns about it wherever you run it.

Disabling a job switches it off everywhere, unconditionally. That is the Enable/Disable operation on the job list, and it is what to reach for instead of deleting something you may want back: the command, the schedule and the assignment are all kept, and no worker is told about the job until it is switched on again.

Job status is content, not config, so it cannot be overridden per environment and does not survive a database copy. Environment differences go on servers.

One rule

Never save an overridden entity from the UI in the environment that overrides it. Editing and saving worker_2 on the box where settings.php has rewritten its hostname writes that hostname into the stored config, and the next drush cex carries a local value into production. The job list's Enable/Disable operation is deliberately not offered for servers for this reason; changing a server's flag for real is a trip to its form, on an environment that does not override it.

Adding jobs from code

Not every job is worth storing. Subscribe to CollectJobsEvent to add jobs that are computed:

public function onCollectJobs(CollectJobsEvent $event): void {
  $event->addJob([
    'id' => 900,
    'name' => 'Rebuild the index',
    'command' => 'search-api:index',
    'runner' => 'drush',
    'type' => 'cron',
    'cron' => '0 4 * * *',
    'async' => TRUE,
    'depends_on' => NULL,
  ]);
}

An interval job is the same shape with every in place of cron, and an optional offset:

$event->addJob([
  'id' => 901,
  'name' => 'Process the bid queue',
  'command' => 'advancedqueue:queue:process bid_queue',
  'runner' => 'drush',
  'type' => 'interval',
  'every' => 25,
  'offset' => 4,
  'async' => TRUE,
  'depends_on' => NULL,
]);

The shape is the same one formatJob() produces. Anything the worker cannot read it drops, with a line in its log saying which job and why.

Commands

Command What it is for
drush druker:jobs [host] The payload the worker fetches. --pretty to read it
drush druker:check [host] Find jobs the worker would silently drop. Exits non-zero if any

Working on the worker

cd worker
go test ./...            # includes parsing examples/sample-output.json
go test -race ./...      # the runner is concurrent; run this before believing it
gofmt -l .

No dependencies beyond the standard library, deliberately: the binary ships to machines that may have no route to a module proxy, and the cron parser it needs is smaller than the trust a dependency tree would ask for.

Related projects

Klaxon is the other half of this. Druker runs your jobs; Klaxon is what tells you when one of them stopped — "cron is falling behind", "queue backlog", or any threshold you care to set — and says so in Slack, Telegram, Discord, email or a webhook.

It works in the other direction too. Klaxon's own scheduled alerts normally ride on Drupal cron, so a wedged cron run silences them. Scheduling klaxon:due and klaxon:deliver as Druker jobs puts the alerting on a process that is not the one it is watching.

Maintainers

About

Druker moves a server's cron schedule out of crontab and into Drupal, where it can be edited in the admin UI, reviewed like any other configuration, and seen by the people who actually want to know what runs. A small Go worker sits beside the site, asks Drupal what this machine should be running, and runs it.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages