Give zip builds their own queue, so one archive cannot hold up the mail

The last piece of the #1687 follow-up. BuildZipDownloadJob allows itself
an hour, every shipped topology runs exactly one worker, and everything
shares the default queue -- so one large archive delayed every
notification email queued behind it. The size cap and the
one-build-per-person rule bounded that in July; they did not remove it.

onQueue('zips') in the constructor rather than at the dispatch site, so a
second caller cannot forget it. Both images grow a worker for it:
compose.yaml gains worker-zips, supervisord gains [program:queue-zips],
and the existing worker in each narrows to --queue=default. --tries=1
there matches the job, which records its own failure rather than being
retried.

The part that needs care is the manual install. A worker whose command
still says plain `queue:work` consumes `default` only, so it would send
email happily and never finish a single zip, with nothing in any log
saying why. INSTALL.md's unit now reads --queue=default,zips -- one
worker watching both, which is right for most installations -- and says
what happens if you leave it off, with the two-worker split offered for
anyone who would rather keep the two kinds of work apart. CHANGELOG
carries it as an upgrade note, since it is something to do rather than
something that was done.

Verified in the dev stack rather than only in a test: dispatched a build
and watched worker-zips take it while the default worker stayed idle.
This commit is contained in:
ignacionelson
2026-08-26 18:06:40 -03:00
parent eb2917f5ff
commit 92a132d74f
6 changed files with 92 additions and 4 deletions
+12
View File
@@ -36,6 +36,18 @@ a version is cut.
ceiling is, rather than simply refused; each person can have one archive being prepared at a time,
for the same reason.
- **Zip downloads no longer hold up your email.** Preparing a large archive can take a while, and it
used to run on the same queue as everything else — so one big zip could delay every notification
email behind it. Zip building now has a queue of its own, and the Docker images run a second
background worker for it.
**Upgrade note, manual installs only:** your background worker has to be told about the new queue,
or zips will never finish and nothing will say why. Edit
`/etc/systemd/system/projectsend-worker.service` so the `ExecStart` line reads
`queue:work --queue=default,zips …`, then `sudo systemctl daemon-reload && sudo systemctl restart
projectsend-worker`. Docker installations need no change. See INSTALL.md for the two-worker setup
if you would rather keep the two kinds of work apart.
### Fixed
- **Sessions no longer break behind a reverse proxy.** Signing in, or submitting the first-run setup
+10 -1
View File
@@ -384,7 +384,7 @@ User=www-data
Group=www-data
Restart=always
WorkingDirectory=/var/www/projectsend
ExecStart=/usr/bin/php artisan queue:work --tries=3 --backoff=3
ExecStart=/usr/bin/php artisan queue:work --queue=default,zips --tries=3 --backoff=3
[Install]
WantedBy=multi-user.target
@@ -396,6 +396,15 @@ Then:
sudo systemctl enable --now projectsend-worker
```
`--queue=default,zips` matters. Building a zip runs on its own queue, so a worker that is not told
to watch `zips` will send email happily and never finish a single zip download — with nothing in any
log to say why. One worker watching both is fine for most installations; ordinary work is taken
first, and a large zip simply holds the worker while it runs.
If zip downloads are heavily used and you would rather they never delayed email, run a second unit
with `--queue=zips` and narrow the first one to `--queue=default`. That is what the Docker images
do.
**Without this, no email is ever sent** and zip downloads never finish. `Restart=always` matters
too: saving your email settings restarts the worker so it picks up the new values, and it needs to
come back on its own.
+14 -1
View File
@@ -63,7 +63,20 @@ class BuildZipDownloadJob implements ShouldQueue
public function __construct(
private readonly int $zipDownloadId,
) {}
) {
// Its own queue, because $timeout is an hour and every shipped
// topology runs one worker: on the default queue a single large
// build holds up every notification email behind it. Set in the
// constructor rather than at the dispatch site so a second caller
// cannot forget it.
//
// A worker has to be listening. The images run a second one; a
// manual install whose worker command still says plain
// `queue:work` consumes `default` only, so INSTALL.md documents
// `--queue=default,zips` for the single-worker case — see the
// upgrade note in CHANGELOG.md.
$this->onQueue('zips');
}
public function handle(): void
{
+22 -1
View File
@@ -56,7 +56,7 @@ services:
args:
WWWUSER: ${WWWUSER:-1000}
WWWGROUP: ${WWWGROUP:-1000}
command: php artisan queue:work --tries=3 --backoff=3
command: php artisan queue:work --queue=default --tries=3 --backoff=3
volumes:
- .:/var/www/html
- ../packages:/var/www/packages
@@ -70,6 +70,27 @@ services:
redis:
condition: service_started
# Zip builds get their own worker: BuildZipDownloadJob allows itself an
# hour, and on a shared queue one large archive holds up every
# notification email behind it.
worker-zips:
build:
context: .
dockerfile: docker/app/Dockerfile
args:
WWWUSER: ${WWWUSER:-1000}
WWWGROUP: ${WWWGROUP:-1000}
command: php artisan queue:work --queue=zips --tries=1
volumes:
- .:/var/www/html
- ../packages:/var/www/packages
restart: unless-stopped
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
scheduler:
build:
context: .
+16 -1
View File
@@ -36,7 +36,7 @@ stderr_logfile_maxbytes=0
; state, and a worker started before a deploy keeps running the old code
; until it exits; an hour bounds both without thrashing.
[program:queue]
command=su-exec www-data php /var/www/html/artisan queue:work --max-time=3600 --tries=3
command=su-exec www-data php /var/www/html/artisan queue:work --queue=default --max-time=3600 --tries=3
autostart=true
autorestart=true
priority=30
@@ -46,6 +46,21 @@ stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
; Zip builds get their own worker. BuildZipDownloadJob allows itself an
; hour, and on the shared queue one large archive holds up every
; notification email queued behind it. --tries=1 matches the job, which
; records its own failure rather than being retried.
[program:queue-zips]
command=su-exec www-data php /var/www/html/artisan queue:work --queue=zips --max-time=3600 --tries=1
autostart=true
autorestart=true
priority=31
stopwaitsecs=3630
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
; schedule:work is the long-running equivalent of a per-minute cron entry,
; which is what a container should use — there is no crond here.
[program:scheduler]
+18
View File
@@ -12,6 +12,7 @@ use App\Modules\Files\Models\ZipDownload;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
// QUEUE_CONNECTION=sync in phpunit.xml — BuildZipDownloadJob runs
@@ -540,3 +541,20 @@ test('a traversing filename cannot escape the archive as a zip entry', function
->and($segments)->not->toContain('..')
->and($names[0])->toStartWith('Reports/');
});
// Its own queue, so one hour-long build cannot hold up every notification
// email behind it. Both shipped topologies run a worker for it; a manual
// install is told to watch `default,zips` (INSTALL.md, and the upgrade
// note in CHANGELOG.md), because a worker that is not listening finishes
// no zips and says nothing about why.
test('a zip build is queued away from ordinary work', function () {
Queue::fake();
$file = zipUploadFile($this->admin, 'a.pdf');
$this->actingAs($this->admin)
->postJson('/zip-downloads', ['file_ids' => [$file->id]])
->assertOk();
Queue::assertPushed(BuildZipDownloadJob::class, fn (BuildZipDownloadJob $job): bool => $job->queue === 'zips');
});