Daily publish cap #90

Closed
opened 2026-03-09 00:12:10 +01:00 by myrmidex · 1 comment
Owner

Summary

Cap how many articles may be published per day, so a feed that suddenly returns a large batch cannot flood a community.

Why this matters concretely

Belga's feed fetches count=50 per poll. PublishNextArticleJob runs every five minutes and publishes one article per run, so the current ceiling is roughly 288 posts/day with no way to lower it beyond stretching the global interval — which also slows normal operation.

Setting

Key Default Notes
daily_publish_cap 0 0 = unlimited

Defaulting to unlimited matters: an existing install must publish exactly as it does today until someone opts in.

Design

  • Gate in PublishNextArticleJob before article selection — the cap is article-independent, so there's no reason to run the candidate query when it has been reached.
  • Count article_publications created today, compared against the cap.
  • Counting uses the UTC day. Local-midnight counting depends on the publishing_timezone setting introduced in #129; once that ships, this should switch to counting from local midnight. Noted deliberately rather than pre-built.

Tasks

  • daily_publish_cap setting + Setting accessors
  • Gate in PublishNextArticleJob
  • Settings UI (dark mode from the start — see #88)
  • API surface in SettingsController
  • Tests

Tests that matter

  • Cap of 0 = unlimited (regression guard: today's behaviour exactly)
  • Publishing stops once the cap is reached
  • The count resets on a new day
  • Publications from a previous day do not count toward today

Constraints

PublishNextArticleJob is ShouldBeUnique with uniqueFor = 300 on a five-minute schedule. Confirm an early return releases the unique lock rather than suppressing the following tick, before adding a new early-return path.

Do not weaken #123 (Cache::lock duplicate guard) or #119 (scopeDueForPublishing is the single source of truth for publish eligibility — do not re-express that threshold in a new query).


Superseded scope

This ticket originally read "Publishing schedule control" and bundled four features. It was split because they are independent in design, review, and risk, and because three of them change the publishing path hardened by #123 and #119.

Split out to v2.0.0:

  • #129 Publishing quiet hours with timezone support
  • #130 Per-channel publish interval

Original description:

Summary

Add more granular control over when and how articles are published, beyond the current global interval.

Context

Currently there's only a global article_publishing_interval (minutes between posts). For hands-off automation you need more control: quiet hours (don't post at 3am), per-channel pacing, and possibly burst limits.

Goals

  • Define quiet hours (e.g. only publish between 08:00–22:00)
  • Per-channel rate limiting (some communities tolerate more frequent posts)
  • Daily publish cap (prevent flooding if a feed suddenly returns 100 articles)
  • Timezone-aware scheduling

Tasks

  • Add settings: quiet hours start/end, timezone, daily publish cap
  • Per-channel publish interval override (on route or channel level)
  • Update PublishNextArticleJob to respect quiet hours and caps
  • Settings UI for schedule configuration
  • Tests for scheduling logic edge cases (midnight crossover, timezone changes)
## Summary Cap how many articles may be published per day, so a feed that suddenly returns a large batch cannot flood a community. ## Why this matters concretely Belga's feed fetches `count=50` per poll. `PublishNextArticleJob` runs every five minutes and publishes one article per run, so the current ceiling is roughly 288 posts/day with no way to lower it beyond stretching the global interval — which also slows normal operation. ## Setting | Key | Default | Notes | |---|---|---| | `daily_publish_cap` | `0` | `0` = unlimited | Defaulting to unlimited matters: an existing install must publish exactly as it does today until someone opts in. ## Design - Gate in `PublishNextArticleJob` **before** article selection — the cap is article-independent, so there's no reason to run the candidate query when it has been reached. - Count `article_publications` created today, compared against the cap. - **Counting uses the UTC day.** Local-midnight counting depends on the `publishing_timezone` setting introduced in #129; once that ships, this should switch to counting from local midnight. Noted deliberately rather than pre-built. ## Tasks - [ ] `daily_publish_cap` setting + `Setting` accessors - [ ] Gate in `PublishNextArticleJob` - [ ] Settings UI (dark mode from the start — see #88) - [ ] API surface in `SettingsController` - [ ] Tests ## Tests that matter - Cap of `0` = unlimited (regression guard: today's behaviour exactly) - Publishing stops once the cap is reached - The count resets on a new day - Publications from a previous day do not count toward today ## Constraints `PublishNextArticleJob` is `ShouldBeUnique` with `uniqueFor = 300` on a five-minute schedule. Confirm an early return releases the unique lock rather than suppressing the following tick, before adding a new early-return path. Do not weaken #123 (`Cache::lock` duplicate guard) or #119 (`scopeDueForPublishing` is the single source of truth for publish eligibility — do not re-express that threshold in a new query). --- ## Superseded scope This ticket originally read "Publishing schedule control" and bundled four features. It was split because they are independent in design, review, and risk, and because three of them change the publishing path hardened by #123 and #119. Split out to v2.0.0: - #129 Publishing quiet hours with timezone support - #130 Per-channel publish interval Original description: > ## Summary > Add more granular control over when and how articles are published, beyond the current global interval. > > ## Context > Currently there's only a global `article_publishing_interval` (minutes between posts). For hands-off automation you need more control: quiet hours (don't post at 3am), per-channel pacing, and possibly burst limits. > > ## Goals > - Define quiet hours (e.g. only publish between 08:00–22:00) > - Per-channel rate limiting (some communities tolerate more frequent posts) > - Daily publish cap (prevent flooding if a feed suddenly returns 100 articles) > - Timezone-aware scheduling > > ## Tasks > - [ ] Add settings: quiet hours start/end, timezone, daily publish cap > - [ ] Per-channel publish interval override (on route or channel level) > - [ ] Update PublishNextArticleJob to respect quiet hours and caps > - [ ] Settings UI for schedule configuration > - [ ] Tests for scheduling logic edge cases (midnight crossover, timezone changes)
myrmidex added this to the v1.3.0 milestone 2026-03-09 00:12:10 +01:00
myrmidex added the
enhancement
label 2026-03-09 00:24:21 +01:00
myrmidex modified the milestone from v1.3.0 to v1.4.0 2026-03-18 18:12:11 +01:00
myrmidex self-assigned this 2026-08-10 21:50:57 +02:00
myrmidex changed title from Publishing schedule control to Daily publish cap 2026-08-10 21:56:53 +02:00
Author
Owner

Implemented in cb63a05 on release/v1.4.0.

Delivered

  • daily_publish_cap setting, default 0 = unlimited
  • Gate in PublishNextArticleJob::dailyCapReached(), checked before the interval check and before the candidate query
  • API surface in SettingsController (index + update, validated integer|min:0)
  • Settings UI row in the Article Processing card
  • 12 tests

Semantics worth knowing

The cap counts ArticlePublication rows, not distinct articles. An article cross-posted to three channels consumes three units.

This is deliberate. The cap exists to stop a community being flooded; counting distinct articles would let each of three channels receive the full cap. The Settings copy states this explicitly rather than saying "articles", which would have been misleading.

Counting runs from now()->startOfDay() — the UTC day, since config/app.php is 'timezone' => 'UTC'. Once #129 lands publishing_timezone, this should switch to local midnight. Noted in .claude/PLATFORM.md so it isn't rediscovered as a bug.

Open question from the ticket — resolved

Confirm an early return releases the unique lock rather than suppressing the following tick.

It does. CallQueuedHandler::ensureUniqueJobLockIsReleased() runs after handle() returns regardless of how it returned, so a cap-gated skip cannot suppress the next five-minute tick. The schedule entry also carries its own withoutOverlapping() as a second layer.

Not done

The Settings row has not been exercised in a browser — it is test-green only.

Verification

960 tests / 2524 assertions passing (948 before), Pint clean (303 files), PHPStan clean. Verified by mutation that the cap test fails when the gate is removed.

Quiet hours and per-channel pacing were split out of this ticket to #129 and #130 (v2.0.0).

Implemented in `cb63a05` on `release/v1.4.0`. ## Delivered - `daily_publish_cap` setting, default `0` = unlimited - Gate in `PublishNextArticleJob::dailyCapReached()`, checked before the interval check and before the candidate query - API surface in `SettingsController` (index + update, validated `integer|min:0`) - Settings UI row in the Article Processing card - 12 tests ## Semantics worth knowing The cap counts **`ArticlePublication` rows, not distinct articles**. An article cross-posted to three channels consumes three units. This is deliberate. The cap exists to stop a community being flooded; counting distinct articles would let each of three channels receive the full cap. The Settings copy states this explicitly rather than saying "articles", which would have been misleading. Counting runs from `now()->startOfDay()` — the **UTC day**, since `config/app.php` is `'timezone' => 'UTC'`. Once #129 lands `publishing_timezone`, this should switch to local midnight. Noted in `.claude/PLATFORM.md` so it isn't rediscovered as a bug. ## Open question from the ticket — resolved > Confirm an early return releases the unique lock rather than suppressing the following tick. It does. `CallQueuedHandler::ensureUniqueJobLockIsReleased()` runs after `handle()` returns regardless of how it returned, so a cap-gated skip cannot suppress the next five-minute tick. The schedule entry also carries its own `withoutOverlapping()` as a second layer. ## Not done The Settings row has not been exercised in a browser — it is test-green only. ## Verification 960 tests / 2524 assertions passing (948 before), Pint clean (303 files), PHPStan clean. Verified by mutation that the cap test fails when the gate is removed. Quiet hours and per-channel pacing were split out of this ticket to #129 and #130 (v2.0.0).
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lvl0/fedi-feed-router#90
No description provided.