Once a year, one of our clients enrolls its entire membership (4,780 people and counting) in a new batch of courses. On paper that’s one click: assign the courses to the “members” group and let background workers enroll everyone, 25 at a time. In practice it was hours of refreshing a page per course, watching a number climb, and restarting the whole thing every time the number stopped. So I took it apart, found out why, and rebuilt it one small, shippable phase at a time.
67 → 34.5KBper queued job, back under Beanstalk’s 64KB ceiling
6 per learner → 7 per chunkcalls to the core API (a 50-learner chunk went from 300 calls to 7)
4,780learners in the first run that ever finished (~45 min, zero duplicates)
11×faster user CSV imports, after removing a hard-coded five-second nap
Hours of babysitting
There was no progress indicator, so the only way to know what was happening was to sit on the course’s enrollments tab and hit refresh. Sometimes the count stalled way short of 4,780, and you had to start over. Already-enrolled learners were skipped, which helped, but somebody still had to watch it, restart it, and watch it again, for every single course.
It wasn’t only one client’s problem, either. Every background job on the platform (course publishing, password-reset emails, webhooks, everybody’s enrollments) shares one queue and one pool of workers. A big enrollment re-queues itself chunk after chunk and fans out messages for each learner, so another client publishing a course could find themselves stuck in line behind thousands of jobs that had nothing to do with them.
The job that said it succeeded
The first time I ran the full 4,780-person group on a dev server, it died on chunk 1 of 192 and left no trace anywhere. It took a chain of clues to find out why.
Every chunk re-queued the next one, and each time it carried along the full list of all 4,780 user IDs, right next to the chunked copy of the same IDs that was the only part ever read. The core API then re-encoded that payload (urlencode(json_encode()), for good measure), which inflated it from ~47KB to ~67KB. Beanstalk caps a job at 65,535 bytes, so it refused. The error got swallowed, and the worker runner treated the non-zero exit as a success and deleted the job. Everything reported fine, and 4,755 people never got enrolled.
Drawn to scale. One unset() on the redundant list brought the job back under the limit, and a full 4,780-learner run finished for the first time: about 45 minutes, no duplicates.
One phase at a time
The one-line fix got it working, and working isn’t the same as trustworthy. Each phase below shipped and got tested on its own, so I always had a working system and a measurement to beat:
Measure first. Logging for every chunk, plus a Bugsnag report whenever Beanstalk refuses a job. That silent failure will never be silent again.
Stop the dominoes. Each learner’s enrollment gets its own try/catch, so one bad record (an invalid ID, an unmet prerequisite) is noted and skipped instead of sinking the whole run.
Keep score. New MongoDB collections record progress and the outcome for every learner, with endpoints the admin screens can read.
Show your work. A live “1,250 / 4,780 (26%)” progress bar right where the admin assigned the course, plus a CSV of anyone who was skipped or failed.
Say when you’re done. The admin who started the run gets a notification the moment it finishes, with succeeded, skipped and failed counts. I reused the existing notification system rather than building another one.
Batch the chatter. Every learner used to cost six round-trips to the core API. The batching code already existed; the caller just never used it. Now course lookups, content assignments, transcripts and messages run once per chunk, and a new endpoint inserts every enrollment in the chunk at once. That makes it seven calls per chunk, whatever the size.
Bigger bites. Chunks went from a hard-coded 25 learners to a configurable 50, which halves the start-up overhead each chunk pays.
A lane of its own. Bulk jobs now get their own queue and their own workers, so a big run no longer competes with everyone else’s routine work. It shipped behind a config switch, off by default, because a queue nobody listens to is worse than a slow one. Once the workers were in place, we flipped it on.
Found along the way
A five-second nap, per user
To build a test group of thousands of learners, I had to import them, and each import took five seconds per person. That turned out to be a hard-coded sleep(5), added years ago so activation emails wouldn’t trip over each other. Sending those emails through the async queue instead made imports 11 times faster, for everyone, not just my test data.
PHP
Performance Metrics
Counting the same chunk twice
Midway through a test run, an hourly worker restart killed a worker mid-chunk. Beanstalk did its job and handed the orphaned chunk to another worker. The enrollments themselves were already protected from duplicates, but the progress counter wasn’t: it counted that chunk twice and declared the run finished early. Now each chunk checks in once, using a job-page log the codebase already had, and a redelivered chunk just steps aside.
Beanstalkd
MongoDB
Incident Response
Three doors, one hallway
Assigning a course to a group, clicking “Enroll All” on a filtered list and uploading a CSV all did the same job in different ways. The CSV upload even did it inline, making the admin’s browser wait. All three now go through the same hardened, observable worker, so a fix to one is a fix to all of them.
PHP
Angular
Multi-Tenant Architecture
Looking back
My favorite part is how unglamorous the root cause was. No race condition, no exotic bug, just a list that got carried along one step too far, plus an error handler optimistic enough to call a crash a success. The instrumentation came first on purpose: once I could see every chunk, every fix after that was a measured before and after instead of a hunch. The bulk queue is the one piece still waiting for its moment. It’s switched on and ready, but no enrollment that big has come along since the one that started all this. That’s fine by me.