Skip to content

Commit bc65a8a

Browse files
lhoupertclaude
andcommitted
feat(storage-tier): window on registration date via --date-field
The batch storage-tier submitter only windowed on sensing `datetime`, so it could not target the S2 backlog by *registration* time (`properties.created`). Bulk-converted items were registered long after they were sensed, so a sensing-date window misses them. Add `--date-field {datetime,created,updated}` (default `datetime`, backward compatible). Non-default fields swap the `datetime=` range for a CQL2 `between` filter on the chosen property — the same pattern `scripts/query_stac.py` uses to harvest by `updated`. Also: - flip `--storage-class` default `STANDARD_IA` -> `STANDARD` (STANDARD_IA is unused in the deployment; documented commands stay explicit regardless); - add `timeout=30` to the webhook POST so a hung endpoint can't stall the per-window submission loop (ruff S113, on this script's own code); - document the registered-window backlog example in operator-tools/README.md. Plan: claude-docs/plans/s2_tier_standard_backlog.md (Task 2) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 295a5c4 commit bc65a8a

3 files changed

Lines changed: 189 additions & 10 deletions

File tree

operator-tools/README.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,18 @@ python operator-tools/submit_storage_tier_workflows.py \
299299
--collection sentinel-2-l2a-staging \
300300
--storage-class STANDARD_IA \
301301
--webhook-url http://localhost:12000/samples
302+
303+
# Registered-window backlog — window on when items were *registered*
304+
# (properties.created) rather than sensed, to drain a bulk-conversion backlog
305+
# to STANDARD. Uses a CQL2 filter instead of the datetime= range.
306+
python operator-tools/submit_storage_tier_workflows.py \
307+
--date-field created \
308+
--start-date 2025-11-01 \
309+
--end-date 2026-04-08 \
310+
--collection sentinel-2-l2a \
311+
--storage-class STANDARD \
312+
--process-all-assets \
313+
--dry-run
302314
```
303315

304316
**All options:**
@@ -308,7 +320,8 @@ python operator-tools/submit_storage_tier_workflows.py \
308320
| `--start-date` | required | Start of date range (`YYYY-MM-DD`) |
309321
| `--end-date` | required | End of date range (`YYYY-MM-DD`) |
310322
| `--collection` | required | STAC collection ID |
311-
| `--storage-class` | `STANDARD_IA` | Target S3 storage class |
323+
| `--date-field` | `datetime` | Item date to window on: sensing `datetime`, registration `created`, or last-modified `updated`. Non-default fields use a CQL2 `between` filter |
324+
| `--storage-class` | `STANDARD` | Target S3 storage class |
312325
| `--stac-api-url` | prod API URL | STAC API endpoint to query |
313326
| `--s3-endpoint` | OVH Cloud | S3 endpoint passed to Argo workflows |
314327
| `--pipeline-image-version` | `v1.6.1` | Docker image tag for Argo jobs |

operator-tools/submit_storage_tier_workflows.py

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -46,15 +46,36 @@ def generate_time_windows(
4646

4747

4848
def query_stac_items(
49-
stac_api_url: str, collection: str, window_start: str, window_end: str
49+
stac_api_url: str,
50+
collection: str,
51+
window_start: str,
52+
window_end: str,
53+
date_field: str = "datetime",
5054
) -> list[str]:
51-
"""Query STAC for items with datetime in the given window. Returns list of item IDs."""
55+
"""Query STAC for items whose ``date_field`` falls in the window. Returns item IDs.
56+
57+
``date_field="datetime"`` uses pystac-client's native ``datetime=`` range
58+
(the item's sensing time). Other fields (``created``, ``updated``) filter on
59+
the registration/update timestamp via a CQL2 ``between`` filter, since those
60+
properties are not reachable through the ``datetime=`` kwarg.
61+
"""
5262
catalog = Client.open(stac_api_url)
53-
search = catalog.search(
54-
collections=[collection],
55-
datetime=f"{window_start}/{window_end}",
56-
limit=100,
57-
)
63+
if date_field == "datetime":
64+
search = catalog.search(
65+
collections=[collection],
66+
datetime=f"{window_start}/{window_end}",
67+
limit=100,
68+
)
69+
else:
70+
search = catalog.search(
71+
collections=[collection],
72+
filter={
73+
"op": "between",
74+
"args": [{"property": date_field}, window_start, window_end],
75+
},
76+
filter_lang="cql2-json",
77+
limit=100,
78+
)
5879
return [item.id for item in search.items()]
5980

6081

@@ -68,6 +89,7 @@ def submit_batch(webhook_url: str, payload: dict[str, object], dry_run: bool) ->
6889
webhook_url,
6990
json=payload,
7091
headers={"Content-Type": "application/json"},
92+
timeout=30,
7193
)
7294
if response.status_code != 200:
7395
logger.warning(
@@ -87,7 +109,14 @@ def main() -> None:
87109
parser.add_argument("--start-date", required=True, help="Start date (YYYY-MM-DD)")
88110
parser.add_argument("--end-date", required=True, help="End date (YYYY-MM-DD)")
89111
parser.add_argument("--collection", required=True, help="STAC collection ID")
90-
parser.add_argument("--storage-class", default="STANDARD_IA", help="S3 storage class")
112+
parser.add_argument(
113+
"--date-field",
114+
choices=["datetime", "created", "updated"],
115+
default="datetime",
116+
help="Item date to window on: sensing 'datetime' (default), registration "
117+
"'created', or last-modified 'updated'. Non-default fields use a CQL2 filter.",
118+
)
119+
parser.add_argument("--storage-class", default="STANDARD", help="S3 storage class")
91120
parser.add_argument("--stac-api-url", default="https://api.explorer.eopf.copernicus.eu/stac")
92121
parser.add_argument("--s3-endpoint", default="https://s3.de.io.cloud.ovh.net")
93122
parser.add_argument("--pipeline-image-version", default="v1.6.1")
@@ -118,7 +147,9 @@ def main() -> None:
118147

119148
for i, (window_start, window_end) in enumerate(windows, 1):
120149
logger.info(f"[{i}/{len(windows)}] Querying window {window_start} to {window_end}")
121-
item_ids = query_stac_items(args.stac_api_url, args.collection, window_start, window_end)
150+
item_ids = query_stac_items(
151+
args.stac_api_url, args.collection, window_start, window_end, args.date_field
152+
)
122153
logger.info(f" Found {len(item_ids)} items")
123154

124155
if not item_ids:

tests/unit/test_submit_storage_tier_workflows.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,64 @@ def test_returns_empty_list_when_no_items(self) -> None:
100100

101101
assert result == []
102102

103+
def test_date_field_created_uses_cql2_between(self) -> None:
104+
"""A non-default date_field switches to a CQL2 `between` filter on that property."""
105+
mock_item = MagicMock()
106+
mock_item.id = "item-a"
107+
mock_search = MagicMock()
108+
mock_search.items.return_value = [mock_item]
109+
110+
mock_catalog = MagicMock()
111+
mock_catalog.search.return_value = mock_search
112+
113+
with patch("submit_storage_tier_workflows.Client") as mock_client:
114+
mock_client.open.return_value = mock_catalog
115+
result = query_stac_items(
116+
"https://stac.example.com",
117+
"sentinel-2",
118+
"2024-01-01T00:00:00Z",
119+
"2024-01-02T00:00:00Z",
120+
date_field="created",
121+
)
122+
123+
assert result == ["item-a"]
124+
mock_catalog.search.assert_called_once_with(
125+
collections=["sentinel-2"],
126+
filter={
127+
"op": "between",
128+
"args": [
129+
{"property": "created"},
130+
"2024-01-01T00:00:00Z",
131+
"2024-01-02T00:00:00Z",
132+
],
133+
},
134+
filter_lang="cql2-json",
135+
limit=100,
136+
)
137+
138+
def test_default_date_field_uses_datetime_range(self) -> None:
139+
"""The default date_field keeps the native pystac-client datetime= range query."""
140+
mock_search = MagicMock()
141+
mock_search.items.return_value = []
142+
mock_catalog = MagicMock()
143+
mock_catalog.search.return_value = mock_search
144+
145+
with patch("submit_storage_tier_workflows.Client") as mock_client:
146+
mock_client.open.return_value = mock_catalog
147+
query_stac_items(
148+
"https://stac.example.com",
149+
"sentinel-2",
150+
"2024-01-01T00:00:00Z",
151+
"2024-01-02T00:00:00Z",
152+
date_field="datetime",
153+
)
154+
155+
mock_catalog.search.assert_called_once_with(
156+
collections=["sentinel-2"],
157+
datetime="2024-01-01T00:00:00Z/2024-01-02T00:00:00Z",
158+
limit=100,
159+
)
160+
103161

104162
class TestSubmitBatch:
105163
def test_dry_run_does_not_send_request(self) -> None:
@@ -236,6 +294,83 @@ def capture(url: str, payload: dict[str, object], dry_run: bool) -> bool:
236294
assert "parallelism" not in submitted_payloads[0]
237295

238296

297+
class TestMainDateFieldForwarding:
298+
def test_date_field_forwarded_to_query(self) -> None:
299+
"""`--date-field created` reaches query_stac_items."""
300+
captured: list[str] = []
301+
302+
def capture_query(
303+
stac_api_url: str,
304+
collection: str,
305+
window_start: str,
306+
window_end: str,
307+
date_field: str,
308+
) -> list[str]:
309+
captured.append(date_field)
310+
return []
311+
312+
with (
313+
patch(
314+
"sys.argv",
315+
[
316+
"submit_storage_tier_workflows.py",
317+
"--start-date",
318+
"2024-01-01",
319+
"--end-date",
320+
"2024-01-02",
321+
"--collection",
322+
"sentinel-2-l2a",
323+
"--date-field",
324+
"created",
325+
"--dry-run",
326+
],
327+
),
328+
patch(
329+
"submit_storage_tier_workflows.query_stac_items",
330+
side_effect=capture_query,
331+
),
332+
):
333+
from submit_storage_tier_workflows import main
334+
335+
main()
336+
337+
assert captured == ["created"]
338+
339+
def test_default_storage_class_is_standard(self) -> None:
340+
"""The submitted payload defaults storage_class to STANDARD."""
341+
submitted_payloads: list[dict[str, object]] = []
342+
343+
def capture(url: str, payload: dict[str, object], dry_run: bool) -> bool:
344+
submitted_payloads.append(payload)
345+
return True
346+
347+
with (
348+
patch(
349+
"sys.argv",
350+
[
351+
"submit_storage_tier_workflows.py",
352+
"--start-date",
353+
"2024-01-01",
354+
"--end-date",
355+
"2024-01-02",
356+
"--collection",
357+
"sentinel-2-l2a",
358+
"--dry-run",
359+
],
360+
),
361+
patch(
362+
"submit_storage_tier_workflows.query_stac_items",
363+
return_value=["item-1"],
364+
),
365+
patch("submit_storage_tier_workflows.submit_batch", side_effect=capture),
366+
):
367+
from submit_storage_tier_workflows import main
368+
369+
main()
370+
371+
assert submitted_payloads[0]["storage_class"] == "STANDARD"
372+
373+
239374
class TestMainDateValidation:
240375
def test_end_before_start_exits(self) -> None:
241376
with patch(

0 commit comments

Comments
 (0)