Skip to content

Commit 8a5cb5b

Browse files
authored
ENH: Add schema migration support for versioned resources (#298)
1 parent 5b3d9cd commit 8a5cb5b

23 files changed

Lines changed: 1903 additions & 54 deletions

ARCHITECTURE.md

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,10 @@ classDiagram
6565
6666
class UserFMUDirectory
6767
68-
class CacheManager
68+
class CacheManager {
69+
+get_revision_content(..., migration_manager)
70+
+restore_revision(..., migration_manager)
71+
}
6972
class LockManager {
7073
+acquire()
7174
+ensure_can_write()
@@ -75,12 +78,20 @@ classDiagram
7578
}
7679
7780
class PydanticResourceManager~PydanticResource~ {
81+
+migration_manager
7882
+load(force, store_cache)
7983
+save(model)
8084
+get_resource_diff(incoming_resource)
8185
+get_structured_model_diff(current_model, incoming_model)
8286
}
8387
88+
class MigrationManager~MigratableResource~ {
89+
+model_class
90+
+current_version: int
91+
+migrate_resource(data)
92+
+requires_migration(data)
93+
}
94+
8495
class MutablePydanticResourceManager~MutablePydanticResource~ {
8596
+get(key, default)
8697
+set(key, value)
@@ -129,6 +140,8 @@ classDiagram
129140
130141
FMUDirectoryBase *-- LockManager
131142
FMUDirectoryBase *-- CacheManager
143+
PydanticResourceManager o-- MigrationManager
144+
CacheManager ..> MigrationManager
132145
ProjectFMUDirectory *-- ProjectConfigManager
133146
ProjectFMUDirectory *-- ChangelogManager
134147
ProjectFMUDirectory *-- MappingsManager
@@ -140,10 +153,53 @@ The main library split is:
140153
- `FMUDirectoryBase` is the filesystem-centered abstraction. It owns the `.fmu` path, lock manager, cache manager, and generic read/write helpers.
141154
- `ProjectFMUDirectory` and `UserFMUDirectory` specialize the base class for project-local `.fmu/` directories and `$HOME/.fmu/`.
142155
- `PydanticResourceManager` is the generic resource engine for loading, saving, diffing, and caching JSON-backed Pydantic models.
156+
- `MigrationManager` applies registered, forward-only schema migrations and validates the result as the current Pydantic model.
143157
- `MutablePydanticResourceManager` adds dot-notation `get`, `set`, `update`, `reset`, and merge behavior for editable resources.
144158
- `ProjectConfigManager`, `UserConfigManager`, `MappingsManager`, and `LogManager` bind specific Pydantic models to managed files inside `.fmu/`.
145159
- Directory objects compose the correct managers and delegate resource operations to them.
146160

161+
## Schema Migration
162+
163+
`ProjectConfig`, `UserConfig`, and `InternalMappings` are versioned resources. Each
164+
model declares its current schema version, and each resource manager has a
165+
`MigrationManager` with the migration registry for that model.
166+
167+
Migration is forward-only. Data without `schema_version` is treated as version 1.
168+
Each registered migration function increments the schema version by one. The manager
169+
rejects a newer schema, a missing migration step, an invalid version, or data that
170+
does not validate as the current model.
171+
172+
The migration boundary depends on the resource operation:
173+
174+
- **Load:** `PydanticResourceManager` decodes the stored JSON and asks
175+
`MigrationManager` for a validated current model. Migration happens in memory.
176+
Loading does not write the resource, create a cache revision, or add a changelog
177+
entry.
178+
- **Save:** The resource manager checks the write lock first. If the stored data is
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
182+
original JSON is also stored as a cache revision. The newly written current
183+
data is added as another cache revision.
184+
- **Cache read:** `CacheManager` uses the migration manager to return old revisions
185+
as current validated models.
186+
- **Restore:** `CacheManager` migrates the selected revision before writing it, so
187+
the restored resource uses the current schema. If you roll back to an older
188+
release while the pre-migration revision is still retained, restore that revision with
189+
the older release to return the resource file to the older schema. Project restore
190+
operations use the existing restore changelog entry.
191+
192+
There is no backward migration. After a current-schema resource is saved, an older
193+
`fmu-settings` release can reject it as newer than supported. If the pre-migration
194+
cache revision is still retained, restore it with the older release. Migration
195+
backups are not loaded or restored by the library. If the cache revision is no longer
196+
available, we should help users copy the appropriate migration backup back to the
197+
resource file before running the older release.
198+
199+
See the
200+
[schema migration guide](src/fmu/settings/_migrations/README.md)
201+
for the implementation, test, and coordinated release process.
202+
147203
## Runtime Flow
148204

149205
The full runtime spans multiple repositories, but `fmu-settings` owns the `.fmu/` directory operations used by the API and CLI.

CONTRIBUTING.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,8 @@ ruff check
3232
ruff format --check
3333
mypy src tests
3434
```
35+
36+
If you need to change the schema of `ProjectConfig`, `UserConfig`, or
37+
`InternalMappings`, see the
38+
[schema migration guide](src/fmu/settings/_migrations/README.md) for implementation
39+
and testing details.

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,11 @@ ruff format --check
8181
mypy src tests
8282
```
8383

84+
If you need to change the schema of `ProjectConfig`, `UserConfig`, or
85+
`InternalMappings`, see the
86+
[schema migration guide](src/fmu/settings/_migrations/README.md) for implementation
87+
and testing details.
88+
8489
See [CONTRIBUTING.md](CONTRIBUTING.md) for more.
8590

8691
> [!NOTE]

src/fmu/settings/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
init_fmu_directory,
2222
init_user_fmu_directory,
2323
)
24+
from ._migrations import MigrationError
2425
from .models._enums import CacheResource
2526
from .models.mappings import (
2627
InternalBaseMapping,
@@ -47,6 +48,7 @@
4748
"InternalWellboreIdentifierMapping",
4849
"InternalWellboreMappings",
4950
"InvalidFMUProjectPathError",
51+
"MigrationError",
5052
"ProjectFMUDirectory",
5153
"REQUIRED_FMU_PROJECT_SUBDIRS",
5254
"UserFMUDirectory",

src/fmu/settings/_fmu_dir.py

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@
2828
)
2929
from .models.user_config import UserConfig
3030

31+
if TYPE_CHECKING:
32+
from ._migrations import MigrationManager
33+
3134
logger: Final = null_logger(__name__)
3235

3336
FMUConfigManager: TypeAlias = ProjectConfigManager | UserConfigManager
@@ -44,7 +47,7 @@ class FMUDirectoryBase:
4447
def __init__(
4548
self: Self,
4649
base_path: str | Path,
47-
cache_revisions: int = CacheManager.MIN_REVISIONS,
50+
cache_revisions: int = 10,
4851
*,
4952
lock_timeout_seconds: int = DEFAULT_LOCK_TIMEOUT,
5053
) -> None:
@@ -53,7 +56,8 @@ def __init__(
5356
Args:
5457
base_path: The directory containing the .fmu directory or one of its parent
5558
dirs
56-
cache_revisions: Number of revisions to retain in the cache. Minimum is 5.
59+
cache_revisions: Number of revisions to retain in the cache. Default is 10.
60+
Minimum is 5.
5761
lock_timeout_seconds: Lock expiration time in seconds. Default 20 minutes.
5862
5963
Raises:
@@ -331,20 +335,18 @@ def __init__(
331335
self.config = ProjectConfigManager(self)
332336
super().__init__(
333337
base_path,
334-
CacheManager.MIN_REVISIONS,
338+
10,
335339
lock_timeout_seconds=lock_timeout_seconds,
336340
)
337341
self._changelog = ChangelogManager(self)
338342
self._mappings = MappingsManager(self)
339343
try:
340-
max_revisions = self.config.get(
341-
"cache_max_revisions", CacheManager.MIN_REVISIONS
342-
)
344+
max_revisions = self.config.get("cache_max_revisions", 10)
343345
self._cache_manager.max_revisions = max_revisions
344346
except (FileNotFoundError, ValueError) as e:
345347
logger.warning(
346348
f"Failed to load 'cache_max_revisions' from project config. "
347-
f"Using default value '{CacheManager.MIN_REVISIONS}'. Error: {e}"
349+
f"Using default value '10'. Error: {e}"
348350
)
349351

350352
def update_validation_metadata(
@@ -494,13 +496,19 @@ def restore_from_cache(
494496
if manager is self.config:
495497
previous_max_revisions = self._cache_manager.max_revisions
496498
restored_config = self.cache.get_revision_content(
497-
relative_path, revision_id, model_class=ProjectConfig
499+
relative_path,
500+
revision_id,
501+
model_class=ProjectConfig,
502+
migration_manager=self.config.migration_manager,
498503
)
499504
self._cache_manager.max_revisions = restored_config.cache_max_revisions
500505

501506
try:
502507
self.cache.restore_revision(
503-
relative_path, revision_id, model_class=ProjectConfig
508+
relative_path,
509+
revision_id,
510+
model_class=ProjectConfig,
511+
migration_manager=self.config.migration_manager,
504512
)
505513
except Exception:
506514
# Restore the previous runtime retention if config restore fails
@@ -519,7 +527,13 @@ def restore_from_cache(
519527
return
520528

521529
self.cache.restore_revision(
522-
relative_path, revision_id, model_class=manager.model_class
530+
relative_path,
531+
revision_id,
532+
model_class=cast("type[BaseModel]", manager.model_class),
533+
migration_manager=cast(
534+
"MigrationManager[BaseModel] | None",
535+
manager.migration_manager,
536+
),
523537
)
524538

525539
# Refresh the resource manager's in-memory cache
@@ -555,7 +569,13 @@ def get_cache_content(
555569
)
556570

557571
return self.cache.get_revision_content(
558-
relative_path, revision_id, model_class=manager.model_class
572+
relative_path,
573+
revision_id,
574+
model_class=cast("type[BaseModel]", manager.model_class),
575+
migration_manager=cast(
576+
"MigrationManager[BaseModel] | None",
577+
manager.migration_manager,
578+
),
559579
)
560580

561581
def _cacheable_resource_managers(

0 commit comments

Comments
 (0)