Runnel 2.8: replay any workflow from a failed step.Read the release

Skip to content
Runnel

Tallis Health

Tallis Health runs patient reminders and lab imports on a self-hosted Runnel

Clinic software for 340 practices, with health data that must stay in the UK. Tallis runs Runnel Server in its own cloud account and moved 22 Celery tasks across in six weeks.

Company
Tallis Health
Industry
Clinic software
Team size
110 people, 34 engineers
Using Runnel since
March 2025
Stack
Python, Django, Postgres, Kubernetes

The problem

Tallis Health makes practice-management software for 340 physiotherapy, dental and GP clinics in the UK. Behind the booking screens runs a steady stream of background work: appointment reminders by SMS and email, imports of lab results from three pathology networks, insurance claim submissions and a nightly export for each clinic’s accounts.

All of it ran on Celery with RabbitMQ. It worked most of the time, but when it didn’t, nobody could tell. A failed lab import left no trace beyond a log line in a system that kept seven days. Reminders that missed their slot were simply never sent. When a clinic phoned to ask why a patient had not been reminded, an engineer had to dig through logs to find out, and often couldn’t.

Tallis also had a constraint most hosted tools could not meet. Patient data must stay in Tallis’s own UK cloud account, under its own audit and retention rules.

What they built

Tallis runs Runnel Server in its own Kubernetes cluster, in a UK region, against a dedicated Postgres instance. The application and the workers stay in Python. The Django app enqueues jobs, and workers run as a separate deployment that scales on queue depth.

The team moved one Celery task at a time, starting with appointment reminders. Each reminder is a run scheduled for its send time, with an idempotency key built from the appointment and the reminder type. When an appointment moves, the old run is cancelled and a new one is enqueued.

clinics/reminders.py
@runnel.job(name="send-reminder", retries=Retries(attempts=4, backoff="exponential"), timeout="30s")
async def send_reminder(payload: dict, ctx) -> dict:
    appt = await Appointment.objects.aget(pk=payload["appointment_id"])
    if appt.cancelled:
        return {"skipped": "cancelled"}
    receipt = await sms.send(appt.patient.phone, reminder_text(appt))
    return {"message_id": receipt.id}

await send_reminder.enqueue(
    {"appointment_id": appt.pk},
    delay=until(appt.starts_at - timedelta(hours=24)),
    idempotency_key=f"reminder:{appt.pk}:24h",
)

Lab imports became schedules, one per pathology network, every ten minutes. They have long timeouts and a concurrency limit of one, so a slow import never overlaps the next tick. Insurance submissions followed, then the nightly exports. All 22 Celery tasks were on Runnel six weeks after the first one, and RabbitMQ was retired.

Results

Reminders now go out on time: 99.97% within a minute of their scheduled time in the last six months, and the rest are visible in the dashboard with the reason they failed. Pages to the on-call engineer fell by 41%, mostly because failed lab imports now retry on their own instead of waking someone up.

The change Tallis mentions most is outside engineering. Clinic support staff have read-only access to the Runnel dashboard and search runs by appointment id. “Did the reminder go out?” is now a two-minute answer from support, not a ticket for an engineer.

Tallis is on an Enterprise agreement for Runnel Server support and upgraded to Runnel Server 2.7 and Postgres 17 in October 2026.

The results, in numbers

of SMS reminders sent within a minute of their time
99.97%
Celery tasks moved across in six weeks
22
pages to the on-call engineer per month
−41%

“We needed the data to stay in our own account and the code to stay in Python. Runnel Server gave us both, and the dashboard meant our clinic support team could answer 'did the reminder go out?' without asking an engineer.”

Dr Amara OkaforCTO, Tallis Health

Running something similar?