2026-05-23DataMesh Consulting
23 May — MERX Canada and AusTender extractors land, iOS Matches refresh-cancel hotfix
Two more national portals come online — MERX (Canada's federal + provincial procurement aggregator) and AusTender (Australian federal government). Both as Step-0 extractors with full detail enrichment. On the iOS side, a hotfix went out as version 1.2 for a stubborn "Couldn't refresh" banner that some users were seeing on cold launch even though the server was returning fresh data fine. Root cause turned out to be a SwiftUI lifecycle quirk cancelling the in-flight URLSession task during AppState state churn; the fix bounded-retries on URLError.cancelled so the next attempt completes cleanly. Diagnostic instrumentation stays in this build to surface any remaining edge cases.
MERX Canada — federal + provincial aggregator
MERX is the canonical Canadian procurement aggregator. It consolidates notices from Public Services and Procurement Canada (federal) plus most provincial procurement systems behind a single search interface. The portal exposes a public listing JSON endpoint that returns notices with enough metadata to populate the Tender model fully — no detail-page round-trip needed for ~80% of notices.
For the remaining 20% (province-routed listings where the detail lives on the originating provincial site), the extractor follows the source URL and parses the provincial detail page format. Six provincial layouts are handled explicitly (Ontario, Quebec, BC, Alberta, Manitoba, Nova Scotia); others fall back to a generic OCDS-shaped parser that works for the standardised notices.
Initial scrape returned ~7,200 active notices. Country code
on all of them resolves to CA with the original
province code preserved in nutsCodes[] (using the
ISO 3166-2:CA codes — CA-ON, CA-QC, etc., adapted to
our NUTS-style storage).
AusTender — tenders.gov.au
AusTender is the Australian federal government's procurement portal. The listing is paginated HTML; per-notice detail pages follow a consistent layout that parses cleanly with cheerio.
- Listing —
?ShowAll=true&pageSize=100&page=N, ordered
- Detail enrichment — agency, value, contract type,
- Australian Government Procurement classifications —
cpvCodes[] alongside the
CPV mapping, so AU-specific filtering still works for
domestic users.
First scrape: ~2,100 active notices. Median value: AUD 180,000, weighted toward services contracts (IT, consulting, maintenance).
Both new extractors are now in the scrape-push-prod registry, so they'll join the regular cycle from the next scheduled poll.
iOS hotfix — 1.2 (build 22)
Some users — including the on-call account — were seeing
the "Couldn't refresh" banner persist on the Matches tab
across multiple pull-to-refreshes, with the feed showing
cached tenders instead of the live top matches. The
production Cloud Run logs showed every /v1/tenders/matches
call returning HTTP 200 with a healthy 145 KB body. So the
data was making it to the device.
Tracking it down took most of the afternoon. The thread:
1. Initial hypothesis: race condition. HomeViewModel's
load() function was clearing errorMessage only at the
start of an attempt, so if two loads overlapped and the
failing one's catch ran after the succeeding one's
completion, the banner would stick. Added a errorMessage
= nil on the success path. This addressed a bug but
not the one users were seeing.
2. Second hypothesis: decode failure. Synthesized
Swift Codable for [Tender] is all-or-nothing — a
single bad row breaks the whole array decode. Wrapped
each row in a failable decoder so one bad row gets
skipped rather than dropping the user to cache. Useful
defensive change; not the root cause either.
3. Cold-launch test in TestFlight. After force-quit and
reopen the banner appeared immediately with cached
data. So load() was definitely throwing on the first
attempt — but getMatches() returns 200 over the wire,
and the bytes (verified with sha256 against a synthetic
fetch from inside Cloud Run as the same user) decode
cleanly through Swift's JSONDecoder.
4. Diagnostic build. Surfaced the actual Error value
in the banner text. Result: URL #-999 cancelled.
5. Root cause: SwiftUI .task cancellation. During the
cold-launch state churn — AppState.checkAuthStatus
updates currentUser, isAuthenticated,
hasAcceptedAIConsent, and unreadNotificationCount in
close succession — the HomeView body re-evaluates
multiple times. The .task modifier's underlying Task
gets cancelled when the view briefly remounts, which
cancels the in-flight URLSession data task, which throws
URLError.cancelled into the catch path.
The fix: on URLError.cancelled, skip the user-visible
banner/cache fallback and schedule one retry 400 ms later.
The retry runs after the AppState churn has settled, the
view is stable, and the URLSession data task completes
normally. Bounded to two retries so a genuine cancellation
storm still surfaces eventually.
Shipped as version 1.2 build 22 to TestFlight. Diagnostic fingerprint instrumentation stays in the build to catch any other transport-layer surprises; will be stripped before the next public App Store push once we confirm the fix is holding in the wild.
Catch-up week
This is the sixth daily update in a six-day catch-up — the public status log had been silent since 15 May while the extractor wave and the iOS submission consumed engineering attention. Going forward the cadence is back to one update per shipping day. If a day has no notable ship, no entry — we'd rather have an honest gap than fill it with cruft.