Problem/Motivation
Four related gaps let the engine open, or re-submit, more charges than a period calls for.
- No lock, duplicate queue items.
hook_cron enqueues every id from CycleEngine::findDueSubscriptionIds() and DunningManager::findDueRetrySubscriptionIds() on every run, with no dedupe. CycleWorker has a sixty-second budget and core drains queues only after every module's cron work, so on a billing day with more due subscriptions than one run can charge, each run adds another full set of items while draining a fraction. Nothing in the module acquires a lock. Two consumers processing the same subscription at once, cron alongside drush queue:run for example, both pass hasOpenCharge(), both run the raters and the tax seam, and both save a submitted charge with its own UUID and idempotency key: two captures for one period.
- Past-due subscriptions are due every run. The due query filters on
status, cycle_anchor and no pending cancellation, not on billing_state. A past-due subscription keeps its anchor in the past for the whole grace period (three weeks by default), so it is enqueued on every cron run and each item pays an entity load and the open-charge count query before isPastDue() drops it.
- Pending without a remote id.
ChargeResult::pending() accepts a null remote charge id and applyChargeResult() stores nothing in that case. settleOpenCharges() then classifies the record as unacknowledged and re-submits it every sweep until the limit, when failUnknown() hands it to dunning, whose first retry uses a fresh idempotency key while the first submission may still settle at the processor. The late webhook either finds a record that is no longer submitted or lands on the retried one. The test connector always returns a remote id, so no test can see this.
- No instrument guard.
processDueSubscription() checks interval, currency and connector but not getInstrumentId(). A due subscription whose instrument was never stored is handed to the connector anyway; a real connector either throws (unknown outcome, re-submission churn, dunning for a customer who never finished setup) or charges the customer's default instrument, one the subscription was never authorised on.
The anchor arithmetic across a year boundary (shiftAnchor(), December forward and January backward) is reached only by now-relative tests run in those months; it goes along here as three fixed-date assertions.
Proposed resolution
- Acquire a per-subscription lock (
lock service, name subscription_manager:cycle:{sid}) around processDueSubscription(), retryCharge() and upgrade(); a worker that cannot acquire it drops the item (the next run picks the subscription up again if it is still due).
- Add
billing_state = current (or not past due) to findDueSubscriptionIds(). Dedupe queue items: either a "queued" marker in state keyed by subscription id and cleared by the worker, or a claim query the worker runs before processing.
- Refuse a pending result without a remote id:
applyChargeResult() records it as an unknown outcome (the same path a thrown connector call takes) so the sweep re-submits it under the same key rather than treating it as acknowledged-nowhere, and the interface documents that a pending result must carry the connector's identifier. Connector catch-up item.
- Skip a due subscription with no instrument: log an error, leave the anchor, no connector call. Make the test connector throw on a null instrument so no future path can rely on the stub's leniency.
- Tests: two workers on the same item open one charge; a past-due subscription is not enqueued; a pending result without a remote id is re-submitted once under the same key and never produces a second record; a due subscription without an instrument is skipped;
computeNextAnchor(2026-12-31, monthly, 31) is 2027-01-31, computePreviousAnchor(2026-01-31, monthly, 31) is 2025-12-31, yearly previous from 2024-02-29 is 2023-02-28.
Remaining tasks
- Design pass: lock scope (per subscription versus per user), what the worker does when the lock is held (drop versus release-and-requeue), and whether the dedupe marker or the claim query is preferred.
- Implementation in thematic commits, tests separate.
- Change record (pending-without-id contract tightening; instrument guard) and connector catch-up.
User interface changes
None.
API changes
ChargeBasedConnectorInterface::charge() documents that a pending result must carry a remote charge id; the engine treats one without as an unknown outcome.
Data model changes
None (a state-based dedupe marker if that option is chosen).
Comments
Comment #2
colanDesign pass against 1.0.x at 2e2e777. Each of the five gaps in the summary is still in the code as described. Three points the summary left open need a decision: where the lock lives and what a contended caller does, how to dedupe the cycle queue, and how "refuse" is spelled for a pending result without a remote id. Notes first, decisions at the end.
The lock
The lock belongs in
CycleEngine, not in the queue workers: Drush, the management page and a connector's webhook handler all reach the engine without passing through a worker, and the engine is the only place that knows which operations touch a subscription's billing state. A smallwithSubscriptionLock(SubscriptionEntityInterface $subscription, callable $operation): boolhelper acquiressubscription_manager:cycle:{sid}from core'slockservice, runs the operation, and releases in afinally. Core's database backend also releases every lock the request holds when it ends, so a worker that dies mid-item leaves no stale lock; the expiry passed toacquire()(sixty seconds, matching the workers' cron budget) bounds the window where a hard crash could hold it.Four operations run under it:
processDueSubscription(),retryCharge(), the sweep'sresubmit(), andupgrade(). The first three are background work and simply return when the lock is held, with an info log line: whoever holds it is doing the same job, and the next cron run picks the subscription up again if it is still due.upgrade()is a person waiting on a page, so it waits (lock->wait(), a few seconds) and tries once more before giving up with an exception; #3630969: Refuse an upgrade while a period charge is open or the subscription is past due, and validate the currency on plan change is where its refusals become typed, so here the exception is a plain\RuntimeExceptionthat issue can replace.resolveCharge()stays outside the lock: it never opens a charge, and the race it could have with a concurrent cycle step (cycle loads the subscription, webhook settles the charge and advances the anchor, cycle then finds no open charge) is already closed bypaidPeriodEnd(), which catches the anchor up instead of charging.scheduleDowngrade(),cancel(),resume()andend()stay outside too: they only save subscription fields, and the single-active invariant's own race has its own issue (#3630999: Acquire a per-user lock around the single-active check in SubscriptionEntity::preSave()).The due query and the queue
findDueSubscriptionIds()gains an OR group:billing_stateis null (a subscription that has never been in dunning stores nothing; the accessor defaults to current) or is notpast_due.isPastDue()insideprocessDueSubscription()stays as the second line of defence, since Drush and tests call the engine directly.For dedupe, two options. (A) A
cycle_queuedtimestamp base field on the subscription. Cron excludes rows whosecycle_queuedis within the settlement wait (four hours by default) and, after enqueuing, stamps the enqueued ids with one directUPDATEplus a storageresetCache()for those ids. No entity save, so no presave role logic and no cache-tag invalidation per enqueue. An item that is dropped (lock held, connector missing) is re-enqueued once the stamp ages out, so nothing is lost, only delayed by at most the wait. Needs an update hook and a schema change; costs one query per cron run. (B) Accept duplicates as cheap no-ops: with the past-due exclusion and the lock, a duplicate costs one entity load before the anchor check returns, and the backlog only arises on a billing day the queue cannot drain within one cron interval. No schema change. On a site with thousands of subscriptions sharing a billing day, (B) still writes thousands of queue rows per cron run until the backlog clears, so I recommend (A). The retry queue needs neither:DunningRetryWorkeralready re-checksnext_retry, whichbeginRetry()clears while an attempt is in flight.Pending without a remote id
Two layers. At the source,
ChargeResult::pending()takes a non-nullablestring $remote_charge_id, so a connector that returns pending without one fails in its own test suite with aTypeError; when it happens in production the error is thrown insidesubmit()'s try block and takes the existing unknown-outcome path, which keeps the record submitted and lets the sweep re-submit it under the same key. As a backstop,applyChargeResult()treats a pending result whose id is an empty string the same way, with its own log message. The interface docblock forcharge()says a pending result must carry the connector's identifier for the charge, and the change record says so too.succeeded()andfailed()keep their nullable id: a synchronous decline often has none.The instrument guard
In
processDueSubscription(), after the zero-amount branch and the connector check and beforepaidPeriodEnd(): a due subscription whoseinstrument_idis empty is logged at error ("due but holds no payment instrument; skipping") and left untouched, anchor included, so it shows up again every run until an operator fixes it, like the missing-currency case. A zero-amount period needs no instrument, which is why the guard sits after that branch.retryCharge()andresubmit()re-submit an existing record and are not guarded: an instrument that vanished since the charge was opened makes the connector throw, which is the unknown-outcome path and ends in dunning after the limit, where it belongs. The test connector'scharge()throws a\LogicExceptionwhen the subscription has no instrument, so no future engine path can lean on the stub's leniency.Tests
subscription_manager:cycle:{sid}itself, then callsprocessDueSubscription(),retryCharge()and the sweep: no charge record, no connector call, the info line logged once each;upgrade()under a held lock throws after waiting and changes nothing. Releasing the lock and calling again works normally. Two genuinely concurrent processes are out of reach for a Kernel test, so the lock-held path is the contract under test.findDueSubscriptionIds(); one with a nullbilling_stateis; one explicitlycurrentis.changedbump, no role hook calls).'pending_no_id'behaviour that returnsChargeResult::pending('')(the typed signature rules out null); the record stays submitted with no remote id and the unknown-outcome warning,settleOpenCharges()re-submits it under the same attempt, and no second record is ever opened. A unit-level assertion thatpending(NULL)is aTypeError.Change record and connectors
One change record:
ChargeResult::pending()requires a remote id; a due subscription without an instrument is skipped rather than handed to the connector; the cycle, retries, re-submissions and upgrades are serialised per subscription (a connector's webhook handler that callsresolveCharge()is unaffected). The connector catch-up template gains thepending()line. With (A), the record also mentions the newcycle_queuedfield for sites with custom queries, and the update hook installs it.Decisions to ratify
processDueSubscription(),retryCharge(),resubmit()andupgrade(); background callers return when it is held,upgrade()waits briefly then throws;resolveCharge()and the field-only operations stay outside.isPastDue()stays in the engine as the second check.cycle_queuedstamp with the settlement wait as its lease, written by direct update. (Alternative: (B), accept duplicates as no-ops.)ChargeResult::pending()requires a string id; an empty id is treated as an unknown outcome inapplyChargeResult().pending()contract, the instrument skip, the per-subscription serialisation and (with A) the new field.Comment #4
colanImplemented on the MR in eight commits, one per theme, tests and docs apart, following the ratified decisions. One deviation from the design comment and two small additions, noted below.
What changed
CycleEngine::withSubscriptionLock()wrapsprocessDueSubscription(),retryCharge(), the sweep's re-submission andupgrade()with core'slockservice undersubscription_manager:cycle:{sid}. The bodies moved torunCycleStep(),retryLocked(),resubmitLocked()andupgradeLocked(). Background callers log at info and return when the lock is held;upgrade()waitsLOCK_WAIT_SECONDS(five) then throws a\RuntimeException, which #3630969: Refuse an upgrade while a period charge is open or the subscription is past due, and validate the currency on plan change can turn into its typed refusal.resolveCharge()and the field-only operations stay outside, as decided.CycleEngine::WORKER_TIME_BUDGET(sixty seconds) is the lock expiry and thecron: timeof bothCycleWorkerandDunningRetryWorker.billing_state;isPastDue()stays in the engine as the second check.cycle_queuedtimestamp base field, installed by update 10029. Cron callsfindDueSubscriptionIds(), enqueues, thenmarkQueued($ids), which writes the stamp with one directUPDATEthrough the storage's table mapping and resets the entity cache for those ids. The due query skips rows stamped within the settlement wait. No entity save, so no role hooks and no cache-tag invalidation per enqueue, andchangedis untouched (asserted).ChargeResult::pending(string $remote_charge_id, ?string $message = NULL). InapplyChargeResult()a blank identifier takes the unknown-outcome path through a newrecordUnacknowledged(), whichrecordUnknownOutcome()now also uses; the charge'slast_errorstays the bare reason so dunning's failure message is unchanged. Interface docblock and README say the identifier is required and that an unacknowledged connector throws.SubscriptionEntity::hasInstrument()(trimmed, non-empty) backs it and the test connector's new\LogicExceptionfor the same case.Deviation and additions
Charge N: the connector threw (…); outcome unknown…andCharge N: pending result carried no remote charge ID; outcome unknown…, one method behind both. Not in the design comment; a side effect of sharing the path.hasInstrument()was not in the design; it replaced three copies of the same trim-and-compare.Tests
New
CycleConcurrencyTest(nine tests) swaps the kernel test base's null lock backend for the real database one and uses a second backend instance, with its own lock id, as the other process:changed, hides the row, releases it once older than the wait;charge_uuidandattempt, one record throughout, awaiting once the connector finally answers with an identifier;pending(NULL)is aTypeError;CycleEngineTest::testAnchorYearBoundary()adds the three fixed-date assertions.UpdatePathTestBaseexpectscycle_queued. Full Kernel suite: 232 tests green, the one pre-existing deprecation; Functional suite green (6 tests) in the clean codebase.Change record
Drafted alongside: the
pending()contract, the lock and its constant, the due-query exclusion and the stamp field, the instrument skip. The connector catch-up template gains thepending()line.Comment #14
colan