Skip to content

REST converters: page by id windows - #232

Open
ivorbosloper wants to merge 8 commits into
mainfrom
split/rest-mixin
Open

ivorbosloper wants to merge 8 commits into
mainfrom
split/rest-mixin

Conversation

@ivorbosloper

@ivorbosloper ivorbosloper commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Paging by sortBy + attribute > last_id sorts the whole layer per page: ~100 s per request on Cantabria's joined layers, and Galicia is 15.8M recintos per edition. Half-open id windows remove the sort, and pages cached by the old scheme are reused where their ids prove they cover a window.

Three bugs found on the way, none visible in the output:

layer ids repeat across services every SIXPAC_<year> has Recintos at id 2, so one year's cache served another
an Esri error body is HTTP 200 it was written to the cache and read back forever
joined layers qualify field names RECINTOS.OBJECTID, so the paging filter matched nothing and returned everything

A second commit adds three things the Balearic services need, each of which a converter had a copy of: a joined layer's table prefixes are stripped for every converter (so es_ib and es_cb lose theirs), a variant whose value is a URL names the service that edition lives in, and the id-bound query — the one sorted query left — is retried, because that proxy answers two in three with a 502.

Not a copy: main's rest_format and where-clause work is kept. tests/test_converter_rest.py is new; mixin coverage 19% → 94%.

Comment thread fiboa_cli/conversion/converter_rest.py Outdated
Paging by `sortBy` + `attribute > last_id` asks the server to sort the whole
layer for every page. On Cantabria's joined SIGPAC layers that costs about 100
seconds per request — days for one edition — where a range filter on the same
indexed key answers in one or two. Galicia is 15.8 million recintos per
edition, in pages of 1000.

Paging by half-open id windows removes the sort: the key is unique, so a window
of `page_size` ids cannot overflow a page, and gaps only produce empty windows,
which are skipped. Pages cached by the old scheme are keyed by the previous
page's last id; on a dense layer those coincide exactly with a window, so one
is reused when its own ids prove it covers the window completely.

Three more things this turned up, each invisible in the output:

- **Layer ids repeat across services.** Every SIXPAC_<year> MapServer has its
  Recintos layer at id 2, so a cache keyed on the layer alone served one year's
  pages for another. The service is part of the key now.
- **An error response was cached as if it were data.** Esri answers a failed
  query with 200 and a JSON error body; that body was written to the cache file
  and every later run read it back. So was a download that broke off halfway.
  Neither survives now.
- **Joined layers qualify every field with its table name**, so "OBJECTID"
  matches nothing and the paging filter silently returns everything. The real
  key field is discovered from a one-row probe before paging starts.

`rest_format`, the fixture-reading branch and the way `rest_params["where"]` is
combined with the paging filter are unchanged; the test that pins them is
updated for the new clause, which now reads
`OBJECTID>0 AND OBJECTID<=1000 AND (<the converter's own filter>)`.

tests/test_converter_rest.py serves a small fake service of five features in
pages of two, which is enough to hold every one of these: the window
arithmetic, the cache naming, the two guards, the qualified key, and the reuse
of a legacy page only when its ids cover the window.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DVx9uQV2QPM8ecPAY3ZjXG
@ivorbosloper ivorbosloper changed the title REST converters: page by id windows, and never cache what is not a page REST converters: page by id windows Sep 12, 2026
@ivorbosloper
ivorbosloper force-pushed the split/rest-mixin branch 4 times, most recently from 9271232 to 8170edb Compare September 12, 2026 20:11
ivorbosloper and others added 2 commits September 12, 2026 22:15
Co-authored-by: Matthias Mohr <m.mohr@moregeo.it>
(cherry picked from commit 090f4d7)
…ervice

Three things every REST converter needs, each of which es_ib or es_cb had a
copy of:

- a joined layer qualifies every field (SIGPAC_FOGAIBA.DN_OID), so a
  converter's `columns` match nothing; the prefixes are stripped on the way
  out of get_data, first table winning, which is the one with the geometry
- an edition may live in a service of its own, so a variant whose value is a
  URL names the service to read it from
- the id-bound query is the one sorted query left, and the Balearic proxy
  answers two in three with a 502; it is retried five times

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DVx9uQV2QPM8ecPAY3ZjXG
ivorbosloper and others added 5 commits September 12, 2026 22:16
The yearly layers moved out of GOIB_SIGPAC_IB into GOIB_SIGPAC_HISTORIC_IB,
which keeps 2022-2025; what is left in the first service is one current-state
layer, ahead of the historic newest (maig against gener 2026). Each variant
names the service it comes from. The years before 2022 are withdrawn.

Both services join their parcels to the municipality and land-use tables, so
their fields arrive table-qualified; the mixin strips that. The snapshot month
they carry becomes determination:datetime through column_migrations — the
current service writes "maig 2026", the historic ones "Febrer2024.0".

`provider` also pointed at Navarra, a copy-paste between the Spanish
converters.

The converter had no test. It has one page of each service now, 100 features
as the service answers them, so the qualified names and both spellings of the
snapshot month are covered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DVx9uQV2QPM8ecPAY3ZjXG
The converter published one edition under a licence Cantabria does not use.
It claimed CC-BY-NC-4.0, justified by comments pointing at caib.es — the
Balearic Islands, a copy-paste from the es_ib converter. Cantabria licenses its
cartography under Decreto 87/2013 (modified by Decreto 102/2018), which defines
two licences of its own, both free of charge: a non-commercial one and a
commercial one, the latter needed only for reselling the data. So both the
Creative Commons label and the NC restriction were wrong. The attribution is
now the wording the licence prescribes, verbatim in Spanish, and the province
is named as the provider — the field was empty, so the collection had no
producer at all.

On the data: the service serves SIGPAC layers from 2010, and the 2025 layers
appeared while this was being worked on. Editions 2010-2014 are joined layers
whose fields arrive table-qualified (SIGPAC_2014_RECFE_ETRS89.PROVINCIA), so
the prefixes are stripped, first occurrence winning because the geometry table
leads. Each edition's determination date comes from its variant year, where it
used to be an empty string that broke the STAC temporal extent.

Sixteen editions are built and published, ~600k filtered fields each.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DVx9uQV2QPM8ecPAY3ZjXG
ES-IB: editions 2022-2026, and the right provider
ES-CB: editions 2010-2025, and the right licence

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Cache provenance remains unsafe, and the Balearic server-side filter still uses an unqualified joined-layer field.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Reworks Esri REST paging for faster, safer conversion and expands Balearic/Cantabrian edition support.

Changes:

  • Pages REST layers using ID windows with service-aware caching and error cleanup.
  • Handles qualified joined-layer fields and URL-backed variants.
  • Adds extensive REST and ES-IB conversion coverage.
File summaries
File Description
fiboa_cli/conversion/converter_rest.py Implements window paging, cache handling, retries, and field normalization.
fiboa_cli/datasets/es_ib.py Adds current/historic services and snapshot-date handling.
fiboa_cli/datasets/es_cb.py Updates editions, licensing, and determination dates.
tests/test_converter_rest.py Covers REST paging and caching behavior.
tests/test_converters.py Updates REST parameter assertions.
tests/test_convert.py Tests current and historic ES-IB variants.
tests/data-files/convert/es_ib/es_ib_2024.geojson Provides historic joined-layer test data.
CHANGELOG.md Documents converter changes.
Review details

Suppressed comments (1)

fiboa_cli/conversion/converter_rest.py:135

  • The new cache key still omits the effective filter/variant. For example, DESTConverter changes rest_params['where'] per edition while using the same service and layer (fiboa_cli/datasets/de_st.py:54-61), so a matching ID window cached for one edition can be returned for another. Include a stable fingerprint of the effective query (at least the variant and rest_params) in the cache key.
                    cache_file = os.path.join(
                        cache_folder,
                        f"{self.id}_{service}_{layer['id']}_r{lo}.{self.rest_format}",
                    )
  • Files reviewed: 7/9 changed files
  • Comments generated: 3
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +124 to 126
data = self._window_from_legacy_cache(
cache_fs, cache_folder, layer["id"], lo, hi, page_size
)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mechanism is right: the legacy name is {id}_{layer}_{key}, so it carries the dataset and the layer but not the service or the variant, and the check only proves the id range. Two services that number their layers the same way would be indistinguishable — which is exactly the case this PR's own comment cites, every SIXPAC_<year> MapServer serving Recintos at layer 2.

What saves it today is an accident of the one dataset that has such pages. On the machine that builds the catalog there are 9,306 legacy pages, all es_cb, and Cantabria gives every edition its own layer id — 3, 7, 11, 15, 19 … 71 — so a page belongs to exactly one edition. es_ib, es_ga and es_cm have none at all.

An accident is not a guarantee, and the saving is bounded: those 9,306 pages are one dataset's, against 210,053 already cached under the new key. I would rather drop the reuse than keep a path that cannot prove where its data came from. Alternatively it could be restricted to converters without variants, where the ambiguity cannot arise — say which you prefer and I will make the change.

Comment on lines 70 to 72
rest_params = {
"where": "USO_SIGPAC NOT IN ('AG','CA','ED','FO','IM','IS','IV','TH','ZC','ZU','ZV','MT')"
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The server resolves the unqualified name. Checked against the published editions, counting rows carrying any of the twelve excluded codes:

2022: 478,041 rows, 0 with an excluded use code
2026: 349,087 rows, 0 with an excluded use code

If the predicate were rejected the request would fail, and if it were ignored those codes would be there — AG, ED and ZU are common SIGPAC uses, so zero out of 349,087 is not chance. ArcGIS accepts the bare field name in where on these joined layers even though it answers with the qualified one.

Comment on lines +50 to +58
if not any("." in c for c in gdf.columns):
return gdf
renames = {}
for column in gdf.columns:
name = column.rsplit(".", 1)[-1]
if name not in gdf.columns and name not in renames.values():
renames[column] = name
gdf = gdf.rename(columns=renames)
return gdf.loc[:, ~gdf.columns.duplicated()]

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right about the mechanism, wrong about the consequence. With A.USO and B.USO and no bare USO, the first is renamed and the second is skipped because the suffix is already a value in renames, so columns.duplicated() sees two different names and keeps both — B.USO does stay qualified.

It does not reach the output, because a column no columns entry maps is dropped before the file is written. So the documented behaviour holds — the first table wins — and the leftover is discarded rather than published with its prefix.

Happy to make it explicit by dropping the remaining qualified columns instead of leaving them to be swept up later, if you would rather the function finish its own job.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants