PROD: sponsor/profiles + geography/travel-stats 500 — UserProfile.Social required-owned-nav throws on pre-#631 documents (schema drift) #672

Closed
opened 2026-07-17 18:16:24 +00:00 by spikerj · 1 comment
Owner

Confirmed root cause (supersedes the earlier "poisoned document" hypothesis)

Server log for traceId 476a07892afaa9a0f4973bc08686bf09:

System.InvalidOperationException: Field 'Social' required but not present in BsonDocument for a 'UserProfile'.
  at MongoDB.EntityFrameworkCore.Storage.BsonBinding.GetBsonDocument(BsonDocument parent, String name, Boolean required, ITypeBase declaredType)
  ... SpikerSoft.Api.Domain.Sponsor.SponsorController.GetPublicProfiles(...) : line 90

Not a corrupt document — schema drift from the social layer (#631, deployed 2026-07-17). That PR added UserProfile.Social as a non-nullable native embedded sub-document. The MongoDB EF Core provider maps a non-nullable owned navigation as required, so materializing any user-profiles document written before #631 (no Social element) throws. Both broken endpoints scan the entire UserProfiles collection (GetPublicProfiles, GetCountryTravelStatsQueryHandler), so they hit a legacy document and 500. Healthy endpoints (/api/geography/countries, /api/blog/posts) don't touch UserProfiles.

Social was the lone single-object sub-document on UserProfile that was neither JSON-string value-converted (Learning/Travel/Appearance/Sponsorship/…) nor nullable — every sibling already tolerates absence. Scan confirmed no other single-object nested prop has this shape (TravelDocuments is unconverted too but is a List → missing array = empty, never throws).

Fix

Code: spikersoft-backend PR #374 — maps Social as an optional owned navigation (ProfileSocial? + Navigation(...).IsRequired(false)); absent → null → all-off defaults; all readers coalesce. New integration test writes a legacy-shaped doc and proves a full-collection scan no longer throws (2/2 pass on Testcontainers Mongo; removing the mapping change reproduces this exact error).

Immediate mitigation (DBA, before the deploy lands): backfill the default Social sub-document onto legacy documents. Shape/enum verified against a real EF-written doc (PascalCase elements, DisplayNameMode as Int32 0). Idempotent, order-independent with the code fix (needed under the current required mapping, harmless under the fixed optional one). Self-verifying mongosh script:

// Run: mongosh "mongodb://<mongo-router>:27017/<db>" backfill-userprofile-social.mongosh.js
const coll = db.getCollection("user-profiles");

const missing = coll.countDocuments({ Social: { $exists: false } });
print(`[backfill] documents missing 'Social': ${missing}`);

// Verify shape against a known-good doc before writing (keys: DisplayNameMode Int32,
// ShareBookmarks/ShareReadingList/ViewOthersBookmarks Bool).
const sample = coll.findOne({ Social: { $exists: true } }, { Social: 1 });
if (sample) { print("[backfill] existing 'Social' shape:"); printjson(sample.Social); }

if (missing > 0) {
  const res = coll.updateMany(
    { Social: { $exists: false } },
    { $set: { Social: {
        ShareBookmarks: false,
        ViewOthersBookmarks: false,
        ShareReadingList: false,
        DisplayNameMode: NumberInt(0),   // NumberInt => BSON Int32 (matches EF enum storage)
    } } }
  );
  print(`[backfill] matched=${res.matchedCount} modified=${res.modifiedCount}`);
  print(`[backfill] still missing: ${coll.countDocuments({ Social: { $exists: false } })}`);
}

Note the earlier scan output for PROBE_SOCIAL_SHAPE/enum was verified empirically: { DisplayNameMode: 0, ShareBookmarks: false, ShareReadingList: false, ViewOthersBookmarks: false }.

Follow-up (separate)

GetPublicProfiles / GetCountryTravelStats materialize whole UserProfile documents where a projection would do — narrowing them would cut memory and structurally dodge this bug class.

## Confirmed root cause (supersedes the earlier "poisoned document" hypothesis) Server log for traceId `476a07892afaa9a0f4973bc08686bf09`: ``` System.InvalidOperationException: Field 'Social' required but not present in BsonDocument for a 'UserProfile'. at MongoDB.EntityFrameworkCore.Storage.BsonBinding.GetBsonDocument(BsonDocument parent, String name, Boolean required, ITypeBase declaredType) ... SpikerSoft.Api.Domain.Sponsor.SponsorController.GetPublicProfiles(...) : line 90 ``` Not a corrupt document — **schema drift from the social layer (#631, deployed 2026-07-17)**. That PR added `UserProfile.Social` as a **non-nullable native embedded sub-document**. The MongoDB EF Core provider maps a non-nullable owned navigation as **required**, so materializing any `user-profiles` document written before #631 (no `Social` element) throws. Both broken endpoints scan the **entire** `UserProfiles` collection (`GetPublicProfiles`, `GetCountryTravelStatsQueryHandler`), so they hit a legacy document and 500. Healthy endpoints (`/api/geography/countries`, `/api/blog/posts`) don't touch `UserProfiles`. `Social` was the lone single-object sub-document on `UserProfile` that was neither JSON-string value-converted (Learning/Travel/Appearance/Sponsorship/…) nor nullable — every sibling already tolerates absence. Scan confirmed no other single-object nested prop has this shape (`TravelDocuments` is unconverted too but is a `List` → missing array = empty, never throws). ## Fix **Code:** spikersoft-backend PR #374 — maps `Social` as an optional owned navigation (`ProfileSocial?` + `Navigation(...).IsRequired(false)`); absent → null → all-off defaults; all readers coalesce. New integration test writes a legacy-shaped doc and proves a full-collection scan no longer throws (2/2 pass on Testcontainers Mongo; removing the mapping change reproduces this exact error). **Immediate mitigation (DBA, before the deploy lands):** backfill the default `Social` sub-document onto legacy documents. Shape/enum verified against a real EF-written doc (PascalCase elements, `DisplayNameMode` as Int32 0). Idempotent, order-independent with the code fix (needed under the current required mapping, harmless under the fixed optional one). Self-verifying mongosh script: ```js // Run: mongosh "mongodb://<mongo-router>:27017/<db>" backfill-userprofile-social.mongosh.js const coll = db.getCollection("user-profiles"); const missing = coll.countDocuments({ Social: { $exists: false } }); print(`[backfill] documents missing 'Social': ${missing}`); // Verify shape against a known-good doc before writing (keys: DisplayNameMode Int32, // ShareBookmarks/ShareReadingList/ViewOthersBookmarks Bool). const sample = coll.findOne({ Social: { $exists: true } }, { Social: 1 }); if (sample) { print("[backfill] existing 'Social' shape:"); printjson(sample.Social); } if (missing > 0) { const res = coll.updateMany( { Social: { $exists: false } }, { $set: { Social: { ShareBookmarks: false, ViewOthersBookmarks: false, ShareReadingList: false, DisplayNameMode: NumberInt(0), // NumberInt => BSON Int32 (matches EF enum storage) } } } ); print(`[backfill] matched=${res.matchedCount} modified=${res.modifiedCount}`); print(`[backfill] still missing: ${coll.countDocuments({ Social: { $exists: false } })}`); } ``` Note the earlier scan output for `PROBE_SOCIAL_SHAPE`/enum was verified empirically: `{ DisplayNameMode: 0, ShareBookmarks: false, ShareReadingList: false, ViewOthersBookmarks: false }`. ## Follow-up (separate) `GetPublicProfiles` / `GetCountryTravelStats` materialize whole `UserProfile` documents where a projection would do — narrowing them would cut memory and structurally dodge this bug class.
spikerj changed title from PROD: /api/sponsor/profiles and /api/geography/travel-stats return 500 — likely poisoned UserProfiles materialization; fails e2e-anonymous CI to PROD: sponsor/profiles + geography/travel-stats 500 — UserProfile.Social required-owned-nav throws on pre-#631 documents (schema drift) 2026-07-17 18:42:37 +00:00
Author
Owner

Verified resolved end-to-end — closing. (Ticket-triage loop 2026-07-18.)

  • Code fix merged: backend PR #374UserProfile.Social is ProfileSocial? with Navigation(...).IsRequired(false) on current master (SpikerDbContext L930-931), with the in-code comment documenting the required-owned-nav trap. The regression test UserProfileSocialAbsenceEfDeserializationIntegrationTests (legacy-shaped doc + full-collection scan) is in the integration suite.
  • Production verified live: both previously-500 endpoints now return 200 —
    • GET https://api.spikersoft.com/api/sponsor/profiles200
    • GET https://api.spikersoft.com/api/geography/travel-stats200

Full-collection scans over user-profiles no longer throw on pre-#631 documents, which also means either the deploy alone fixed it (optional mapping tolerates absence regardless of backfill) or the backfill ran too — either way the failure mode is gone and the code no longer depends on the backfill. Closing.

**Verified resolved end-to-end — closing.** (Ticket-triage loop 2026-07-18.) - **Code fix merged:** backend PR #374 — `UserProfile.Social` is `ProfileSocial?` with `Navigation(...).IsRequired(false)` on current master (`SpikerDbContext` L930-931), with the in-code comment documenting the required-owned-nav trap. The regression test `UserProfileSocialAbsenceEfDeserializationIntegrationTests` (legacy-shaped doc + full-collection scan) is in the integration suite. - **Production verified live:** both previously-500 endpoints now return 200 — - `GET https://api.spikersoft.com/api/sponsor/profiles` → **200** - `GET https://api.spikersoft.com/api/geography/travel-stats` → **200** Full-collection scans over `user-profiles` no longer throw on pre-#631 documents, which also means either the deploy alone fixed it (optional mapping tolerates absence regardless of backfill) or the backfill ran too — either way the failure mode is gone and the code no longer depends on the backfill. Closing.
Sign in to join this conversation.