Skip to content

Batch FX lookups, guard subscription updates with a concurrency token - #140

Merged
rghvgrv merged 2 commits into
mainfrom
feat/fx-batching-and-concurrency
Aug 8, 2026
Merged

Batch FX lookups, guard subscription updates with a concurrency token#140
rghvgrv merged 2 commits into
mainfrom
feat/fx-batching-and-concurrency

Conversation

@rghvgrv

@rghvgrv rghvgrv commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

The two design calls left open from the review. Branched from current main (ea7d9a1), so #139 is included.

Closes #137
Closes #138

#137 — one FX query instead of one per subscription

BurnRateCalculator called GetRateAsync per subscription, and FxRateService reads the cache with a fresh AsNoTracking query each time, so nothing deduplicated them. Twenty USD subscriptions against an INR home currency issued the same query twenty times — on the screen the app opens to, again on every SubscriptionsChangedMessage, against a 0.1 CPU instance and a scale-to-zero database.

IFxRateService gains GetRatesAsync: many base currencies, one target, one round trip (base_currency = ANY(...)). Absent from the returned dictionary means "no rate", exactly as null does for the single-pair call, so UnresolvedSubscriptionIds is unchanged.

I went with the batch method on the interface rather than memoizing inside FxRateService — memoization would have hidden the round trips rather than removed them, and left the interface implying a cost it no longer had.

Two behaviours deliberately kept: the on-demand fetch still runs for missed pairs only (a user's first subscription in a new currency must count before the next scheduled pass), and the target currency is dropped before querying since nothing stores an identity rate.

The calculator now walks the list twice, so it materializes rather than trusting the caller's IEnumerable — pinned by a test using a sequence that throws on second enumeration.

#138 — optimistic concurrency

Postgres' xmin mapped as a shadow row version. No column is addedxmin exists on every table, and Npgsql's generator emits no DDL for it. I verified with dotnet ef migrations script: the migration produces only the __EFMigrationsHistory insert. No backfill, no lock on an existing table. The generated C# reads as though it adds a column, so there's a comment on the file recording what the SQL actually is.

The token has to round-trip to be worth anything. Loading a row and saving it in the same call compares it with itself and can never conflict — so SubscriptionDto carries Version out and CreateSubscriptionRequest takes it back in, where it becomes the OriginalValue the UPDATE asserts on. This is the part that makes the feature real rather than decorative.

Version is optional, and that's a judgement call worth reviewing: APKs already sideloaded don't know the field and aren't force-upgraded, so omitting it keeps last-write-wins rather than failing every older client's save. New clients get the guarantee; old ones are no worse off than today. A test pins that path.

UpdateAsync now returns SubscriptionUpdateResultnull could no longer distinguish "no such subscription" from "someone got there first", and 404 and 409 are different answers.

Client behaviour is the one you picked: a 409 reloads the record and explains, without navigating away. Not a retry — a retry would re-apply an edit written against a state that no longer exists, which is the thing being prevented. Reloading also refreshes the version, so the next save isn't doomed to conflict again.

Testing

Suite Result
SubVora.Api.Tests 129 passed
SubVora.Mobile.Tests 166 passed
SubVora.Infrastructure.Tests 103 passed
SubVora.Application.Tests 38 passed

436 total, 0 failures, run in full against real Postgres via Testcontainers.

New coverage worth calling out — ConcurrentUpdateTests reproduces the actual bug end to end: create → mark paid → save a stale edit → 409, and the payment survives with NextBillingDate and LastPaidDate intact. Before this change that request succeeded and reversed the payment. Also covered: stale-edit-vs-edit, no-version-still-applies, 404-not-409 for a missing row, and on the client, version round-trip, the 409 reload, no spurious SubscriptionsChangedMessage on a refused save, a fresh version on retry, and the reload-also-failed case keeping the offline message rather than claiming a reload happened.

Note

SubscriptionDto and CreateSubscriptionRequest changed on both sides — there's no shared DTO project by design, so API and mobile were edited together, per CLAUDE.md.

rghvgrv and others added 2 commits August 8, 2026 14:39
…r subscription

BurnRateCalculator called GetRateAsync per subscription, and FxRateService reads
the cache with a fresh AsNoTracking query each time, so nothing deduplicated
them. Twenty USD subscriptions against an INR home currency issued the same
query twenty times - on the screen the app opens to, and again on every
SubscriptionsChangedMessage, against a 0.1 CPU instance and a scale-to-zero
database.

IFxRateService gains GetRatesAsync: many base currencies, one target, one round
trip. Npgsql turns the Contains into base_currency = ANY(...). Absent from the
returned dictionary means "no rate", exactly as a null return does for the
single-pair call, so UnresolvedSubscriptionIds behaves unchanged.

Chosen over memoizing inside FxRateService, which would have hidden the round
trips rather than removed them and left the interface implying a cost it no
longer had.

Two behaviours the batch deliberately keeps:

- The on-demand fetch still runs, for missed pairs only. A user's first
  subscription in a new currency has to count toward their totals before the
  next scheduled pass, and that path - cooldown included - is what does it.
- The target currency is dropped and the rest deduplicated before querying.
  Nothing stores an identity rate, so asking for one is a guaranteed miss that
  would then trigger a pointless provider call.

The calculator now walks the subscription list twice - once for currencies, once
for amounts - so it materializes rather than trusting the caller's IEnumerable.
A test pins that with a sequence that throws on second enumeration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An update read the row and wrote every field back with no check, so overlapping
writes were last-write-wins across the whole entity, silently.

The case that is not theoretical: MarkPaidAsync sets LastPaidDate and advances
NextBillingDate, while UpdateAsync writes NextBillingDate straight from the
request. An edit screen opened before a mark-paid - a second device, or a stale
detail page - wrote the pre-payment billing date back on save. The payment was
reversed and the row left inconsistent: LastPaidDate set, but the charge due
again on the old date. That matters more than an ordinary lost update, because a
past NextBillingDate is what IsOverdue reads, so the corrupted state reads as
"this charge is outstanding" for a charge already settled.

Postgres' xmin is mapped as a shadow row version. No column is added - xmin
exists on every table - and Npgsql's generator emits no DDL for it, so the
migration produces only the history row: no backfill, no lock on an existing
table. The migration file reads as though it adds a column; a comment there
records that `dotnet ef migrations script` proves otherwise.

The token has to make a round trip to be worth anything. Loading a row and
saving it in the same call compares it with itself and can never conflict, so
SubscriptionDto carries Version out and CreateSubscriptionRequest takes it back
in, where it becomes the OriginalValue the UPDATE asserts on.

Version is optional, deliberately. APKs already sideloaded do not know the field
and are not force-upgraded, so omitting it keeps the previous behaviour rather
than failing every older client's saves. New clients get the guarantee; old ones
are no worse off than before.

UpdateAsync returns SubscriptionUpdateResult rather than a nullable DTO, because
null could no longer distinguish "no such subscription" from "someone else got
there first" - 404 and 409 are different answers.

On the client, a 409 reloads the record and says so, rather than retrying: a
retry would re-apply an edit written against a state that no longer exists,
which is the very thing being prevented. Reloading also refreshes the version,
so the next save is not doomed to conflict again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rghvgrv
rghvgrv merged commit a20b3e0 into main Aug 8, 2026
3 checks passed
@rghvgrv
rghvgrv deleted the feat/fx-batching-and-concurrency branch August 8, 2026 09:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

No optimistic concurrency — concurrent edits silently overwrite each other Burn-rate does one FX database query per subscription

1 participant