Skip to content

Commit be2da77

Browse files
committed
fix(overture): harden integration lifecycle
1 parent ac31232 commit be2da77

35 files changed

Lines changed: 1342 additions & 218 deletions

apps/api/.env.example

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -200,9 +200,10 @@ PASSKEY_ORIGIN=http://127.0.0.1:3000,openmapx://
200200
# default: https://raw.githubusercontent.com/openmapx/community-extensions/main/catalog.json
201201
# EXTENSION_CATALOG_URL=
202202

203-
# Overture Maps POI integration (monthly data pull by data-manager)
203+
# Overture Maps POI integration (weekly release check by data-manager)
204204
# OVERTURE_ENABLED=false
205-
# OVERTURE_SYNC_CRON=0 5 1 * *
205+
# OVERTURE_SYNC_CRON=0 5 * * 2
206+
# OVERTURE_CONFLATION_RETRY_CRON=*/15 * * * *
206207

207208
# Instance display name shown in the admin UI header. Optional; falls back to
208209
# the value set in /admin/settings.

apps/web/src/components/admin/services/OvertureMaintenance.test.tsx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,39 @@ describe("OvertureMaintenance", () => {
3333
expect(markup).toContain("completed");
3434
expect(markup).not.toContain("completed · complete");
3535
});
36+
37+
it("uses phase-specific progress denominators", async () => {
38+
const { overtureProgress } = await import("./OvertureMaintenance");
39+
expect(
40+
overtureProgress({
41+
status: "running",
42+
phase: "score",
43+
placeCount: 4_000_000,
44+
extractedCount: 1_400_000,
45+
processedCount: 700_000,
46+
}),
47+
).toEqual({ value: 50, label: "700,000 of 1,400,000 OSM POIs scored" });
48+
expect(
49+
overtureProgress({
50+
status: "running",
51+
phase: "assign",
52+
componentCount: 200,
53+
assignmentCursor: 50,
54+
}),
55+
).toEqual({ value: 25, label: "50 of 200 components assigned" });
56+
expect(overtureProgress({ status: "running", phase: "publish" })).toEqual({
57+
value: null,
58+
label: "Validating and publishing links",
59+
});
60+
});
61+
62+
it("allows only an expired running lease to be resumed", async () => {
63+
const { canResumeOvertureLinks } = await import("./OvertureMaintenance");
64+
expect(
65+
canResumeOvertureLinks({ status: "running", stalled: false }, "europe/germany", false),
66+
).toBe(false);
67+
expect(
68+
canResumeOvertureLinks({ status: "running", stalled: true }, "europe/germany", false),
69+
).toBe(true);
70+
});
3671
});

apps/web/src/components/admin/services/OvertureMaintenance.tsx

Lines changed: 67 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,11 @@ interface OvertureStatus {
2626
placeCount?: number;
2727
status?: string;
2828
phase?: string;
29+
emittedCount?: number | null;
30+
extractedCount?: number | null;
2931
processedCount?: number | null;
32+
componentCount?: number | null;
33+
assignmentCursor?: number | null;
3034
linkedCount?: number | null;
3135
attemptCount?: number;
3236
lastError?: string | null;
@@ -36,6 +40,54 @@ interface OvertureStatus {
3640

3741
type Operation = "overture-sync" | "overture-conflate";
3842

43+
export function overtureProgress(status: OvertureStatus | undefined): {
44+
value: number | null;
45+
label: string;
46+
} | null {
47+
if (status?.status !== "running") return null;
48+
if (
49+
status.phase === "score" &&
50+
status.extractedCount &&
51+
status.processedCount !== null &&
52+
status.processedCount !== undefined
53+
) {
54+
return {
55+
value: Math.min(100, (status.processedCount / status.extractedCount) * 100),
56+
label: `${status.processedCount.toLocaleString()} of ${status.extractedCount.toLocaleString()} OSM POIs scored`,
57+
};
58+
}
59+
if (
60+
status.phase === "assign" &&
61+
status.componentCount &&
62+
status.assignmentCursor !== null &&
63+
status.assignmentCursor !== undefined
64+
) {
65+
return {
66+
value: Math.min(100, (status.assignmentCursor / status.componentCount) * 100),
67+
label: `${status.assignmentCursor.toLocaleString()} of ${status.componentCount.toLocaleString()} components assigned`,
68+
};
69+
}
70+
const label =
71+
status.phase === "extract"
72+
? `${status.emittedCount?.toLocaleString() ?? "0"} OSM geometries streamed`
73+
: status.phase === "publish"
74+
? "Validating and publishing links"
75+
: "Preparing Overture conflation";
76+
return { value: null, label };
77+
}
78+
79+
export function canResumeOvertureLinks(
80+
status: OvertureStatus | undefined,
81+
region: string,
82+
operationPending: boolean,
83+
): boolean {
84+
return (
85+
region.trim().length > 0 &&
86+
!operationPending &&
87+
(status?.status !== "running" || status.stalled === true)
88+
);
89+
}
90+
3991
export function OvertureMaintenance({ apiUrl }: { apiUrl: string }) {
4092
const showToast = useAdminToast();
4193
const queryClient = useQueryClient();
@@ -83,10 +135,7 @@ export function OvertureMaintenance({ apiUrl }: { apiUrl: string }) {
83135
});
84136

85137
const status = statusQuery.data;
86-
const progress =
87-
status?.placeCount && status.processedCount !== null && status.processedCount !== undefined
88-
? Math.min(100, (status.processedCount / status.placeCount) * 100)
89-
: null;
138+
const progress = overtureProgress(status);
90139
const visiblePhase =
91140
status?.status === "completed" && status.phase === "complete" ? undefined : status?.phase;
92141

@@ -136,6 +185,13 @@ export function OvertureMaintenance({ apiUrl }: { apiUrl: string }) {
136185
{status.lastError}
137186
</Alert>
138187
)}
188+
{status?.stalled && (
189+
<Alert severity="warning" sx={{ mt: 1.5 }}>
190+
The worker heartbeat is stale
191+
{status.heartbeatAgeMs ? ` (${Math.floor(status.heartbeatAgeMs / 60_000)} minutes)` : ""}.
192+
The expired lease can be reclaimed safely with Resume links.
193+
</Alert>
194+
)}
139195

140196
{status?.ok && (
141197
<Stack direction="row" sx={{ gap: 3, flexWrap: "wrap", mt: 1.5 }}>
@@ -157,12 +213,14 @@ export function OvertureMaintenance({ apiUrl }: { apiUrl: string }) {
157213
))}
158214
</Stack>
159215
)}
160-
{progress !== null && status?.status === "running" && (
216+
{progress && (
161217
<Box sx={{ mt: 1.5 }}>
162-
<LinearProgress variant="determinate" value={progress} />
218+
<LinearProgress
219+
variant={progress.value === null ? "indeterminate" : "determinate"}
220+
value={progress.value ?? undefined}
221+
/>
163222
<Typography variant="caption" sx={{ color: "text.secondary" }}>
164-
{status.processedCount?.toLocaleString()} of {status.placeCount?.toLocaleString()}{" "}
165-
places
223+
{progress.label}
166224
</Typography>
167225
</Box>
168226
)}
@@ -189,7 +247,7 @@ export function OvertureMaintenance({ apiUrl }: { apiUrl: string }) {
189247
<Button
190248
variant="outlined"
191249
startIcon={<MergeIcon />}
192-
disabled={!region.trim() || operation.isPending || status?.status === "running"}
250+
disabled={!canResumeOvertureLinks(status, region, operation.isPending)}
193251
onClick={() => setConfirmOperation("overture-conflate")}
194252
>
195253
Resume links

docs/docs/features/overture-places.md

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,12 @@ than enrich OSM records: a place present only in Overture is returned as its own
1212
search result and opens as a complete place card with a stable GERS identifier.
1313

1414
The integration is optional. Without it, category search continues to use OSM
15-
through Overpass. With it enabled, OpenMapX queries both sources, matches records
16-
that describe the same real-world place, and keeps unmatched Overture records as
15+
through Overpass. Activating it has two layers: enable the data lifecycle with
16+
`OVERTURE_ENABLED=true`, then enable `poi-overture` for search and
17+
`knowledge-overture` for place-card enrichment in **Admin → Integrations**.
18+
These runtime integrations are independent, so an operator may enable either or
19+
both. With both enabled, OpenMapX queries both sources, matches records that
20+
describe the same real-world place, and keeps unmatched Overture records as
1721
coverage gap-fill.
1822

1923
## Imported fields
@@ -69,11 +73,14 @@ release starts with a durable `pending` link state. The rebuild records
6973
`extract`, `score`, `assign`, `publish`, and `complete` phases, keyset and
7074
component cursors, source fingerprint, heartbeats, attempt counts, separate
7175
emitted-geometry and unique-POI counts, and per-phase durations in PostGIS. A
72-
failed or interrupted attempt leaves the Places release active and is retried
73-
every six hours by default. A retry resumes its saved phase: completed OSM
76+
failed or interrupted attempt leaves the Places release active. Recovery is
77+
attempted once at data-manager startup and every 15 minutes thereafter. A retry resumes its saved phase: completed OSM
7478
extraction is not repeated, scoring continues after its last committed keyset
7579
cursor, and assignment continues after its last completed graph component. If
76-
the local PBF fingerprint changes, the state safely restarts from extraction.
80+
the local PBF fingerprint changes—even after a previously completed run—the
81+
state safely restarts from extraction. Across Overture releases, an unchanged
82+
OSM table and its fingerprint are moved into the new release in constant time,
83+
so only Overture-dependent scoring and assignment repeat.
7784

7885
The rebuild is designed for country-scale inputs. OSM GeoJSON sequences are
7986
parsed directly into fixed-size Postgres batches and published with an atomic
@@ -87,7 +94,11 @@ country-wide scored graph as one object. The completely assigned next-link
8794
snapshot is durable and replaces the live link table in one transaction, so a
8895
failed attempt can never expose a partial link set. A PostgreSQL advisory lock
8996
serializes schema swaps, OSM snapshot publication, and link rebuilding across
90-
data-manager processes.
97+
data-manager processes. Temporary filtered PBFs are removed after extraction.
98+
Capacity checks measure the host `/data` filesystem before a pull and the
99+
PostgreSQL container's actual data filesystem before staging or conflation.
100+
Each reserves estimated working space plus a safety margin and fails with an
101+
actionable byte estimate instead of filling either filesystem.
91102

92103
The staged Places snapshot is checked against release-pinned, human-reviewed
93104
category cases before activation. After link assignment, the final fused
@@ -98,7 +109,10 @@ including known upstream category mistakes and duplicates.
98109

99110
After a complete ingest, fused quality check, and link publication, OpenMapX
100111
retains the active local release snapshot and one predecessor by default. Older
101-
release directories are pruned; incomplete refreshes never trigger pruning.
112+
release directories are pruned whether publication completed in the original
113+
sync or an independent retry; incomplete refreshes never trigger pruning.
114+
Durable finalization markers prevent completed retries from repeatedly
115+
truncating work tables or rescanning release directories.
102116

103117
Run the complete workflow manually with:
104118

docs/docs/install/configuration.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -286,14 +286,16 @@ for what each class covers.
286286

287287
## Overture Maps POIs
288288

289-
Schedules and switches for the monthly regional Overture Maps Places refresh.
289+
Schedules and switches for the regional Overture Maps Places refresh.
290290

291291
| Variable | Description | Required / Default |
292292
| ------------------ | ------------------------------------------------------------------------------------------- | ------------------------- |
293293
| `OVERTURE_ENABLED` | Set to `true` to discover and atomically import newer Overture Places releases for `OPENMAPX_REGION`. | Optional. Default unset |
294-
| `OVERTURE_SYNC_CRON`| Cron schedule for checking and refreshing the regional snapshot. | Optional. Default `0 5 1 * *` (monthly) |
295-
| `OVERTURE_CONFLATION_RETRY_CRON` | Retry an incomplete OSM↔Overture link rebuild without downloading or importing Places again. | Optional. Default `15 */6 * * *` (every six hours) |
294+
| `OVERTURE_SYNC_CRON`| Cron schedule for checking and refreshing the regional snapshot. | Optional. Default `0 5 * * 2` (weekly, Tuesday 05:00 UTC) |
295+
| `OVERTURE_CONFLATION_RETRY_CRON` | Retry an incomplete OSM↔Overture link rebuild without downloading or importing Places again. Recovery also runs once at startup. | Optional. Default `*/15 * * * *` |
296296
| `OVERTURE_RELEASE_RETENTION` | Number of completed local release snapshots to retain, including the active release. Applied only after fused quality validation and link publication. | Optional. Default `2`; range 1–12 |
297+
| `OVERTURE_DISK_RESERVE_BYTES` | Free-space safety reserve kept beyond estimated pull/ingest working space. | Optional. Default `5368709120` (5 GiB) |
298+
| `OVERTURE_FIRST_PULL_ESTIMATE_BYTES` | Working-space estimate for the first regional pull when no earlier snapshot exists. Later pulls use prior local snapshot sizes. | Optional. Default `2147483648` (2 GiB) |
297299

298300
The job uses Overture's official STAC catalog, skips an installed release,
299301
resolves exact spatially relevant Places assets, validates a local release
@@ -303,6 +305,11 @@ retry schedule with resumable extraction, scoring, assignment, and publication
303305
phases: a missing PBF or failed rebuild does not roll back a valid Places
304306
release. See [Overture Places](../features/overture-places.md).
305307

308+
After the first successful ingest, enable `poi-overture` in **Admin →
309+
Integrations** to return Overture search results and `knowledge-overture` to
310+
enrich matched OSM place cards. `OVERTURE_ENABLED` maintains the data but does
311+
not silently enable either runtime integration.
312+
306313
## Natural-language search
307314

308315
[Natural-language search](../features/natural-language-search.md) (the

infra/docker/.env.example

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -395,11 +395,13 @@ THUNDERFOREST_API_KEY=
395395
# default: https://raw.githubusercontent.com/openmapx/community-extensions/main/catalog.json
396396
# EXTENSION_CATALOG_URL=
397397

398-
# Overture Maps POI integration (monthly Places pull + independent link retries)
398+
# Overture Maps POI integration (weekly release check + independent link retries)
399399
# OVERTURE_ENABLED=false
400-
# OVERTURE_SYNC_CRON=0 5 1 * *
401-
# OVERTURE_CONFLATION_RETRY_CRON=15 */6 * * *
400+
# OVERTURE_SYNC_CRON=0 5 * * 2
401+
# OVERTURE_CONFLATION_RETRY_CRON=*/15 * * * *
402402
# OVERTURE_RELEASE_RETENTION=2
403+
# OVERTURE_DISK_RESERVE_BYTES=5368709120
404+
# OVERTURE_FIRST_PULL_ESTIMATE_BYTES=2147483648
403405

404406
# Data-use policy. By default every data source is served (the reference
405407
# deployment is non-commercial). A commercial operator can exclude

integrations/knowledge-overture/provider.ts

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,7 @@ function parseOsmRef(ref: string): { type: string; id: bigint } | null {
7474
* Resolve a GERS id for the given place.
7575
*
7676
* Phase 1 — link-first: query poi_conflation_link using the OSM ref from
77-
* context.ids.osm. The table is empty until plan 03, so this always falls
78-
* through for now (expected).
77+
* context.ids.osm.
7978
*
8079
* Phase 2 — spatial+name: find candidates within 150 m of the place
8180
* coordinates and filter with Overture's own taxonomy hierarchy; pick the one whose name
@@ -84,7 +83,7 @@ function parseOsmRef(ref: string): { type: string; id: bigint } | null {
8483
* context.ids may be undefined (the neighborhoods call site passes a partial
8584
* Place with no ids) — falls through to the spatial path without throwing.
8685
*/
87-
async function resolveGers(
86+
export async function resolveGers(
8887
database: DatabaseClient,
8988
osmTags: Record<string, string>,
9089
context: KnowledgeContext | undefined,
@@ -131,25 +130,45 @@ async function resolveGers(
131130
sql = `
132131
SELECT gers_id, name
133132
FROM overture_places.places
134-
WHERE ST_DWithin(geom::geography, ST_MakePoint($1, $2)::geography, 150)
133+
WHERE geom && ST_Envelope(
134+
ST_Buffer(
135+
ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography,
136+
150
137+
)::geometry
138+
)
139+
AND ST_DWithin(
140+
geom::geography,
141+
ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography,
142+
150
143+
)
135144
AND (
136145
basic_category = ANY($3::TEXT[])
137146
OR taxonomy_primary = ANY($3::TEXT[])
138147
OR taxonomy_hierarchy && $3::TEXT[]
139148
OR taxonomy_alternates && $3::TEXT[]
140149
)
141150
AND (operating_status IS NULL OR operating_status <> 'permanently_closed')
142-
ORDER BY geom <-> ST_MakePoint($1, $2)::geometry
151+
ORDER BY geom <-> ST_SetSRID(ST_MakePoint($1, $2), 4326)
143152
LIMIT 5
144153
`;
145154
params = [lng, lat, concepts];
146155
} else {
147156
sql = `
148157
SELECT gers_id, name
149158
FROM overture_places.places
150-
WHERE ST_DWithin(geom::geography, ST_MakePoint($1, $2)::geography, 150)
159+
WHERE geom && ST_Envelope(
160+
ST_Buffer(
161+
ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography,
162+
150
163+
)::geometry
164+
)
165+
AND ST_DWithin(
166+
geom::geography,
167+
ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography,
168+
150
169+
)
151170
AND (operating_status IS NULL OR operating_status <> 'permanently_closed')
152-
ORDER BY geom <-> ST_MakePoint($1, $2)::geometry
171+
ORDER BY geom <-> ST_SetSRID(ST_MakePoint($1, $2), 4326)
153172
LIMIT 5
154173
`;
155174
params = [lng, lat];

packages/cli/src/commands/data.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -679,13 +679,26 @@ export function registerDataCommands(program: Command): void {
679679
);
680680
process.exit(1);
681681
}
682+
if (result.status === "already_running") {
683+
log.warn(
684+
result.message ??
685+
"Overture conflation is still running under another worker; no work was claimed",
686+
);
687+
return;
688+
}
682689
const linked = result.linked ?? 0;
683-
log.ok(
684-
`Overture conflation complete: ${linked} link${linked === 1 ? "" : "s"} written` +
685-
(result.extracted !== undefined && result.candidates !== undefined
686-
? ` from ${result.extracted} OSM POIs and ${result.candidates} accepted edges`
687-
: ""),
688-
);
690+
if (result.status === "already_completed") {
691+
log.ok(
692+
`Overture conflation was already complete: ${linked} link${linked === 1 ? "" : "s"} active`,
693+
);
694+
} else {
695+
log.ok(
696+
`Overture conflation complete: ${linked} link${linked === 1 ? "" : "s"} written` +
697+
(result.extracted !== undefined && result.candidates !== undefined
698+
? ` from ${result.extracted} OSM POIs and ${result.candidates} accepted edges`
699+
: ""),
700+
);
701+
}
689702
} catch (err) {
690703
log.err(`overture-conflate failed: ${(err as Error).message}`);
691704
dataManagerHint();

0 commit comments

Comments
 (0)