fix(massif): fix stale materialized views and broken statistics endpoint - #1755
Conversation
- Split shared cron job into three independent jobs so a v_country_info failure no longer rolls back the successful v_massif_info refresh - Fix v_country_info unique index to include id_country, preventing duplicate key errors for caves whose entrances span multiple countries - Replace isMassifInView() check against the materialized view with a direct TMassif lookup so stale/empty view data never causes a 404 - Remove silent error-swallowing in safeDBQuery; propagate errors to the controller try/catch instead
Paul-AUB
left a comment
There was a problem hiding this comment.
Thanks for the very thorough root-cause analysis in #1754 — the chain (shared cron transaction → wrong unique index → view-based existence check) is well documented and the three code fixes address it precisely. The controller/service refactor looks correct to me: TMassif.findOne({ id, isDeleted: false }) matches the model (api/models/TMassif.js:45-50, defaultsTo: false), the fixtures keep GET /api/v1/massifs/1/statistics at 200, and the ?./== null guards correctly handle safeDBQuery returning null for the LIMIT 1 queries.
My main concern is that the two SQL fixes are edits to database-creation files, so they only reach freshly created databases.
Issues (Must Fix)
-
[sql/91_materialized_views.sql:324] The index fix is applied to a creation-time file, not a migration.
docker/docker-compose.yml:18mounts../sqlat/docker-entrypoint-initdb.d, so0_,1_and91_files only run when the database is created;sql/README.mdasks for a new prefixed migration file for anything that must reach an existing database. As it stands, every already-provisioned database (staging, developer databases restored from a dump, and production if it is ever rebuilt from these scripts) keepsv_country_info_id_massif_id_cave_idxand will keep abortingREFRESH MATERIALIZED VIEW CONCURRENTLY v_country_infoexactly as described in #1754. Consider adding a dated migration alongside the edit, in the style ofsql/9_18_2026_08_04_rename_j_document_grotto_author_fk.sql:DROP INDEX IF EXISTS v_country_info_id_massif_id_cave_idx; CREATE UNIQUE INDEX IF NOT EXISTS v_country_info_country_massif_cave_idx ON v_country_info (id_country, id_massif, id_cave);
That also makes the manual production fix from 2026-08-05 reproducible from the repository rather than existing only as a one-off.
-
[sql/1_cron.sql:14-40] The previous job is never unscheduled.
cron.schedule_in_databaseupserts by job name, and the three new calls use new names (Refresh massif info view every 3 days, …), so on any existing database the old'Refresh info views every 3 days'entry stays incron.joband keeps executing the old multi-statement body — the same single-transaction rollback this PR is fixing, now running in parallel with the new jobs. Note this file cannot simply be re-run either, since line 2 isCREATE EXTENSION pg_cron;withoutIF NOT EXISTS. The same migration suggested in item 1 could drop it, keeping in mind thatcron.unscheduleraises if the job is absent:DELETE FROM cron.job WHERE jobname = 'Refresh info views every 3 days';
Suggestions (Should Consider)
-
[api/controllers/v1/massif/get-statistics.js:76-84] Removing the silent
try/catchfromsafeDBQueryis a real improvement, but the new catch routes toControllerService.treat(req, err, …), which returnsres.badRequest(\${parameters.controllerMethod} error: ${err}`)(api/services/ControllerService.js:59-61`). Two consequences worth weighing, given that #1754 is precisely a story about a failure nobody noticed for five weeks:-
api/helpers/log-response.js:2-3logs 400 atverboseseverity (only 401/403/404/409/422 areinfoand 500 iserror), so a genuine database failure produces no actionable server-side log and no 5xx for alerting to pick up. - The raw error string is echoed to an anonymous caller (the route is public per
config/policies.js), which can expose relation/index names.
ControllerService.treatAndConverthandles this withsails.log.error(err)+res.serverError(...). Adding at leastsails.log.error(err)beforetreat— or usingres.serverError— would match that and keep the diagnosability this PR is aiming for. I realiseapi/controllers/v1/massif/find.js:41-42uses the sametreatpattern, so this is as much about the shared helper as about this PR. -
-
[test/integration/4_routes/Massifs/get-statistics.test.js] The behaviour change at the heart of this PR — an existing massif that has no rows in
v_massif_infonow returns 200 instead of 404 — has no test. The fixtures already provide the case for free: massif101exists intest/fixtures/tmassif.json(non-sensitive) and has no row intest/fixtures/vmassifinfo.json, soGET /api/v1/massifs/101/statisticsshould now return 200 withnb_caves: 0and null aggregates. A companion test asserting a soft-deleted massif still returns 404 would lock in theisDeleted: falsefilter as well. Three service tests were removed here and nothing replaced them. -
[api/services/StatisticsMassifService.js:54-61]
safeDBQueryno longer swallows anything, so the name is now misleading — something likequeryFirstRowwould read truer. Relatedly, the JSDoc on all seven exported methods still ends with "or null if no result or something went wrong" (lines 68, 76, 84, 93, 102, 111, 120); after this change they return null only when there is no row, and throw otherwise. -
[api/services/StatisticsCountryService.js:79-101] The country endpoint still has both halves of the pattern fixed here:
safeDBQueryswallowing every error intonull(lines 79-89) andisCountryInViewused as an existence check byapi/controllers/v1/country/get-statistics.js:7. Since #1754 lists/countries/:id/statisticsamong the impacted endpoints, applying the same treatment here (or opening a follow-up alongside the item 4 alerting issue) would close the loop.api/controllers/v1/region/get-statistics.js:10already checks the realTISO31662table, so regions are fine.
Nitpicks (Optional)
-
[api/controllers/v1/massif/get-statistics.js:5] Sibling massif controllers coerce the route param first —
const massifId = Number(req.params.id);(api/controllers/v1/massif/find.js:12). Waterline coerces the numeric string fine here, so this is purely about matching the neighbours. -
[api/controllers/v1/massif/get-statistics.js:38-65] The file mixes
parseIntandNumber.parseInt(line 65 is the onlyNumber.parseInt). Pre-existing, but since these lines were all rewritten it is a cheap consistency win —api/controllers/v1/country/get-statistics.jsusesNumber.parseIntthroughout. -
[sql/91_materialized_views.sql:324] Worth noting for later: the new index is unique only as long as a cave has at most one
t_namerow withis_main = true, sincen.nameis part of the view'sGROUP BY(line 40) but not part of the index. That is the same assumption the pre-existingv_massif_infoindex makes, so nothing to change here — but if that invariant is ever broken, all three views fail the same silent way, which is another argument for the cron alerting tracked as item 4 of #1754. -
[sql/1_cron.sql:26,35] The 5/10-minute stagger is a reasonable heuristic, though
REFRESH … CONCURRENTLYon the larger views can run longer than that and pg_cron gives each job its own worker, so overlap is still possible. Harmless now that a failure in one can no longer cascade — just worth not relying on the stagger for serialisation.
|
Thanks for the thorough review @Paul-AUB! Items 1 & 2 (SQL migration file) — intentionally not addressed in this PR. Both fixes (drop/recreate the Items 3–8 — all addressed in the latest commit:
|
Paul-AUB
left a comment
There was a problem hiding this comment.
Thanks for the quick turnaround — items 3–8 are all addressed cleanly.
Spot-checking the ones that mattered most:
- 3 —
res.serverErroris a good call overres.badRequest:api/responses/serverError.js:31always returns the fixed'An internal server error occurred.'payload, so the raw error string no longer reaches an anonymous caller, andapi/helpers/log-response.js:3puts 500 aterrorseverity on top of your explicitsails.log.error(err). Both halves of that item are closed. - 4 — The two new tests are exactly the cases I had in mind.
GET /api/v1/massifs/101/statistics→ 200 withnb_caves: 0locks in the view-independence fix, and massif 102 → 404 locks in theisDeleted: falsefilter.count.test.jsassertsgreaterThanOrEqual(0)so the two new fixture rows don't disturb it. - 6 —
TCountryhas noisDeletedattribute (api/models/TCountry.js), soTCountry.findOne({ id: countryId })without a soft-delete filter is right for that model.
Items 1 & 2 (SQL migration) — understood, dropping them. Your call on the deployment process.
One regression came in with item 7, plus a few follow-up notes.
Issues (Must Fix)
-
[api/controllers/v1/massif/get-statistics.js:5,10] The
Number(req.params.id)coercion turns a malformed id into a 500 instead of a 404. The route has no numeric constraint (config/routes.js:257), so/api/v1/massifs/abc/statisticsreaches the controller,Number('abc')givesNaN, and Waterline rejects it before it ever hits the database —normalize-pk-valuethrowsE_INVALID_PK_VALUE(Cannot use \NaN` as a primary key value). I checked the same helper against the other shapes a URL can produce, and all of these throw:NaN,1.5,-1,0. Each one now lands in the newcatch, so/massifs/abc,/massifs/0and/massifs/-1` all return 500 with an error-level log.Previously these returned 404:
safeDBQueryswallowed the Postgres22P02cast error intonull, and the view check treated that as "not found". That swallow was worth removing, but the 404 it produced for garbage input was the correct status.This matters more than usual for this PR specifically. The reason for moving to
res.serverError+sails.log.errorwas so a genuine database failure becomes visible to alerting — and any crawler or stale link hitting/massifs/<slug>/statisticsnow feeds that same channel. Suggest guarding before the lookup:const massifId = Number(req.params.id); if (!Number.isSafeInteger(massifId) || massifId <= 0) { return res.notFound({ message: `Massif of id ${req.params.id} not found` }); }
Number.isIntegeralone isn't enough, since0and negatives throw too. A/massifs/abc/statistics→ 404 test alongside the two you just added would pin it down.api/controllers/v1/massif/find.js:12has the same exposure, but its catch routes toControllerService.treat→ 400, so it at least stays in 4xx. The country endpoint isn't affected —TCountry.idis a string PK, so no coercion happens.
Suggestions (Should Consider)
-
[test/integration/4_routes/Countries/get-statistics.test.js] The country endpoint got the same behaviour change as the massif one — an existing country with no rows in
v_country_inforeturns 200 instead of 404 — but no test came with it, and three service tests were removed on that side. The fixture case is free:test/fixtures/vcountryinfo.jsoncontains onlyid_country: "FR", whiletest/fixtures/tcountry.jsonalso hasES,GBandUS. SoGET /api/v1/countries/GB/statisticsshould now return 200 withnb_caves: 0, mirroring the massif 101 test exactly. -
[api/services/StatisticsRegionService.js:79-89] Regions are now the odd one out.
safeDBQuerythere still swallows every error intonull, andapi/controllers/v1/region/get-statistics.jshas notry/catchat all. That combination has a concrete failure mode: on a DB error the aggregate helpers returnnull, and the guard at line 39 isnbMassifs && nbMassifs.nb_massifs === null ? null : Number.parseInt(nbMassifs.nb_massifs, 10)— whennbMassifsisnullthe condition is falsy, so the else branch dereferences it and throwsTypeError. That's the same null-handling this PR fixed with?./== nullin the other two controllers. Since #1754 lists/countries/:countryId/regions/:regionId/statisticsamong the impacted endpoints and this PR givesv_region_infoits own cron job, applying the same treatment here — or a follow-up issue next to the item 4 alerting one — would finish the set.
Nitpicks (Optional)
-
[api/services/StatisticsRegionService.js:98]
isRegionInViewis dead code — the region controller already checksTISO31662(api/controllers/v1/region/get-statistics.js:10), and the only remaining reference is its own test attest/integration/1_services/StatisticsRegionService.test.js:5-14. Pre-existing, but it's the third sibling of the two methods this PR deletes, so it would go naturally with suggestion 3. -
PR description — the
🔍 Howsection still lists only the massif-side changes. The country controller and service are now a comparable share of the diff (78+/65− and 24+/51−), and the description doubles as the merge commit message.
27c34ac to
dfaa450
Compare
|
Amended the previous commit to address the new round of review items. Must fix:
Should consider: Nitpicks: Updated 🔍 How section:
|
Paul-AUB
left a comment
There was a problem hiding this comment.
All five items from the previous round are addressed, and extending the treatment to the region endpoint closes the loop on #1754's three impacted endpoints. Nothing blocking left from my side.
Verification of each item:
1 — Invalid id guard. api/controllers/v1/massif/get-statistics.js:8-10 reads !Number.isSafeInteger(massifId) || massifId <= 0, which is the correct form. (The summary comment above writes it as Number.isSafeInteger(massifId) && massifId <= 0 — shorthand only, the code is right; that variant would have let NaN and floats through.) I re-checked the four shapes a URL can produce against normalize-pk-value: NaN, 1.5, -1 and 0 all throw E_INVALID_PK_VALUE, and all four are now intercepted before the lookup. The /massifs/abc/statistics → 404 test pins the NaN case.
2 — Country test. Correct fixture reasoning: test/fixtures/vcountryinfo.json contains only id_country: "FR", and GB is present in test/fixtures/tcountry.json:37. GET_NB_CAVES is SELECT COUNT(*) FROM (SELECT DISTINCT id_cave …) as tmp, which always yields one row, so nb_caves: 0 is the right assertion rather than null.
3 — Region endpoint. api/services/StatisticsRegionService.js now matches its two siblings, and the null guards in api/controllers/v1/region/get-statistics.js are fixed. Worth noting the old && form was only wrong in the null case — nbMassifs && nbMassifs.nb_massifs === null behaved identically to nbMassifs?.nb_massifs == null for both { nb_massifs: '5' } and { nb_massifs: null }, and only diverged when the whole row was null, where it threw TypeError. So this is a strict improvement with no behavioural change on the paths that were already working.
4 — isRegionInView. Gone; git grep finds no remaining reference to any of the three isXInView methods. The queryFirstRow rename is applied consistently and no JSDoc still carries "or something went wrong" across the three services.
5 — PR description. Updated.
Nitpicks (Optional)
- [api/services/MassifService.js:145-152] Unrelated to this PR, just noting it since
git grep safeDBQuerynow returns exactly one hit. This one swallows into[]rather thannulland carries an explicit rationale in the comment ("happens when the longitude and latitude are null for example"), so it is a deliberate different case — no action suggested, only flagging that the name now refers to a single, differently-motivated helper.
For the record on items 1 & 2 of the first round (a dated migration for the v_country_info index and the unscheduling of 'Refresh info views every 3 days'): approving here reflects that the deployment process is your call, not that I think the repository now reproduces the manual production fixes of 2026-08-05. If a database is ever rebuilt from sql/, the corrected index and the split jobs will be there — it is only existing databases that still depend on the manual steps.
CI was still running at the time of this review; the reasoning above is from reading the code and fixtures rather than from a green build.
- Use sails.log.error + res.serverError in massif and country get-statistics catch blocks so DB failures produce a 500 and an error-level log instead of a silent 400 - Add tests: massif absent from view returns 200, soft-deleted massif returns 404; add fixture for deleted massif 102 - Rename safeDBQuery to queryFirstRow in StatisticsMassifService and StatisticsCountryService; fix JSDoc (drop 'or something went wrong') - Remove isCountryInView from StatisticsCountryService; country get-statistics now checks TCountry.findOne like the massif controller; remove corresponding service tests - Coerce massifId to Number() in massif get-statistics controller - Replace all parseInt with Number.parseInt in massif controller
dfaa450 to
57cbdd6
Compare
|
Addressed the nitpick from the third review round: renamed |
🤔 What
Fix stale materialized views causing
GET /api/v1/massifs/:id/statisticsto return 404 for massifs that exist and have caves. Addresses items 1–3 from #1754 (item 4 — cron failure alerting — is tracked separately).pg_cronjobsv_country_infounique index to includeid_countryisMassifInView()existence check with a directTMassiftable lookup🤷♂️ Why
Since 2026-07-01, every refresh of the three "info" materialized views has failed silently. The root causes were chained:
Shared cron job —
v_massif_info,v_country_info, andv_region_infowere all refreshed in a singlepg_cronjob body. Becausepg_cronexecutes multi-statement strings as one implicit transaction, a failure on statement 2 rolled back the successful statement 1.v_massif_infowas never updated despite succeeding.Wrong unique index on
v_country_info— the index was on(id_massif, id_cave), but the view'sGROUP BYincludesid_country. Cave 75266 (Pierre-Saint-Martin) has entrances in both FR and ES inside massif 4, producing two rows with the same(id_massif, id_cave)pair.REFRESH MATERIALIZED VIEW CONCURRENTLYaborted every time.View-based existence check —
isMassifInView()checked the materialized view to decide whether to 404. A stale or empty view made every massif that wasn't cached appear non-existent.🔍 How
sql/1_cron.sql: Replaced the single multi-statement job with four independentcron.schedule_in_databasecalls — one per view, staggered by 5 minutes. A failure in one view no longer affects the others.sql/91_materialized_views.sql: ChangedCREATE UNIQUE INDEX ON v_country_info(id_massif, id_cave)to(id_country, id_massif, id_cave), matching the actualGROUP BYkey.api/services/StatisticsMassifService.js: RemovedisMassifInView()and the silenttry/catchinsafeDBQuerythat discarded errors. Errors now propagate to the caller.api/controllers/v1/massif/get-statistics.js: Replaced the view check withTMassif.findOne({ id, isDeleted: false }), wrapped everything in atry/catchthat routes toControllerService.treat.🧪 Testing
StatisticsMassifServicetests updated to removeisMassifInView()coverage (method deleted); all other service tests pass.npm run dev:cleanto take effect locally.📸 Previews
N/A — JSON API endpoint, no UI changes.