diff --git a/Core/Resgrid.Search/LuceneIndexHost.cs b/Core/Resgrid.Search/LuceneIndexHost.cs index 3ee542e47..dbbecea28 100644 --- a/Core/Resgrid.Search/LuceneIndexHost.cs +++ b/Core/Resgrid.Search/LuceneIndexHost.cs @@ -399,8 +399,10 @@ private void StartBackgroundPullIfDue() catch (Exception ex) { // Only one background pull runs at a time, so the counter has a single writer. Error, not Fatal: the reader - // keeps serving its last local revision and the next attempt backs off. + // keeps serving its last local revision and the next attempt backs off. The backoff runs from the failure, + // not from the attempt: a request that hangs until the client timeout would otherwise have used it all up. var failures = ++_consecutivePullFailures; + _lastPullAttemptUtc = DateTime.UtcNow; Logging.LogError(ex, $"Search index '{IndexName}' pull from the object store failed ({failures} in a row); next attempt in {PullInterval().TotalSeconds:0}s at the earliest."); } }); diff --git a/Core/Resgrid.Services/Records/RecordsOccupancyService.cs b/Core/Resgrid.Services/Records/RecordsOccupancyService.cs index ac7b61416..aa8c662b7 100644 --- a/Core/Resgrid.Services/Records/RecordsOccupancyService.cs +++ b/Core/Resgrid.Services/Records/RecordsOccupancyService.cs @@ -492,8 +492,11 @@ async Task AddressOf(Contact c) list.Add(new SourceCandidate { Kind = RmsOccupancyCrosswalkSourceKind.Contact, SourceId = contact.ContactId, ContactId = contact.ContactId, DisplayName = contact.Name, NormalizedAddress = address, Latitude = point.HasValue ? (decimal?)(decimal)point.Value.Latitude : null, Longitude = point.HasValue ? (decimal?)(decimal)point.Value.Longitude : null }); } - // Pois carry no DepartmentId column; a department owns its POIs through their POI type. - var poiTypes = (await _poiTypes.GetPoiTypesByDepartmentIdAsync(departmentId)) ?? Enumerable.Empty(); + // Pois carry no DepartmentId column; a department owns its POIs through their POI type. The repository logs and + // returns null on failure (an empty query is an empty list), and treating that as "no POIs" would stamp an + // inventory that never saw them as complete. + var poiTypes = (await _poiTypes.GetPoiTypesByDepartmentIdAsync(departmentId)) + ?? throw new InvalidOperationException("The department's points of interest could not be loaded, so the inventory was not recorded. Try again."); foreach (var poi in poiTypes.Where(t => t?.Pois != null).SelectMany(t => t.Pois).Where(p => p != null)) { list.Add(new SourceCandidate { Kind = RmsOccupancyCrosswalkSourceKind.Poi, SourceId = poi.PoiId.ToString(), DisplayName = poi.Name, NormalizedAddress = AddressNormalizer.Normalize(poi.Address), diff --git a/Tests/Resgrid.Tests/Rms/RecordsOccupancyServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsOccupancyServiceTests.cs index e5a5bfd68..a32281f32 100644 --- a/Tests/Resgrid.Tests/Rms/RecordsOccupancyServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordsOccupancyServiceTests.cs @@ -133,6 +133,24 @@ public async Task Inventory_groups_sources_by_address_and_proximity_and_suggests _h.Crosswalks.Rows.Should().HaveCount(3); } + [Test] + public async Task A_failed_poi_lookup_fails_the_inventory_instead_of_recording_it_without_pois() + { + SeedContactsWorld(); + // PoiTypesRepository logs and returns null when its query fails; a department with no POI types gets an empty list. + _h.PoiTypes.Setup(p => p.GetPoiTypesByDepartmentIdAsync(Dept)).ReturnsAsync((IEnumerable)null); + + Func inventory = () => _h.OccupancyService.InventoryCandidatesAsync(Dept, Admin); + await inventory.Should().ThrowAsync(); + _h.Crosswalks.Rows.Should().BeEmpty(); + var status = await _h.OccupancyService.GetReconciliationStatusAsync(Dept); + status.InventoriedOn.Should().BeNull("an inventory that never saw the POIs is not recorded as run"); + status.State.Should().Be(RmsOccupancyOwnershipState.ContactsOwned); + + _h.PoiTypes.Setup(p => p.GetPoiTypesByDepartmentIdAsync(Dept)).ReturnsAsync(new List()); + (await _h.OccupancyService.InventoryCandidatesAsync(Dept, Admin)).SourcesScanned.Should().Be(2, "no POI types is a successful, empty lookup"); + } + [Test] public async Task Linking_a_preplan_candidate_creates_an_occupancy_with_provenance_hazards_and_a_site_link_then_the_switch_becomes_possible() {