refactor(banners): Replace the payment-failure evaluator with a generic backend command intake - #4909
Conversation
d09cabb to
c275d9b
Compare
| msgstr "You have reached your storage limit." | ||
|
|
||
|
|
||
| msgid "Banners Billing Restricted Text" |
There was a problem hiding this comment.
Why did you remove the translations here?
The idea is that the stack knows the instance language and it will materialize the banner using that instance language.
we should just keep that system, but the events and calculations are done in cloudery
There was a problem hiding this comment.
The stack doesn't choose billing wording anymore, so those entries would never be read again. What we did lose is re-localization, and that's a separate thing: fixed in 36d95f94b, where the private command document keeps every locale the Cloudery sends and the refresh hook picks one again.
There was a problem hiding this comment.
too much text here, and it feels we are just repeating what the ADR said
| Title: cmd.Title[locale], | ||
| Text: cmd.Text[locale], | ||
| Lang: locale, |
There was a problem hiding this comment.
A banner made from a command is stored in one language only. The Cloudery sends
every language it has, but here we pick one and throw the others away.
This breaks something ADR 054 decided. The trigger table has a row for it:
| Instance locale change | Every document for that instance, re-localized |
and the Translation section spells out the mechanism: "a language change
re-materializes the documents, which clients pick up on their next refetch".
The stack already does exactly that. Instance patching flags a banner refresh
when the locale changes, and the refresh hook runs. That hook is only wired to
the quota rule, so quota banners get reworded and command banners do not.
I tested this locally. An instance in French with both kinds of banner, then one
language change to English: the storage warning became English, the payment
warning stayed French and still declared lang: "fr". The user sees two banners
in two languages, side by side.
The fix does not need the deleted translation files, and does not need the
Cloudery to resend anything. The command already carried the English text, we
just dropped it. Keep the full set of languages on the private command document
and let the existing refresh hook pick again.
Worth separating two things here. Moving the wording to the Cloudery is right ( in a sense ),
and it removes a cost the ADR accepted: that new wording and new locales need a
stack release ( and it's fine ). Dropping re-localization is a different change, and those two
were separable. If we do want to change the decision rather than fix the code,
that needs an ADR amendment, not a paragraph in docs/banners.md.
There was a problem hiding this comment.
Fixed in 36d95f94b. The private command document now keeps every locale, and the existing refresh hook picks the language again when the instance changes it. I kept the whole command rather than only the wording, so we never have to rebuild a banner from the public document that apps can write.
| // ErrInvalidCommand marks a command no retry can fix. A transport rejects such | ||
| // a delivery instead of requeuing it; anything else is a storage failure worth | ||
| // retrying. |
There was a problem hiding this comment.
This comment says a transport rejects an invalid command instead of putting it
back on the queue. Nothing does that. The queue runner puts every failed message
back, no matter the error, and no code outside the tests ever checks for this
error type.
we need to fix this comment
There was a problem hiding this comment.
You're right, nothing checks it. The comment now says the runner nacks every error the same way. Fixed in 16cb17427.
| SecondaryCTA: cmd.SecondaryCTA.pick(locale), | ||
| Dismissible: cmd.Dismissible, | ||
| Priority: cmd.Priority, | ||
| StartsAt: cmd.StartsAt, |
There was a problem hiding this comment.
startsAt is read from the command here, but it gets thrown away later if the
banner already exists with the same bannerId. endsAt and the text are
applied, startsAt is not.
I tested this locally. I sent a first command with a window in August 2026, then
a second command moving both ends into March 2027. The result was a banner
running from August 2026 to March 2027, which is neither of the two windows I
asked for, and no error was raised.
So the Cloudery extending a window gets half of the change applied. Either honour
an explicit startsAt, or document that the start is locked once an occurrence
exists and a new window needs a new bannerId.
There was a problem hiding this comment.
Fixed in de627cb63. A command that sends no startsAt keeps the start the occurrence already had, and one that sends it replaces the stored value. There was a worse case than the one you hit: validation checked the window the command sent, not the one we actually store, so moving a window backwards could store a start after its end.
| // queue is declared with a delivery limit, so the broker dead letters it | ||
| // after those attempts rather than redelivering it forever. | ||
| if err := banner.ApplyCommand(cmd); err != nil { | ||
| return fmt.Errorf("banner.commands: %s revision %d: %w", d.RoutingKey, cmd.Revision, err) |
There was a problem hiding this comment.
This logs "applied" even when the command was ignored. ApplyCommand returns no error both when it wrote something and when it skipped a stale revision, so the handler cannot tell them apart.
Locally, one command produced both of these lines:
billing: ignoring revision 7, not newer than the stored 50
banner.commands: banner.materialize applied to bob.cozy.example
Since ignoring a stale revision is the normal outcome of at-least-once delivery, this is the line someone will be reading when they are trying to work out why a banner never appeared, and it tells them the opposite. Either return an applied/ignored result, or say "accepted" instead of "applied".
There was a problem hiding this comment.
Fixed in 16cb17427. It says accepted now, and the line names the other outcomes that reach it, a stale revision and an instance with banners off.
| // A command the queue cannot fix is nacked like any other failure. The | ||
| // queue is declared with a delivery limit, so the broker dead letters it | ||
| // after those attempts rather than redelivering it forever. |
There was a problem hiding this comment.
This states a deployment setting as a fact from inside the code. It is true of the example config, but not of any deployment that leaves out delivery_limit, and the code has no way to know. The rabbitmq doc is the right place for it and already says it.
There was a problem hiding this comment.
Fixed in 16cb17427. The comment now leaves the delivery limit to the rabbitmq doc.
| target := cmd.WorkplaceFqdn | ||
| if target == "" { | ||
| target = cmd.Domain | ||
| } |
There was a problem hiding this comment.
Four lines to build one log value. Exactly one of the two fields is set, so concatenating them works, and that is the idiom the command file already uses at line 295.
There was a problem hiding this comment.
Fixed in 16cb17427.
| if err := l.lock.Lock(); err != nil { | ||
| return err | ||
| } | ||
| l.tick = time.NewTicker(l.timeout / 3) | ||
| l.mu.Lock() | ||
| done := make(chan struct{}) | ||
| l.done = done | ||
| l.mu.Unlock() | ||
| go func() { | ||
| defer l.mu.Unlock() | ||
| tick := time.NewTicker(l.timeout / 3) | ||
| defer tick.Stop() | ||
| for { | ||
| l.mu.Lock() | ||
| if l.tick == nil { | ||
| return | ||
| } | ||
| ch := l.tick.C | ||
| l.mu.Unlock() | ||
| <-ch | ||
| l.mu.Lock() | ||
| if l.tick == nil { | ||
| select { | ||
| case <-done: | ||
| return | ||
| case <-tick.C: | ||
| // A lease that cannot be renewed is not going to start | ||
| // renewing again, so the goroutine stops rather than logging | ||
| // the same failure every tick until Unlock. | ||
| if err := l.lock.Extend(); err != nil { | ||
| logger.WithNamespace("lock"). | ||
| Warnf("cannot extend a long operation lease: %s", err) | ||
| return | ||
| } | ||
| } | ||
| l.lock.Extend() | ||
| l.mu.Unlock() | ||
| } | ||
| }() | ||
| return nil | ||
| } | ||
|
|
There was a problem hiding this comment.
The fix is right and the two new tests cover both halves. Worth saying out loud that the renewer stopping does not stop the operation. The VFS migration never checks its lease, so it keeps running while the lock expires and another process can take it. That was already true before this change, and the new warning is the first signal it has ever had.
There was a problem hiding this comment.
Moved to #4911 and written into its description. Neither caller watches its lease, so the operation keeps running while the lock expires. That was already true before the change, the new warning is just the first time anything says so.
| // ErrLockLost means that an operation no longer owns its distributed lock. | ||
| var ErrLockLost = errors.New("lock ownership lost") |
There was a problem hiding this comment.
ErrLockLost is exported but nothing outside this package ever checks for it. Could stay unexported until something needs it.
|
|
||
| // Exactly one of Domain and WorkplaceFqdn is set. Domain addresses a | ||
| // B2B organization, and every instance under it gets the banner. | ||
| Domain string `json:"domain,omitempty"` |
There was a problem hiding this comment.
can we use here tenant(org id) instead of domain?
There was a problem hiding this comment.
Fixed in 861eb4f: organization commands now use tenant (org ID) with ListOrgInstancesByID, with updated validation, fixtures, documentation, and passing banner, RabbitMQ, and API tests.
| } | ||
| for _, inst := range instances { | ||
| if inst.HasBannersEnabled() && !inst.AllowsBannerCategory(cmd.Category) { | ||
| return fmt.Errorf("%w: the context of %s does not accept commands for the %s category", |
There was a problem hiding this comment.
should we just skip this instance and not return error for others?
There was a problem hiding this comment.
Updated in 2328c50, now we skip instances whose context disallows the category and continue processing eligible instances, while still returning storage errors for retries.
There was a problem hiding this comment.
but for errors as well, if we had an error for one instance, why should we stop processing others?
There was a problem hiding this comment.
Agreed, done in a0811b1. We now try every member and return all the failures together so the delivery is retried, and the test checks that a member after the failing ones still gets its banner.
| } | ||
| ctx := context.Background() | ||
| if _, err := couchdb.CheckStatus(ctx); err == nil { | ||
| if err := couchdb.InitGlobalDB(ctx); err != nil { |
There was a problem hiding this comment.
mmmm, why do we need this init here?
There was a problem hiding this comment.
It brings up the global database, which the command tests need because they create instances. It's spelled out here instead of calling our shared test helper because importing tests/testutils from inside package banner is an import cycle: testutils pulls in model/stack, which pulls in pkg/rabbitmq, which imports model/banner. That was already true before this PR, pkg/rabbitmq imported model/banner on master too.
There was a problem hiding this comment.
that's why it's better then to put test in test package, actually to banner_test package
There was a problem hiding this comment.
Done in 3a93280. The command tests moved to banner_test and use the shared testutils setup, so the inlined TestMain is gone.
|
|
||
| // commandState records the last command accepted for a category, so ordering | ||
| // survives clears (which leave no public document) and unchanged decisions. | ||
| type commandState struct { |
There was a problem hiding this comment.
Why do we need a new doc type? Why banners isn't enough?
There was a problem hiding this comment.
Because io.cozy.banners isn't in the permission blocklist and io.cozy.banners.commands is. An app needs write access to the banners doctype to record a dismissal, so if the revision lived there the same app could rewind it and we'd accept a stale command. A clear also deletes the public document, which would leave nothing to compare the next revision against.
There was a problem hiding this comment.
Then we shouldn't delete the public document, or whatever. To have a permssion blacklist as a separate type is kind of everkill. There is multiple variants how you can implement it, to have multiple banners of the same catergoy and the filter/group on get query or just let the app do anything it wants as we have actually for files. Could you please describe the use case: when do we need this "blacklist", I just don't understand what problem we can have with this "dismissal" record. If it's ABA problem, then just, ok, let's record timestamp or optimistiv locking on a revision
There was a problem hiding this comment.
You're right, the separate doctype was overkill, so we dropped it in 2816d56. The revision and the accepted command now live on the banner document itself, a clear expires that document instead of deleting it, and dismissals go through the normal _rev.
…nd intake The stack was reading Stripe subscription statuses and attempt counts and deciding from them what to tell the user. That is billing logic, and it does not belong here: the stack should write banners, not decide them. billing.go, the BillingLifecycleMessage contract, its handler, its queue and the four billing translations are gone. In their place, a backend publishes a banner.materialize or banner.clear command carrying the wording it wants, and the stack validates it and stores it. What the stack still decides: - The category must be allowed for the instance context, through banner_command_categories. The quota category is always refused, because the stack measures disk usage itself. - The routing key decides materialize versus clear, never the payload. - The CTA must be an absolute https URL, the payload must fit MaxCommandBytes, and the bannerId must match the doctype's format. Ordering no longer rides on the visible document. A command carries a revision, and the last accepted one per category is kept in io.cozy.banners.commands, blocklisted so no application can touch it. An app with dismissal rights on io.cozy.banners could otherwise rewind the ordering by rewriting the document it is allowed to write. Delivery is at-least-once and unordered: a stale revision is refused and the same revision applied twice converges, so a redelivery is a no-op. A command that cannot be fixed by retrying is nacked and the broker dead letters it after delivery_limit attempts. docs/banners.md describes the contract for the producing side.
The shared wire fixtures were decoded by a helper that panicked on a read or unmarshal error, so a broken fixture reported a stack trace rather than the test that wanted it. It takes t now, marks itself a helper and uses require.NoError, as asked in review.
A commanded banner was stored in one language and never revisited, so an instance that switched language ended up with the quota banner reworded and the billing banner still in the old one, side by side, as the review reported. The private io.cozy.banners.commands record now keeps the accepted command whole, with every locale the backend sent, and the existing RefreshBanners hook picks a language again from it. Reconstructing severity, surface, occurrence id and the action URLs from the public document instead would mean trusting fields an application can rewrite, which is the reason that doctype is private. Refresh takes the same instance lock a command takes, so it cannot race a newer command into restoring what that command replaced. It moves no revision, since re-picking a language is not a decision, and it keeps a dismissal because the occurrence id is unchanged. A cleared category, a record written before the stack retained the wording, and a category the context no longer accepts are all left alone. Re-localizing rewrites a banner rather than restoring one: a category whose document is gone stays gone until the next command. Recreating it from the retained command alone would move a scheduled banner to the decision time of whichever command wrote last.
An explicit startsAt was accepted and then discarded whenever a banner already existed with the same bannerId, while endsAt was applied, so a backend moving its window got half the change: a first command running August 2026 followed by one moving both ends into March 2027 left a banner running August 2026 to March 2027, which is neither window and raised no error. A nil StartsAt now means the producer stated no window, and the moment the occurrence began is carried forward. A producer that states one owns it. Both producers had to stop stating a default for that rule to hold. The quota rule no longer assigns StartsAt on every evaluation, and a command no longer defaults it to the decision time, so Materialize fills a nil start from the producer's own decision: the evaluation time for a rule, the command timestamp for a command. Quota's stored documents are unchanged, since the value it used to assign itself is now the value Materialize fills from the same clock reading. Validation cannot see the stored occurrence, so it no longer substitutes the decision time for an omitted start. It judges a window only when the command states both ends, which stops it refusing an end the backend is entitled to move on its own. Carrying a start forward is skipped when it would land after an end the producer just stated, so a shortened window cannot invert.
ErrInvalidCommand claimed that a transport rejects such a delivery instead of requeuing it. Nothing does: the queue runner nacks every handler error alike, and no code outside the tests inspects the error. The comment now says what happens, and keeps the classification for a transport that learns to reject. The handler logged a command as applied even when ApplyCommand had ignored it, because a stale revision and a successful write both return nil. Since ignoring a stale revision is the normal outcome of at-least-once delivery, that line was telling whoever was working out why a banner never appeared the opposite of what happened. It says accepted now, and names the other outcomes that reach it. Also drops two comments that restated the code below them, and builds the log target by concatenation, which validation makes safe and which the command file already does.
A missing workplace was described as retried to give an instance still being provisioned time to appear. Nothing in the path delays a redelivery, so the attempts are consumed as fast as the consumer loops and the command reaches the dead letter queue without waiting for anything. It says so now, and says to repair the instance and replay. The delivery limit bounds deliveries rather than retries, which is why a limit of five runs the handler six times on the version the test fixture pins. That number is attributed rather than promised: how a redelivery is counted has changed between RabbitMQ releases, and this stack requeues with basic.nack, so a deployment should confirm it against its own broker.
c275d9b to
515f6c5
Compare
dc52450 to
49db1bf
Compare
rezk2ll
left a comment
There was a problem hiding this comment.
still two things missing:
- a disallowed category is now silently swallowed
- no CTA allowlist yet
- the Revision collision issue
| } | ||
|
|
||
| func (cmd Command) applyTo(inst *instance.Instance) error { | ||
| // Instances that disable banners or disallow this category are skipped. |
There was a problem hiding this comment.
But the single-instance path now has no signal at all. Nothing written, nothing dead-lettered, and the only line an operator sees is the handler's, which says the command was accepted:
banner.commands: banner.materialize accepted for dave.cozy.example (category billing, revision 9, event skip1)
There was a problem hiding this comment.
Right, fixed in a27af44. A skipped instance now logs a warning with the reason, either the category or the CTA host, and its revision doesn't move.
An instance skipped for a disallowed category or CTA host now logs a warning with the reason. CTA URLs must use a host listed in the context's banner_cta_hosts, checked on apply and on refresh. The docs state that the revision is kept per instance and category, shared by tenant and workplace commands.
|
|
||
| // Exactly one of Tenant and WorkplaceFqdn is set. Tenant is the B2B | ||
| // organization ID, and every instance under it gets the banner. | ||
| Tenant string `json:"tenant,omitempty"` |
| # this context. Off by default, so the rules can ship before the clients | ||
| # that render them. Turning it back off stops the writes and leaves the | ||
| # documents already materialized in place. | ||
| enable_banners: true |
There was a problem hiding this comment.
group to the banner: and one struct
| - billing | ||
| - trial | ||
| # The hosts a banner command's call to action may link to. | ||
| banner_cta_hosts: |
Context
The stack was deciding when a payment problem deserves a banner. It read the Stripe subscription status and the invoice attempt count off the bus, ran the escalation rules itself, and picked the wording from its own translation catalog. None of that is ours: the stack should be writing banners, not deciding them. Every wording change or rule change meant a stack release, and the billing rules already live on the backend side that owns the subscription.
Solution
billing.go, theBillingLifecycleMessagecontract, its handler, its queue and the four billing translations are removed. In their place a backend publishes abanner.materializeor abanner.clearcommand on theplatformexchange, carrying the wording it already decided, and the stack validates it and stores it.docs/banners.mddescribes that contract for the producing side.The intake is not a pass-through. A command is refused if its category is not listed in
banner_command_categoriesfor the instance context, and thequotacategory is refused always, because the stack measures disk usage itself and no backend should be able to speak for it. The routing key decides materialize versus clear, never the payload, so a producer cannot clear a category by naming a field. The CTA has to be an absolutehttpsURL, the payload has to fitMaxCommandBytes, and thebannerIdhas to match the format the doctype documents.Ordering is the part worth reading. Delivery is at-least-once and unordered, so the intake needs to know what it already applied. That record is a new stack-private doctype,
io.cozy.banners.commands, blocklisted so no application can reach it, holding the last accepted revision per category. It is separate fromio.cozy.bannerson purpose: an app has write access there to carry a dismissal, and if the ordering rode on the visible document that app could rewind it. A stale revision is refused and the same revision applied twice converges, so a redelivery is a no-op. A command that retrying cannot fix is nacked and the broker dead letters it afterdelivery_limitattempts.The second commit is unrelated and reads on its own.
Extend()returned nothing, so the renewal goroutine of a long operation could not tell a renewed lease from a lost one: it kept renewing a lease Redis no longer held, and it outlivedUnlock. It now returns an error and the goroutine stops on the first failure. The two callers, the RAG workspace and the VFS migration, do not observe the lease themselves, so for them this is a goroutine that stops instead of one that lies.Worth knowing
Tested locally against a real broker: materialize inline and modal, clear, a stale revision refused, and malformed and reserved-category commands dead lettered.
TestHandlers/InstallAppfails, but it fails identically on master.One gap is open and I do not think it blocks: a commanded banner does not re-localize when the user changes their language, because we keep only the locale picked when the command arrived. The backend sends the full set, we drop the rest. Follow-up.
The old
stack.billing.lifecyclequeue and its dead letter queue stay declared on the broker. That is a deployment cleanup, not a code change.