Skip to content

Commit 764d900

Browse files
committed
ENH: Add best-effore migration backups
1 parent 397d880 commit 764d900

4 files changed

Lines changed: 105 additions & 18 deletions

File tree

ARCHITECTURE.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -176,16 +176,20 @@ The migration boundary depends on the resource operation:
176176
Loading does not write the resource, create a cache revision, or add a changelog
177177
entry.
178178
- **Save:** The resource manager checks the write lock first. If the stored data is
179-
older, it stores the original JSON as a cache revision
180-
before writing the current model. Existing cache retention applies.
179+
older, it attempts to store the exact original JSON under
180+
`.fmu/migration-backups/` before writing the current model. This backup is
181+
best-effort: a file-system failure is logged and does not stop the save. The newly
182+
written current data is still added to the cache.
181183
- **Cache read:** `CacheManager` uses the migration manager to return old revisions
182184
as current validated models.
183185
- **Restore:** `CacheManager` migrates the selected revision before writing it, so
184186
the restored resource uses the current schema. Project restore operations use the
185187
existing restore changelog entry.
186188

187189
There is no backward migration. After a current-schema resource is saved, an older
188-
`fmu-settings` release can reject it as newer than supported.
190+
`fmu-settings` release can reject it as newer than supported. Migration backups are
191+
not loaded or restored by the library. Users must copy one back manually when they
192+
need to use the data with an older release.
189193

190194
See the
191195
[schema migration guide](src/fmu/settings/_migrations/README.md)

src/fmu/settings/_migrations/README.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,10 @@ Migration is automatic during normal use:
215215
On the first save:
216216

217217
1. The write lock is checked.
218-
2. The current stored data is added to a cache revision when it is older.
218+
2. If the stored data needs migration, the resource manager tries to save a copy of
219+
the original JSON under `.fmu/migration-backups/`. This backup is separate from
220+
the normal cache, is not removed automatically, and a failure to write it does not
221+
stop the save.
219222
3. The current model is written with the new schema version.
220223
4. When automatic caching is enabled, the newly written data is added to a cache
221224
revision.
@@ -224,7 +227,10 @@ Loading a resource does not create a changelog entry. A later user update or
224227
restore uses the existing changelog behavior.
225228

226229
When an old cache revision is restored, it is migrated before it is written. The
227-
resource file therefore uses the current schema after the restore.
230+
resource file therefore uses the current schema after the restore. Migration backups
231+
are only saved for manual recovery, and the library does not read or restore them. To
232+
roll back, first replace the current resource file with the appropriate backup, then
233+
run the older release.
228234

229235
Migrations are forward-only. After current-schema data is saved, an older
230236
`fmu-settings` release can reject it as newer than its supported schema.

src/fmu/settings/_resources/pydantic_resource_manager.py

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,14 @@
33
from __future__ import annotations
44

55
import json
6-
from typing import TYPE_CHECKING, Any, Generic, Self, TypeVar
6+
from datetime import UTC, datetime
7+
from pathlib import Path
8+
from typing import TYPE_CHECKING, Any, Final, Generic, Self, TypeVar
9+
from uuid import uuid4
710

811
from pydantic import BaseModel, ValidationError
912

13+
from fmu.settings._logging import null_logger
1014
from fmu.settings._migrations import (
1115
MigrationError,
1216
MigrationManager,
@@ -24,13 +28,14 @@
2428
from collections.abc import Mapping
2529

2630
# Avoid circular dependency for type hint in __init__ only
27-
from pathlib import Path
28-
2931
from fmu.settings._fmu_dir import FMUDirectoryBase
3032

3133
PydanticResource = TypeVar("PydanticResource", bound=BaseModel)
3234
MutablePydanticResource = TypeVar("MutablePydanticResource", bound=ResettableBaseModel)
3335

36+
MIGRATION_BACKUP_DIRECTORY: Final = Path("migration-backups")
37+
logger: Final = null_logger(__name__)
38+
3439

3540
class PydanticResourceManager(Generic[PydanticResource]):
3641
"""Base class for managing resources represented by Pydantic models."""
@@ -168,7 +173,7 @@ def save(
168173
model: Validated Pydantic model instance.
169174
"""
170175
self.fmu_dir._lock.ensure_can_write()
171-
self._store_pre_migration_revision()
176+
self._store_migration_backup()
172177

173178
json_data = model.model_dump_json(by_alias=True, indent=2)
174179
self.fmu_dir.write_text_file(self.relative_path, json_data)
@@ -178,12 +183,15 @@ def save(
178183

179184
self._cache = model
180185

181-
def _store_pre_migration_revision(self: Self) -> None:
182-
"""Preserve current disk content before replacing it with migrated data.
186+
def _store_migration_backup(self: Self) -> None:
187+
"""Try to back up older data before saving the migrated version.
183188
184-
Invalid JSON and non-object content are not stored as pre-migration revisions.
189+
The backup is best effort. Invalid JSON and non-object content are skipped.
190+
File-system errors while writing the backup are logged, but do not stop the
191+
save.
185192
"""
186-
if self.migration_manager is None:
193+
migration_manager = self.migration_manager
194+
if migration_manager is None:
187195
return
188196

189197
try:
@@ -196,15 +204,47 @@ def _store_pre_migration_revision(self: Self) -> None:
196204
return
197205

198206
try:
199-
requires_migration = self.migration_manager.requires_migration(data)
207+
requires_migration = migration_manager.requires_migration(data)
200208
except MigrationError as e:
201209
raise MigrationError(
202210
f"Failed to check migration requirements for resource file "
203211
f"'{self.__class__.__name__}' at '{self.path}': {e}"
204212
) from e
205213

206-
if requires_migration:
207-
self.fmu_dir.cache.store_revision(self.relative_path, content)
214+
if not requires_migration:
215+
return
216+
217+
source_version = data.get("schema_version", 1)
218+
self._write_migration_backup(content, source_version)
219+
220+
def _write_migration_backup(self: Self, content: str, source_version: int) -> None:
221+
"""Write a best-effort migration backup of the original content.
222+
223+
For example, a ``config.json`` backup can be stored under
224+
``.fmu/migration-backups/config/`` as
225+
``<timestamp>-<token>-ProjectConfig-v1.json``.
226+
"""
227+
timestamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S.%fZ")
228+
token = uuid4().hex[:8]
229+
backup_directory = (
230+
MIGRATION_BACKUP_DIRECTORY
231+
/ self.relative_path.parent
232+
/ self.relative_path.stem
233+
)
234+
backup_filename = (
235+
f"{timestamp}-{token}-{self.model_class.__name__}-v{source_version}"
236+
f"{self.relative_path.suffix}"
237+
)
238+
backup_path = backup_directory / backup_filename
239+
240+
try:
241+
self.fmu_dir.write_text_file(backup_path, content)
242+
except OSError as e:
243+
logger.warning(
244+
f"Failed to save migration backup for '{self.path}' at "
245+
f"'{self.fmu_dir.get_file_path(backup_path)}'. "
246+
f"Continuing without it: {e}"
247+
)
208248

209249
def _migrate_and_validate_data(self: Self, data: Any) -> PydanticResource:
210250
"""Migrate decoded data with registered functions and validate the result."""

tests/test_migrations/test_resource_migration.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ def test_load_migrates_in_memory_without_changing_disk(
8181
def test_save_after_migration_backs_up_old_content(
8282
fmu_dir: ProjectFMUDirectory,
8383
) -> None:
84-
"""The first write backs up old data and writes the current schema."""
84+
"""The first write backs up old data outside the normal cache."""
8585
manager = MigratableResourceManager(fmu_dir)
8686
old_content = json.dumps({"schema_version": 1, "value": "old"}, indent=2)
8787
fmu_dir.write_text_file(manager.relative_path, old_content)
@@ -92,11 +92,17 @@ def test_save_after_migration_backs_up_old_content(
9292
disk_data = json.loads(fmu_dir.read_text_file(manager.relative_path))
9393
assert disk_data == {"schema_version": 2, "value": "updated"}
9494

95+
backup_directory = fmu_dir.get_file_path("migration-backups/migratable")
96+
backups = [path for path in backup_directory.iterdir() if path.is_file()]
97+
assert len(backups) == 1
98+
assert backups[0].name.endswith("-VersionTwoResource-v1.json")
99+
assert backups[0].read_text(encoding="utf-8") == old_content
100+
95101
cached_data = [
96102
json.loads(path.read_text(encoding="utf-8"))
97103
for path in fmu_dir.cache.list_revisions(manager.relative_path)
98104
]
99-
assert {"schema_version": 1, "value": "old"} in cached_data
105+
assert {"schema_version": 1, "value": "old"} not in cached_data
100106
assert {"schema_version": 2, "value": "updated"} in cached_data
101107

102108

@@ -107,6 +113,8 @@ def test_cached_old_schema_is_readable_and_restorable(
107113
manager = MigratableResourceManager(fmu_dir)
108114
old_content = json.dumps({"schema_version": 1, "value": "old"}, indent=2)
109115
fmu_dir.write_text_file(manager.relative_path, old_content)
116+
old_revision = fmu_dir.cache.store_revision(manager.relative_path, old_content)
117+
assert old_revision is not None
110118
manager.save(VersionTwoResource(value="updated"))
111119

112120
old_revision = next(
@@ -212,6 +220,34 @@ def test_migration_save_respects_lock_before_backup(
212220
assert fmu_dir.cache.list_revisions(manager.relative_path) == revisions_before
213221

214222

223+
def test_save_continues_when_migration_backup_fails(
224+
fmu_dir: ProjectFMUDirectory,
225+
) -> None:
226+
"""A failed migration backup does not block saving current data."""
227+
manager = MigratableResourceManager(fmu_dir)
228+
old_content = json.dumps({"schema_version": 1, "value": "old"}, indent=2)
229+
fmu_dir.write_text_file(manager.relative_path, old_content)
230+
original_write_text_file = fmu_dir.write_text_file
231+
232+
def write_text_file(
233+
relative_path: Path | str,
234+
content: str,
235+
encoding: str = "utf-8",
236+
) -> None:
237+
if Path(relative_path).parts[:1] == ("migration-backups",):
238+
raise OSError("backup unavailable")
239+
original_write_text_file(relative_path, content, encoding=encoding)
240+
241+
with patch.object(fmu_dir, "write_text_file", side_effect=write_text_file):
242+
manager.save(VersionTwoResource(value="updated"))
243+
244+
assert json.loads(fmu_dir.read_text_file(manager.relative_path)) == {
245+
"schema_version": 2,
246+
"value": "updated",
247+
}
248+
assert not fmu_dir.get_file_path("migration-backups").exists()
249+
250+
215251
def test_current_schema_save_does_not_add_migration_backup(
216252
fmu_dir: ProjectFMUDirectory,
217253
) -> None:
@@ -231,6 +267,7 @@ def test_current_schema_save_does_not_add_migration_backup(
231267
"schema_version": 2,
232268
"value": "updated",
233269
}
270+
assert not fmu_dir.get_file_path("migration-backups").exists()
234271

235272

236273
def test_save_replaces_non_object_json_without_migration_backup(

0 commit comments

Comments
 (0)