diff --git a/README.md b/README.md index 84e2a3e..2f8d410 100644 --- a/README.md +++ b/README.md @@ -20,13 +20,14 @@ Lardon3D ne vise pas simplement "dossier de photos → objet 3D", mais "ensemble ### Briques implémentées (IMPLEMENTED) -- **Project** : gestion persistante des projets +- **Project** : cycle de vie persistant, identité stable et Project Database ouverte - **Import** : import asynchrone et annulable d'images - **Import Task** : wrapper asynchrone avec états et progression - **Image Catalog** : indexage des métadonnées d'images - **Image View** : vues triées et filtrées pour la TUI - **Task** : moteur de tâches avec pause/reprise, annulation et séquences - **Task Checkpoint v1** : snapshot durable, fichier atomique et reprise sûre +- **Project Database v1** : identité, tâches/checkpoints et inventaire d'artefacts SQLite - **Task Queue** : file FIFO avec sélection adaptative et backpressure - **Hardware Profile** : détection des capacités matérielles - **Resource Snapshot** : capture instantanée des ressources @@ -39,7 +40,7 @@ Lardon3D ne vise pas simplement "dossier de photos → objet 3D", mais "ensemble ### Briques prévues (PLANNED) -- Branchement des checkpoints aux pipelines et reprise globale +- Reconstruction des callbacks métier et resoumission des tâches récupérables - DAG de dépendances - Pools de workers multiples (CPU/GPU/IO) - Publication live validée diff --git a/docs/architecture/persistence.md b/docs/architecture/persistence.md index 22c41b9..e50f6ea 100644 --- a/docs/architecture/persistence.md +++ b/docs/architecture/persistence.md @@ -108,13 +108,46 @@ par le `time_t` local avant conversion. Le format reste donc lisible entre plateformes uniquement pour les valeurs communes à leurs domaines `size_t` et `time_t`. +## Project Database v1 + +SQLite contient l'état logique interrogable et les références aux fichiers ; +les checkpoints et artefacts volumineux restent externes. L'enregistrement du +résumé de tâche et de sa référence checkpoint est une transaction unique. Un +artefact est d'abord publié et vérifié comme fichier régulier, puis seulement +marqué `READY` en DB. Le chemin inverse est interdit. + +### Protocole checkpoint projet + +Le protocole réel n'est pas une transaction distribuée : + +1. capture locale du snapshot sous le mutex de tâche puis déverrouillage ; +2. publication atomique du fichier sous + `.lardon3d/checkpoints/.chk` ; +3. transaction SQLite sur `tasks` et `checkpoints` avec chemin relatif. + +Une erreur avant publication ne modifie pas la DB. `PUBLISHED_NOT_DURABLE` est +conservé comme tel en DB. Si la publication réussit puis que SQLite retourne +`BUSY` ou une erreur, le fichier valide reste sur disque, la DB conserve son +ancienne vérité et le nouveau fichier est un orphelin à réconcilier plus tard. +Il n'est pas supprimé et aucune atomicité FS+SQLite n'est revendiquée. + +L'inventaire distingue checkpoint récupérable durable, récupérable mais publié +non durable, absent, invalide, version inconnue et erreur d'I/O. Aucune réparation +ou suppression silencieuse n'est effectuée. + ## Statut **IMPLEMENTED** — modèle durable, codec v1, lecture validée, publication atomique et restauration sûre d'une tâche isolée. -**NOT_YET_WIRED** — les pipelines et le scheduler ne déclenchent pas encore les -sauvegardes et ne rechargent pas globalement les tâches au démarrage. +**IMPLEMENTED** — Project Database v1 pour identité, résumés de tâches, +références checkpoint et artefacts génériques. -**PLANNED** — Project Database SQLite, catalogue d'artefacts réels, transactions -entre métadonnées et artefacts, migration de formats et reprise globale. +**IMPLEMENTED** — API projet de sauvegarde fichier+DB et inventaire validé au +redémarrage. + +**NOT_YET_WIRED** — autosave complet, reconstruction des callbacks métier, +resoumission scheduler et réconciliation des fichiers orphelins. + +**PLANNED** — catalogue d'artefacts photogrammétriques réels, migrations v2+ et +reprise globale. diff --git a/docs/architecture/project_database.md b/docs/architecture/project_database.md index 7bc9338..4160c7e 100644 --- a/docs/architecture/project_database.md +++ b/docs/architecture/project_database.md @@ -162,9 +162,90 @@ validation ; elle ne stockera jamais les objets pthread, callbacks, pointeurs, contrats ou réservations. Le fichier par tâche est une fondation, pas une Project Database miniature. +La stratégie v1 retient un résumé logique interrogable dans SQLite et une +référence vers le fichier checkpoint. Le fichier checkpoint validé reste la +source complète pour `lardon3d_task_restore()` ; la DB seule ne reconstruit +jamais une tâche. Un écart ou un fichier invalide interdit la reprise. + +## Schéma v1 implémenté + +- `metadata(key PRIMARY KEY, value)` contient uniquement `schema_version=1`. +- `project(singleton=1, stable_id UNIQUE, name, created_at, updated_at)` décrit + l'unique identité logique de la DB. +- `tasks(task_id PRIMARY KEY, name, saved_state, recovery_state, progress, + sequence_count, started_sec/nsec, finished_sec/nsec, updated_at)` contient le + résumé durable. Les IDs v1 sont compris entre 1 et `INT64_MAX`. +- `checkpoints(task_id PRIMARY KEY REFERENCES tasks ON DELETE CASCADE, path, + format_version, durability, updated_at)` représente `DURABLE` ou + `PUBLISHED_NOT_DURABLE`. +- `artifacts(artifact_id PRIMARY KEY, kind, path, state, size_bytes, + producer_task_id REFERENCES tasks, created_at, updated_at)` inventorie des + fichiers externes. Les états v1 sont `STAGED` et `READY`. + +Les indexes portent uniquement sur `tasks(recovery_state, task_id)`, +`artifacts(state, artifact_id)` et `artifacts(producer_task_id)`. + +## Ouverture et migrations + +Une DB vide reçoit le schéma v1 dans une transaction `BEGIN IMMEDIATE`. Une DB +v1 est validée puis ouverte. Une version future est refusée et une DB contenant +des tables sans métadonnée de version est considérée corrompue. La fonction +interne de migration ne connaît que `0 → 1`; aucune migration fictive ou +destructive n'est présente. + +Configuration v1 : `foreign_keys=ON`, `journal_mode=DELETE`, +`synchronous=FULL`, `busy_timeout=5000`. Le mode DELETE convient au propriétaire +unique actuel, évite les fichiers WAL/SHM durables et conserve la synchronisation +forte. Le timeout borne l'attente d'un verrou externe à cinq secondes. + +## Concurrence et ownership + +Une connexion opaque est sérialisée par un mutex interne. Chaque opération +composée possède sa transaction entière ; aucune transaction publique ne peut +rester ouverte entre deux appels. Aucune I/O d'artefact n'a lieu sous le mutex : +le module confirme que le fichier publié et validé par l'appelant est régulier +avant la mise à jour `READY`. Fermer la DB pendant un +appel concurrent est interdit au propriétaire. + +Les records et chaînes sont copiés dans des buffers fournis par l'appelant ; +aucun pointeur SQLite n'en sort. Tous les statements sont finalisés dans +l'appel. La liste de reprise utilise des pages fournies par l'appelant, limitées +à 256 entrées. Elle ne retourne que les tâches normalisées `PENDING` possédant +une référence checkpoint ; le fichier doit encore être chargé et validé. + +## Branchement au projet + +`Lardon3DAppState` est l'instance projet runtime actuelle et possède exactement +une `Lardon3DProjectDb *` pendant que `project_loaded` est vrai. La DB canonique +est `/project.db`. Elle est ouverte à la création/ouverture du +projet et fermée une seule fois par `lardon3d_project_close()`. À l'arrêt de +l'application, la task queue est arrêtée avant la fermeture du projet et de sa +DB. Une fermeture concurrente à un appel projet/DB est interdite au propriétaire. + +`project.ini` v2 contient `name`, `stable_id` hexadécimal sur 128 bits et +`version=2`. La même identité est enregistrée dans la table `project`. Toute +divergence est une erreur. Un INI v1 sans identité adopte l'identité d'une DB +existante ; sans DB, une identité est générée une seule fois puis l'INI est +migré atomiquement avant de devenir la référence des ouvertures suivantes. Une +DB existante sans ligne projet ne peut être initialisée que si l'INI possède +déjà son identité. + +Les checkpoints sont référencés par chemins relatifs portables : +`.lardon3d/checkpoints/.chk`. L'inventaire projet pagine les tâches DB, +résout ce chemin sous la racine, charge le checkpoint et vérifie la cohérence du +snapshot avec le résumé DB. + ## Statut -**NOT_YET_WIRED** — le modèle de checkpoint est prêt à être consommé. +**IMPLEMENTED** — SQLite système, schéma/migration v1, identité projet, +transactions tâche+checkpoint, pagination de reprise et artefacts génériques. -**PLANNED** — schéma SQLite, migrations, transactions, inventaire d'artefacts, -chargement global et coordination avec le scheduler. +**IMPLEMENTED** — ouverture/fermeture avec le projet, identité INI/DB cohérente, +publication de checkpoints par le projet et inventaire de reprise validé. + +**NOT_YET_WIRED** — reconstruction des callbacks/userdata métier, resoumission +scheduler, autosave à toutes les transitions et réconciliation des checkpoints +orphelins, ScanSet et catalogue image persistants, Feature Store et Visual Index. + +**PLANNED** — migrations v2+, dépendances d'artefacts, graphe géométrique et +reconstruction incrémentale. diff --git a/docs/architecture/runtime.md b/docs/architecture/runtime.md index 283dba3..ae34e5f 100644 --- a/docs/architecture/runtime.md +++ b/docs/architecture/runtime.md @@ -75,11 +75,24 @@ qu'à la terminaison de cette tentative. **IMPLEMENTED** — snapshot, codec v1 et restauration isolée. -**NOT_YET_WIRED** — sauvegarde périodique, chargement de projet et resoumission -automatique. +**NOT_YET_WIRED** — sauvegarde périodique, reconstruction métier des tâches et +resoumission automatique. **PLANNED** — reprise globale du scheduler via la Project Database. +## Accès Project Database + +**IMPLEMENTED** — une connexion SQLite opaque sérialisée par mutex interne ; +les opérations multi-tables sont transactionnelles et bornées. + +**IMPLEMENTED** — le cycle de vie projet ouvre/crée `project.db`, vérifie +l'identité et ferme la connexion. L'application arrête la task queue avant la +fermeture finale du projet. + +**NOT_YET_WIRED** — aucune reconstruction de callback/userdata ni resoumission +automatique. Une future resoumission devra aussi permettre à la queue de +conserver l'identifiant stable restauré au lieu d'en assigner un nouveau. + ## Invariants - ncurses appartient exclusivement au thread principal diff --git a/docs/architecture/task_queue.md b/docs/architecture/task_queue.md index d0cb141..8f7bded 100644 --- a/docs/architecture/task_queue.md +++ b/docs/architecture/task_queue.md @@ -19,40 +19,33 @@ l'ordre FIFO et consulte le gouverneur via le scheduler. ## Types principaux -```c -typedef struct task_queue task_queue_t; - -typedef void (*task_callback_fn)(task_t *task, task_result_t *result, void *userdata); -``` +Le type public réel est l'opaque `Lardon3DTaskQueue`. ## API publique | Fonction | Description | |---|---| -| `task_queue_create()` | Alloue et initialise une file vide | -| `task_queue_destroy()` | Libère la file et toutes les tâches restantes | -| `task_queue_submit()` | Soumet une tâche à la file (FIFO) | -| `task_queue_next()` | Sélectionne la prochaine tâche admissible | -| `task_queue_pop()` | Retire et retourne la tâche sélectionnée | -| `task_queue_cancel()` | Annule une tâche spécifique dans la file | -| `task_queue_cancel_all()` | Annule toutes les tâches en attente | -| `task_queue_size()` | Retourne le nombre de tâches en attente | -| `task_queue_is_empty()` | Vérifie si la file est vide | -| `task_queue_set_callback()` | Définit le callback pour les résultats | +| `lardon3d_task_queue_create()` | Crée une file bornée et son worker | +| `lardon3d_task_queue_destroy()` | Arrête, annule, attend puis libère la file | +| `lardon3d_task_queue_add()` | Ajoute avec backpressure bloquante | +| `lardon3d_task_queue_try_add()` | Ajoute sans bloquer si une place existe | +| `lardon3d_task_queue_cancel()` | Demande l'annulation par ID | +| `lardon3d_task_queue_remove()` | Retire une tâche terminale | +| `lardon3d_task_queue_snapshot()` | Copie une vue bornée de la file | ## Comportement FIFO -1. `task_queue_submit()` ajoute la tâche en fin de file. -2. `task_queue_next()` parcourt la file du début vers la fin. -3. La première tâche en état `QUEUED` (non en pause, non annulée) est +1. `lardon3d_task_queue_add()` ajoute la tâche en fin de file. +2. Le sélecteur interne parcourt la file du début vers la fin. +3. La première tâche `PENDING` admissible (non terminale) est retournée. -4. Si aucune tâche n'est admissible, `task_queue_next()` retourne `NULL`. +4. Si aucune tâche n'est admissible, le worker attend un changement. 5. L'ordre de soumission est toujours respecté entre tâches de même priorité. ## Sélection adaptative -`task_queue_next()` saute les tâches en état `WAIT` (en attente de -ressources) et retourne la première tâche réellement admissible. Cela évite +Le sélecteur saute les tâches pour lesquelles le governor répond `WAIT` et +retourne la première tâche réellement admissible. Cela évite le blocage par la tête de file lorsqu'une tâche ne peut pas démarrer. ## Invariants @@ -71,9 +64,9 @@ le blocage par la tête de file lorsqu'une tâche ne peut pas démarrer. ## Interactions -- **task** : chaque entrée de la file est un `task_t` avec son état et sa +- **task** : chaque entrée de la file est un `Lardon3DTask` avec son état et sa progression. -- **scheduler** : le scheduler appelle `task_queue_submit()` et orchestre +- **scheduler** : la file applique la soumission FIFO et orchestre l'exécution via le worker. - **resource_governor** : la file consulte le gouverneur (via le scheduler) avant d'exécuter chaque tâche. @@ -90,6 +83,6 @@ annulation coopératives. - Worker unique : pas de parallélisme interne. - Pas de DAG ni de dépendances inter-tâches. - Pas de priorités (FIFO strict). -- Pas de persistance des tâches après arrêt. +- La reprise depuis Project Database n'est pas encore branchée. - Pas de pool de workers CPU/IO/GPU. -- Pas de contre-pression (backpressure) entre étapes. +- La backpressure borne les producteurs à la capacité configurée. diff --git a/docs/architecture/task_system.md b/docs/architecture/task_system.md index 61fff92..c5b161b 100644 --- a/docs/architecture/task_system.md +++ b/docs/architecture/task_system.md @@ -75,6 +75,17 @@ adaptatives et fondation de checkpoints persistants isolés. **NOT_YET_WIRED** — sauvegarde automatique et restauration par la file. +La Project Database v1 peut enregistrer transactionnellement un résumé +`Lardon3DTaskDurableSnapshot` et la référence de son checkpoint. Elle ne stocke +ni estimation sérialisée complète, ni callback, ni réservation, et ne remplace +pas la validation du fichier checkpoint avant `task_restore()`. + +`lardon3d_project_checkpoint_task()` est la frontière runtime : elle capture le +snapshot, publie le fichier hors mutex de tâche, puis met à jour la DB. L'API +d'inventaire retourne des snapshots durables validés, mais ne peut pas appeler +`task_restore()` tant que le type métier ne sait pas reconstruire callback et +userdata. + ## Limites - Aucune priorité interne : l'ordre est uniquement FIFO. diff --git a/docs/roadmap/roadmap.md b/docs/roadmap/roadmap.md index 17e84f2..697d826 100644 --- a/docs/roadmap/roadmap.md +++ b/docs/roadmap/roadmap.md @@ -29,8 +29,9 @@ Lardon3D suit une feuille de route ordonnée qui privilégie la stabilité et la ### Phase 3 : Persistance - ✅ Fondation versionnée des checkpoints de tâches -- 📋 Branchement des checkpoints aux pipelines et reprise globale -- 📋 Project Database v1 (SQLite) +- ✅ Project Database v1 (SQLite) +- ✅ Branchement Project Database au cycle de vie projet et inventaire de reprise +- 📋 Reconstruction métier et resoumission des tâches récupérables - 📋 ScanSet et Image Catalog persistants ### Phase 4 : Pipeline avancé diff --git a/include/lardon3d/app_state.h b/include/lardon3d/app_state.h index 305bc93..8a364b1 100644 --- a/include/lardon3d/app_state.h +++ b/include/lardon3d/app_state.h @@ -8,6 +8,7 @@ typedef struct Lardon3DImageCatalog Lardon3DImageCatalog; typedef struct Lardon3DImageView Lardon3DImageView; typedef struct Lardon3DTaskQueue Lardon3DTaskQueue; typedef struct Lardon3DResourceGovernor Lardon3DResourceGovernor; +typedef struct Lardon3DProjectDb Lardon3DProjectDb; typedef enum { LARDON3D_SCREEN_HOME = 0, @@ -25,12 +26,14 @@ typedef struct { bool project_loaded; char project_name[128]; char project_path[PATH_MAX]; + char project_stable_id[65]; char status_message[256]; Lardon3DImageCatalog *image_catalog; Lardon3DImageView *image_view; Lardon3DTaskQueue *task_queue; Lardon3DHardwareProfile hardware_profile; Lardon3DResourceGovernor *resource_governor; + Lardon3DProjectDb *project_db; } Lardon3DAppState; void lardon3d_app_state_init(Lardon3DAppState *state); diff --git a/include/lardon3d/project.h b/include/lardon3d/project.h index c06b549..9af5d3a 100644 --- a/include/lardon3d/project.h +++ b/include/lardon3d/project.h @@ -4,6 +4,34 @@ #include #include +#include + +typedef enum { + LARDON3D_PROJECT_TASK_CHECKPOINT_OK = 0, + LARDON3D_PROJECT_TASK_CHECKPOINT_PUBLISHED_NOT_DURABLE, + LARDON3D_PROJECT_TASK_CHECKPOINT_NO_PROJECT, + LARDON3D_PROJECT_TASK_CHECKPOINT_INVALID_TASK, + LARDON3D_PROJECT_TASK_CHECKPOINT_IO_ERROR, + LARDON3D_PROJECT_TASK_CHECKPOINT_DB_BUSY, + LARDON3D_PROJECT_TASK_CHECKPOINT_DB_ERROR +} Lardon3DProjectTaskCheckpointResult; + +typedef enum { + LARDON3D_PROJECT_RECOVERABLE = 0, + LARDON3D_PROJECT_RECOVERABLE_PUBLISHED_NOT_DURABLE, + LARDON3D_PROJECT_RECOVERY_MISSING_CHECKPOINT, + LARDON3D_PROJECT_RECOVERY_INVALID_CHECKPOINT, + LARDON3D_PROJECT_RECOVERY_UNSUPPORTED_CHECKPOINT, + LARDON3D_PROJECT_RECOVERY_CHECKPOINT_IO_ERROR +} Lardon3DProjectRecoveryStatus; + +typedef struct { + uint64_t task_id; + char name[LARDON3D_TASK_NAME_CAPACITY]; + Lardon3DProjectRecoveryStatus status; + Lardon3DProjectDbCheckpointDurability durability; + Lardon3DTaskDurableSnapshot snapshot; +} Lardon3DProjectRecoveryEntry; bool lardon3d_project_create( Lardon3DAppState *state, @@ -16,5 +44,16 @@ bool lardon3d_project_open( ); void lardon3d_project_close(Lardon3DAppState *state); +Lardon3DProjectTaskCheckpointResult lardon3d_project_checkpoint_task( + Lardon3DAppState *state, + const Lardon3DTask *task +); +Lardon3DProjectDbResult lardon3d_project_list_recoverable( + Lardon3DAppState *state, + uint64_t after_task_id, + Lardon3DProjectRecoveryEntry *entries, + size_t capacity, + size_t *count +); #endif diff --git a/include/lardon3d/project_db.h b/include/lardon3d/project_db.h new file mode 100644 index 0000000..9fe1bff --- /dev/null +++ b/include/lardon3d/project_db.h @@ -0,0 +1,136 @@ +#ifndef LARDON3D_PROJECT_DB_H +#define LARDON3D_PROJECT_DB_H + +#include +#include +#include +#include + +#include + +enum { + LARDON3D_PROJECT_DB_SCHEMA_VERSION = 1, + LARDON3D_PROJECT_DB_ID_CAPACITY = 65, + LARDON3D_PROJECT_DB_KIND_CAPACITY = 65, + LARDON3D_PROJECT_DB_PATH_CAPACITY = 4096, + LARDON3D_PROJECT_DB_ERROR_CAPACITY = 256, + LARDON3D_PROJECT_DB_RECOVERY_PAGE_MAX = 256, +}; + +typedef struct Lardon3DProjectDb Lardon3DProjectDb; + +typedef enum { + LARDON3D_PROJECT_DB_OK = 0, + LARDON3D_PROJECT_DB_INVALID_ARGUMENT, + LARDON3D_PROJECT_DB_NOT_FOUND, + LARDON3D_PROJECT_DB_BUSY, + LARDON3D_PROJECT_DB_UNSUPPORTED_SCHEMA, + LARDON3D_PROJECT_DB_CORRUPT, + LARDON3D_PROJECT_DB_CONSTRAINT, + LARDON3D_PROJECT_DB_IO_ERROR +} Lardon3DProjectDbResult; + +typedef enum { + LARDON3D_DB_CHECKPOINT_DURABLE = 0, + LARDON3D_DB_CHECKPOINT_PUBLISHED_NOT_DURABLE +} Lardon3DProjectDbCheckpointDurability; + +typedef enum { + LARDON3D_DB_ARTIFACT_STAGED = 0, + LARDON3D_DB_ARTIFACT_READY +} Lardon3DProjectDbArtifactState; + +typedef struct { + char stable_id[LARDON3D_PROJECT_DB_ID_CAPACITY]; + char name[LARDON3D_TASK_NAME_CAPACITY]; + int64_t created_at; + int64_t updated_at; +} Lardon3DProjectDbProject; + +typedef struct { + char path[LARDON3D_PROJECT_DB_PATH_CAPACITY]; + uint32_t format_version; + Lardon3DProjectDbCheckpointDurability durability; + int64_t updated_at; +} Lardon3DProjectDbCheckpoint; + +typedef struct { + uint64_t task_id; + char name[LARDON3D_TASK_NAME_CAPACITY]; + Lardon3DTaskState saved_state; + Lardon3DTaskState recovery_state; + unsigned int progress; + unsigned int sequence_count; + struct timespec started_at; + struct timespec finished_at; + int64_t updated_at; + bool has_checkpoint; + Lardon3DProjectDbCheckpoint checkpoint; +} Lardon3DProjectDbTask; + +typedef struct { + char artifact_id[LARDON3D_PROJECT_DB_ID_CAPACITY]; + char kind[LARDON3D_PROJECT_DB_KIND_CAPACITY]; + char path[LARDON3D_PROJECT_DB_PATH_CAPACITY]; + Lardon3DProjectDbArtifactState state; + uint64_t size_bytes; + bool has_producer_task; + uint64_t producer_task_id; + int64_t created_at; + int64_t updated_at; +} Lardon3DProjectDbArtifact; + +Lardon3DProjectDbResult lardon3d_project_db_open( + const char *path, + Lardon3DProjectDb **database, + char error[LARDON3D_PROJECT_DB_ERROR_CAPACITY] +); +void lardon3d_project_db_close(Lardon3DProjectDb *database); +bool lardon3d_project_db_last_error( + Lardon3DProjectDb *database, + char error[LARDON3D_PROJECT_DB_ERROR_CAPACITY] +); +unsigned int lardon3d_project_db_schema_version(Lardon3DProjectDb *database); + +Lardon3DProjectDbResult lardon3d_project_db_set_project( + Lardon3DProjectDb *database, + const Lardon3DProjectDbProject *project +); +Lardon3DProjectDbResult lardon3d_project_db_get_project( + Lardon3DProjectDb *database, + Lardon3DProjectDbProject *project +); +Lardon3DProjectDbResult lardon3d_project_db_record_task( + Lardon3DProjectDb *database, + const Lardon3DTaskDurableSnapshot *snapshot, + const Lardon3DProjectDbCheckpoint *checkpoint, + int64_t updated_at +); +Lardon3DProjectDbResult lardon3d_project_db_load_task( + Lardon3DProjectDb *database, + uint64_t task_id, + Lardon3DProjectDbTask *task +); +Lardon3DProjectDbResult lardon3d_project_db_list_recoverable( + Lardon3DProjectDb *database, + uint64_t after_task_id, + Lardon3DProjectDbTask *tasks, + size_t capacity, + size_t *count +); +Lardon3DProjectDbResult lardon3d_project_db_create_artifact( + Lardon3DProjectDb *database, + const Lardon3DProjectDbArtifact *artifact +); +Lardon3DProjectDbResult lardon3d_project_db_mark_artifact_ready( + Lardon3DProjectDb *database, + const char *artifact_id, + int64_t updated_at +); +Lardon3DProjectDbResult lardon3d_project_db_load_artifact( + Lardon3DProjectDb *database, + const char *artifact_id, + Lardon3DProjectDbArtifact *artifact +); + +#endif diff --git a/meson.build b/meson.build index 4b53624..adae776 100644 --- a/meson.build +++ b/meson.build @@ -20,6 +20,7 @@ add_project_arguments( ncursesw = dependency('ncursesw', required: true) threads = dependency('threads') +sqlite3 = dependency('sqlite3', required: true) executable( 'lardon3d', @@ -34,6 +35,7 @@ executable( 'src/image_catalog.c', 'src/image_view.c', 'src/project.c', + 'src/project_db.c', 'src/task.c', 'src/task_checkpoint.c', 'src/task_queue.c', @@ -42,7 +44,7 @@ executable( 'src/hardware_profile.c', ], include_directories: include_directories('include'), - dependencies: [ncursesw, threads], + dependencies: [ncursesw, threads, sqlite3], ) import_test = executable( @@ -126,6 +128,43 @@ task_checkpoint_test = executable( test('task-checkpoint', task_checkpoint_test, timeout: 30) +project_db_test = executable( + 'test-project-db', + sources: [ + 'tests/test_project_db.c', + 'src/project_db.c', + ], + c_args: ['-DLARDON3D_PROJECT_DB_TESTING'], + include_directories: include_directories('include'), + dependencies: [threads, sqlite3], +) + +test('project-db', project_db_test, timeout: 30) + +project_test = executable( + 'test-project', + sources: [ + 'tests/test_project.c', + 'src/app_state.c', + 'src/project.c', + 'src/project_db.c', + 'src/task.c', + 'src/task_checkpoint.c', + 'src/image_catalog.c', + 'src/image_view.c', + 'src/resource_governor.c', + 'src/resource_snapshot.c', + ], + c_args: [ + '-DLARDON3D_PROJECT_DB_TESTING', + '-DLARDON3D_CHECKPOINT_TESTING', + ], + include_directories: include_directories('include'), + dependencies: [threads, sqlite3], +) + +test('project', project_test, timeout: 30) + sequential_task_test = executable( 'test-sequential-task', sources: [ diff --git a/src/app.c b/src/app.c index f5a7f1d..628ed77 100644 --- a/src/app.c +++ b/src/app.c @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -51,10 +52,12 @@ lardon3d_app_run(void) } bool success = lardon3d_tui_run(&state); - lardon3d_image_view_destroy(state.image_view); - lardon3d_image_catalog_destroy(state.image_catalog); lardon3d_tui_shutdown(); lardon3d_task_queue_destroy(state.task_queue); + state.task_queue = NULL; + if (state.project_loaded) { + lardon3d_project_close(&state); + } lardon3d_resource_governor_destroy(state.resource_governor); return success ? EXIT_SUCCESS : EXIT_FAILURE; diff --git a/src/app_state.c b/src/app_state.c index e5466cb..f8a16dd 100644 --- a/src/app_state.c +++ b/src/app_state.c @@ -13,6 +13,7 @@ lardon3d_app_state_init(Lardon3DAppState *state) .project_loaded = false, .project_name = "", .project_path = "", + .project_stable_id = "", .status_message = "Bienvenue dans Lardon3D", }; } diff --git a/src/project.c b/src/project.c index 1e96e82..dbd04de 100644 --- a/src/project.c +++ b/src/project.c @@ -1,17 +1,21 @@ #include #include #include +#include #include #include #include #include #include #include +#include #include #include #include #include +#include +#include enum { MAX_CREATED_DIRECTORIES = 16, @@ -23,6 +27,12 @@ typedef struct { size_t count; } CreatedDirectories; +typedef struct { + char name[128]; + char stable_id[LARDON3D_PROJECT_DB_ID_CAPACITY]; + unsigned int version; +} ProjectMetadata; + static void set_status(Lardon3DAppState *state, const char *message) { @@ -34,6 +44,28 @@ set_status(Lardon3DAppState *state, const char *message) ); } +static void +set_database_status( + Lardon3DAppState *state, + Lardon3DProjectDb *database, + const char *open_error, + const char *fallback +) +{ + char detail[LARDON3D_PROJECT_DB_ERROR_CAPACITY] = ""; + if (database) { + (void)lardon3d_project_db_last_error(database, detail); + } else if (open_error) { + (void)snprintf(detail, sizeof(detail), "%s", open_error); + } + (void)snprintf( + state->status_message, + sizeof(state->status_message), + "Erreur project.db : %.220s", + detail[0] ? detail : fallback + ); +} + static void clear_catalog(Lardon3DAppState *state) { @@ -113,6 +145,35 @@ join_path( return written >= 0 && (size_t)written < size; } +static bool +generate_stable_id(char output[LARDON3D_PROJECT_DB_ID_CAPACITY]) +{ + unsigned char bytes[16]; + int descriptor = open("/dev/urandom", O_RDONLY | O_CLOEXEC); + if (descriptor < 0) { + return false; + } + size_t used = 0; + while (used < sizeof(bytes)) { + ssize_t amount = read(descriptor, bytes + used, sizeof(bytes) - used); + if (amount < 0 && errno == EINTR) { + continue; + } + if (amount <= 0) { + (void)close(descriptor); + return false; + } + used += (size_t)amount; + } + if (close(descriptor) != 0) { + return false; + } + for (size_t index = 0; index < sizeof(bytes); ++index) { + (void)snprintf(output + index * 2, 3, "%02x", bytes[index]); + } + return true; +} + static bool resolve_projects_root(Lardon3DAppState *state, char root[PATH_MAX]) { @@ -224,7 +285,8 @@ static bool write_project_ini( Lardon3DAppState *state, const char *project_path, - const char *project_name + const char *project_name, + const char *stable_id ) { char temporary_path[PATH_MAX]; @@ -256,8 +318,9 @@ write_project_ini( bool success = fprintf( file, - "[project]\nname=%s\nversion=1\n", - project_name + "[project]\nname=%s\nstable_id=%s\nversion=2\n", + project_name, + stable_id ) >= 0; if (success) { success = fflush(file) == 0; @@ -284,10 +347,15 @@ lardon3d_project_create(Lardon3DAppState *state, const char *name) if (!state) { return false; } + if (state->project_loaded || state->project_db) { + set_status(state, "Erreur : un projet est déjà ouvert."); + return false; + } char normalized_name[sizeof(state->project_name)]; char root[PATH_MAX]; char project_path[PATH_MAX]; + char stable_id[LARDON3D_PROJECT_DB_ID_CAPACITY]; if (!normalize_name( state, name, @@ -299,6 +367,10 @@ lardon3d_project_create(Lardon3DAppState *state, const char *name) if (!resolve_projects_root(state, root)) { return false; } + if (!generate_stable_id(stable_id)) { + set_status(state, "Erreur : impossible de créer l'identité du projet."); + return false; + } if (!join_path( project_path, sizeof(project_path), @@ -331,6 +403,8 @@ lardon3d_project_create(Lardon3DAppState *state, const char *name) "reconstruction", "exports", "logs", + ".lardon3d", + ".lardon3d/checkpoints", }; for (size_t index = 0; index < sizeof(subdirectories) / sizeof(subdirectories[0]); @@ -352,13 +426,56 @@ lardon3d_project_create(Lardon3DAppState *state, const char *name) } } - if (!write_project_ini(state, project_path, normalized_name)) { + if (!write_project_ini(state, project_path, normalized_name, stable_id)) { + cleanup_directories(&created); + return false; + } + + char database_path[PATH_MAX]; + char ini_path[PATH_MAX]; + if (!join_path(database_path, sizeof(database_path), project_path, "project.db") + || !join_path(ini_path, sizeof(ini_path), project_path, "project.ini")) { + cleanup_directories(&created); + set_status(state, "Erreur : chemin de base projet trop long."); + return false; + } + Lardon3DProjectDb *database = NULL; + char database_error[LARDON3D_PROJECT_DB_ERROR_CAPACITY]; + Lardon3DProjectDbResult database_result = lardon3d_project_db_open( + database_path, + &database, + database_error + ); + struct timespec now; + if (database_result == LARDON3D_PROJECT_DB_OK + && clock_gettime(CLOCK_REALTIME, &now) == 0) { + Lardon3DProjectDbProject project = { + .created_at = now.tv_sec, + .updated_at = now.tv_sec, + }; + (void)snprintf(project.stable_id, sizeof(project.stable_id), "%s", stable_id); + (void)snprintf(project.name, sizeof(project.name), "%s", normalized_name); + database_result = lardon3d_project_db_set_project(database, &project); + } else if (database_result == LARDON3D_PROJECT_DB_OK) { + database_result = LARDON3D_PROJECT_DB_IO_ERROR; + } + if (database_result != LARDON3D_PROJECT_DB_OK) { + set_database_status( + state, + database, + database_error, + "initialisation impossible" + ); + lardon3d_project_db_close(database); + (void)unlink(database_path); + (void)unlink(ini_path); cleanup_directories(&created); return false; } clear_catalog(state); state->project_loaded = true; + state->project_db = database; (void)copy_path( state->project_name, sizeof(state->project_name), @@ -369,6 +486,12 @@ lardon3d_project_create(Lardon3DAppState *state, const char *name) sizeof(state->project_path), project_path ); + (void)snprintf( + state->project_stable_id, + sizeof(state->project_stable_id), + "%s", + stable_id + ); (void)snprintf( state->status_message, sizeof(state->status_message), @@ -382,7 +505,7 @@ static bool read_project_ini( Lardon3DAppState *state, const char *path, - char project_name[128] + ProjectMetadata *metadata ) { int descriptor = open(path, O_RDONLY | O_NOFOLLOW); @@ -408,6 +531,7 @@ read_project_ini( bool in_project_section = false; bool section_found = false; bool name_found = false; + bool stable_id_found = false; bool version_found = false; bool valid = true; char line[INI_LINE_CAPACITY]; @@ -433,14 +557,45 @@ read_project_ini( if (name_found || !normalize_name( state, line + 5, - project_name, + metadata->name, sizeof(state->project_name) )) { valid = false; } name_found = true; + } else if (in_project_section && strncmp(line, "stable_id=", 10) == 0) { + size_t id_length = strnlen( + line + 10, + sizeof(metadata->stable_id) + ); + if (stable_id_found || id_length != 32) { + valid = false; + } else { + for (size_t index = 0; index < id_length; ++index) { + char character = line[10 + index]; + if (!((character >= '0' && character <= '9') + || (character >= 'a' && character <= 'f'))) { + valid = false; + } + } + if (valid) { + (void)snprintf( + metadata->stable_id, + sizeof(metadata->stable_id), + "%s", + line + 10 + ); + } + } + stable_id_found = true; } else if (in_project_section && strncmp(line, "version=", 8) == 0) { - if (version_found || strcmp(line, "version=1") != 0) { + if (version_found) { + valid = false; + } else if (strcmp(line, "version=1") == 0) { + metadata->version = 1; + } else if (strcmp(line, "version=2") == 0) { + metadata->version = 2; + } else { valid = false; } version_found = true; @@ -453,7 +608,9 @@ read_project_ini( if (fclose(file) != 0) { valid = false; } - if (!valid || !section_found || !name_found || !version_found) { + if (!valid || !section_found || !name_found || !version_found + || (metadata->version == 2 && !stable_id_found) + || (metadata->version == 1 && stable_id_found)) { set_status(state, "Erreur : project.ini invalide."); return false; } @@ -469,11 +626,16 @@ lardon3d_project_open( if (!state) { return false; } + if (state->project_loaded || state->project_db) { + set_status(state, "Erreur : un projet est déjà ouvert."); + return false; + } char normalized_directory[sizeof(state->project_name)]; char root[PATH_MAX]; char project_path[PATH_MAX]; char ini_path[PATH_MAX]; + char database_path[PATH_MAX]; if (!normalize_name( state, directory_name, @@ -491,7 +653,8 @@ lardon3d_project_open( root, normalized_directory ) - || !join_path(ini_path, sizeof(ini_path), project_path, "project.ini")) { + || !join_path(ini_path, sizeof(ini_path), project_path, "project.ini") + || !join_path(database_path, sizeof(database_path), project_path, "project.db")) { set_status(state, "Erreur : chemin du projet trop long."); return false; } @@ -502,17 +665,133 @@ lardon3d_project_open( return false; } - char project_name[sizeof(state->project_name)]; - if (!read_project_ini(state, ini_path, project_name)) { + ProjectMetadata metadata = {0}; + if (!read_project_ini(state, ini_path, &metadata)) { + return false; + } + + bool database_existed = false; + if (lstat(database_path, &info) == 0) { + if (!S_ISREG(info.st_mode)) { + set_status(state, "Erreur : project.db n'est pas un fichier régulier."); + return false; + } + database_existed = true; + } else if (errno != ENOENT) { + set_status(state, "Erreur : project.db est inaccessible."); + return false; + } + + CreatedDirectories created = {0}; + char internal_path[PATH_MAX]; + char checkpoint_path[PATH_MAX]; + if (!join_path(internal_path, sizeof(internal_path), project_path, ".lardon3d") + || !join_path(checkpoint_path, sizeof(checkpoint_path), internal_path, "checkpoints") + || !ensure_directory(state, internal_path, &created) + || !ensure_directory(state, checkpoint_path, &created)) { + cleanup_directories(&created); + return false; + } + + Lardon3DProjectDb *database = NULL; + char database_error[LARDON3D_PROJECT_DB_ERROR_CAPACITY]; + Lardon3DProjectDbResult database_result = lardon3d_project_db_open( + database_path, + &database, + database_error + ); + if (database_result != LARDON3D_PROJECT_DB_OK) { + cleanup_directories(&created); + set_database_status( + state, + NULL, + database_error, + "ouverture impossible" + ); + return false; + } + + Lardon3DProjectDbProject db_project; + database_result = lardon3d_project_db_get_project(database, &db_project); + bool rewrite_metadata = metadata.version == 1; + struct timespec now; + if (clock_gettime(CLOCK_REALTIME, &now) != 0) { + database_result = LARDON3D_PROJECT_DB_IO_ERROR; + } else if (database_result == LARDON3D_PROJECT_DB_OK) { + if (metadata.stable_id[0] + && strcmp(metadata.stable_id, db_project.stable_id) != 0) { + database_result = LARDON3D_PROJECT_DB_CONSTRAINT; + } else if (!metadata.stable_id[0]) { + (void)snprintf( + metadata.stable_id, + sizeof(metadata.stable_id), + "%s", + db_project.stable_id + ); + rewrite_metadata = true; + } + if (database_result == LARDON3D_PROJECT_DB_OK) { + db_project.updated_at = now.tv_sec; + (void)snprintf(db_project.name, sizeof(db_project.name), "%s", metadata.name); + database_result = lardon3d_project_db_set_project(database, &db_project); + } + } else if (database_result == LARDON3D_PROJECT_DB_NOT_FOUND) { + if (database_existed && !metadata.stable_id[0]) { + database_result = LARDON3D_PROJECT_DB_CONSTRAINT; + } else { + if (!metadata.stable_id[0]) { + if (!generate_stable_id(metadata.stable_id)) { + database_result = LARDON3D_PROJECT_DB_IO_ERROR; + } + rewrite_metadata = true; + } + if (database_result != LARDON3D_PROJECT_DB_IO_ERROR) { + Lardon3DProjectDbProject new_project = { + .created_at = now.tv_sec, + .updated_at = now.tv_sec, + }; + (void)snprintf(new_project.stable_id, sizeof(new_project.stable_id), "%s", metadata.stable_id); + (void)snprintf(new_project.name, sizeof(new_project.name), "%s", metadata.name); + database_result = lardon3d_project_db_set_project(database, &new_project); + } + } + } + if (database_result == LARDON3D_PROJECT_DB_OK && rewrite_metadata + && !write_project_ini(state, project_path, metadata.name, metadata.stable_id)) { + database_result = LARDON3D_PROJECT_DB_IO_ERROR; + } + if (database_result != LARDON3D_PROJECT_DB_OK) { + if (database_result == LARDON3D_PROJECT_DB_CONSTRAINT) { + set_status(state, "Erreur : identité project.ini/project.db incohérente."); + } else { + set_database_status( + state, + database, + database_error, + "initialisation impossible" + ); + } + lardon3d_project_db_close(database); + if (!database_existed) { + (void)unlink(database_path); + } + cleanup_directories(&created); return false; } clear_catalog(state); state->project_loaded = true; + state->project_db = database; (void)copy_path( state->project_name, sizeof(state->project_name), - project_name + metadata.name + ); + (void)snprintf( + state->project_stable_id, + sizeof(state->project_stable_id), + "%s", + metadata.stable_id ); (void)copy_path( state->project_path, @@ -541,8 +820,185 @@ lardon3d_project_close(Lardon3DAppState *state) } clear_catalog(state); + lardon3d_project_db_close(state->project_db); + state->project_db = NULL; state->project_loaded = false; state->project_name[0] = '\0'; state->project_path[0] = '\0'; + state->project_stable_id[0] = '\0'; set_status(state, "Projet fermé."); } + +static bool +checkpoint_paths( + const Lardon3DAppState *state, + uint64_t task_id, + char relative[LARDON3D_PROJECT_DB_PATH_CAPACITY], + char absolute[PATH_MAX] +) +{ + int written = snprintf( + relative, + LARDON3D_PROJECT_DB_PATH_CAPACITY, + ".lardon3d/checkpoints/%" PRIu64 ".chk", + task_id + ); + return written > 0 + && (size_t)written < LARDON3D_PROJECT_DB_PATH_CAPACITY + && join_path(absolute, PATH_MAX, state->project_path, relative); +} + +Lardon3DProjectTaskCheckpointResult +lardon3d_project_checkpoint_task( + Lardon3DAppState *state, + const Lardon3DTask *task +) +{ + if (!state || !state->project_loaded || !state->project_db) { + return LARDON3D_PROJECT_TASK_CHECKPOINT_NO_PROJECT; + } + Lardon3DTaskDurableSnapshot snapshot; + if (!lardon3d_task_durable_snapshot(task, &snapshot) || snapshot.id == 0) { + return LARDON3D_PROJECT_TASK_CHECKPOINT_INVALID_TASK; + } + char relative[LARDON3D_PROJECT_DB_PATH_CAPACITY]; + char absolute[PATH_MAX]; + if (!checkpoint_paths(state, snapshot.id, relative, absolute)) { + return LARDON3D_PROJECT_TASK_CHECKPOINT_IO_ERROR; + } + Lardon3DTaskCheckpointResult saved = lardon3d_task_checkpoint_save( + absolute, + &snapshot + ); + if (saved == LARDON3D_TASK_CHECKPOINT_INVALID) { + return LARDON3D_PROJECT_TASK_CHECKPOINT_INVALID_TASK; + } + if (saved != LARDON3D_TASK_CHECKPOINT_OK + && saved != LARDON3D_TASK_CHECKPOINT_PUBLISHED_NOT_DURABLE) { + return LARDON3D_PROJECT_TASK_CHECKPOINT_IO_ERROR; + } + struct timespec now; + if (clock_gettime(CLOCK_REALTIME, &now) != 0) { + return LARDON3D_PROJECT_TASK_CHECKPOINT_DB_ERROR; + } + Lardon3DProjectDbCheckpoint checkpoint = { + .format_version = LARDON3D_TASK_CHECKPOINT_VERSION, + .durability = saved == LARDON3D_TASK_CHECKPOINT_OK + ? LARDON3D_DB_CHECKPOINT_DURABLE + : LARDON3D_DB_CHECKPOINT_PUBLISHED_NOT_DURABLE, + .updated_at = now.tv_sec, + }; + (void)snprintf(checkpoint.path, sizeof(checkpoint.path), "%s", relative); + Lardon3DProjectDbResult recorded = lardon3d_project_db_record_task( + state->project_db, + &snapshot, + &checkpoint, + now.tv_sec + ); + if (recorded == LARDON3D_PROJECT_DB_BUSY) { + return LARDON3D_PROJECT_TASK_CHECKPOINT_DB_BUSY; + } + if (recorded != LARDON3D_PROJECT_DB_OK) { + return LARDON3D_PROJECT_TASK_CHECKPOINT_DB_ERROR; + } + return saved == LARDON3D_TASK_CHECKPOINT_OK + ? LARDON3D_PROJECT_TASK_CHECKPOINT_OK + : LARDON3D_PROJECT_TASK_CHECKPOINT_PUBLISHED_NOT_DURABLE; +} + +static bool +coherent_recovery( + const Lardon3DProjectDbTask *database_task, + const Lardon3DTaskDurableSnapshot *snapshot +) +{ + return snapshot->id == database_task->task_id + && strcmp(snapshot->name, database_task->name) == 0 + && snapshot->saved_state == database_task->saved_state + && snapshot->recovery_state == database_task->recovery_state + && snapshot->progress == database_task->progress + && snapshot->sequence_count == database_task->sequence_count; +} + +Lardon3DProjectDbResult +lardon3d_project_list_recoverable( + Lardon3DAppState *state, + uint64_t after_task_id, + Lardon3DProjectRecoveryEntry *entries, + size_t capacity, + size_t *count +) +{ + if (count) { + *count = 0; + } + if (!state || !state->project_loaded || !state->project_db || !entries + || !count || capacity == 0 + || capacity > LARDON3D_PROJECT_DB_RECOVERY_PAGE_MAX) { + return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; + } + enum { RECOVERY_CHUNK = 8 }; + Lardon3DProjectDbTask tasks[RECOVERY_CHUNK]; + uint64_t cursor = after_task_id; + while (*count < capacity) { + size_t requested = capacity - *count; + if (requested > RECOVERY_CHUNK) { + requested = RECOVERY_CHUNK; + } + size_t task_count = 0; + Lardon3DProjectDbResult result = lardon3d_project_db_list_recoverable( + state->project_db, + cursor, + tasks, + requested, + &task_count + ); + if (result != LARDON3D_PROJECT_DB_OK) { + return result; + } + for (size_t index = 0; index < task_count; ++index) { + Lardon3DProjectRecoveryEntry *entry = &entries[*count]; + memset(entry, 0, sizeof(*entry)); + entry->task_id = tasks[index].task_id; + entry->durability = tasks[index].checkpoint.durability; + (void)snprintf(entry->name, sizeof(entry->name), "%s", tasks[index].name); + char relative[LARDON3D_PROJECT_DB_PATH_CAPACITY]; + char absolute[PATH_MAX]; + if (!checkpoint_paths(state, entry->task_id, relative, absolute) + || strcmp(relative, tasks[index].checkpoint.path) != 0) { + entry->status = LARDON3D_PROJECT_RECOVERY_INVALID_CHECKPOINT; + } else { + uint32_t version = 0; + Lardon3DTaskCheckpointResult loaded = lardon3d_task_checkpoint_load( + absolute, + &entry->snapshot, + &version + ); + if (loaded == LARDON3D_TASK_CHECKPOINT_NOT_FOUND) { + entry->status = LARDON3D_PROJECT_RECOVERY_MISSING_CHECKPOINT; + } else if (loaded == LARDON3D_TASK_CHECKPOINT_UNSUPPORTED_VERSION) { + entry->status = LARDON3D_PROJECT_RECOVERY_UNSUPPORTED_CHECKPOINT; + } else if (loaded == LARDON3D_TASK_CHECKPOINT_INVALID) { + entry->status = LARDON3D_PROJECT_RECOVERY_INVALID_CHECKPOINT; + } else if (loaded != LARDON3D_TASK_CHECKPOINT_OK + || version != tasks[index].checkpoint.format_version + || !coherent_recovery(&tasks[index], &entry->snapshot)) { + entry->status = loaded == LARDON3D_TASK_CHECKPOINT_IO_ERROR + ? LARDON3D_PROJECT_RECOVERY_CHECKPOINT_IO_ERROR + : LARDON3D_PROJECT_RECOVERY_INVALID_CHECKPOINT; + } else { + entry->status = entry->durability + == LARDON3D_DB_CHECKPOINT_PUBLISHED_NOT_DURABLE + ? LARDON3D_PROJECT_RECOVERABLE_PUBLISHED_NOT_DURABLE + : LARDON3D_PROJECT_RECOVERABLE; + } + } + cursor = entry->task_id; + ++*count; + } + if (task_count < requested) { + break; + } + } + return LARDON3D_PROJECT_DB_OK; +} diff --git a/src/project_db.c b/src/project_db.c new file mode 100644 index 0000000..eb44df5 --- /dev/null +++ b/src/project_db.c @@ -0,0 +1,504 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +struct Lardon3DProjectDb { + sqlite3 *connection; + pthread_mutex_t mutex; + char error[LARDON3D_PROJECT_DB_ERROR_CAPACITY]; +}; + +static const char schema_v1[] = + "CREATE TABLE metadata(key TEXT PRIMARY KEY,value INTEGER NOT NULL);" + "INSERT INTO metadata(key,value) VALUES('schema_version',1);" + "CREATE TABLE project(singleton INTEGER PRIMARY KEY CHECK(singleton=1)," + "stable_id TEXT NOT NULL UNIQUE,name TEXT NOT NULL,created_at INTEGER NOT NULL,updated_at INTEGER NOT NULL);" + "CREATE TABLE tasks(task_id INTEGER PRIMARY KEY CHECK(task_id>0),name TEXT NOT NULL," + "saved_state INTEGER NOT NULL CHECK(saved_state BETWEEN 0 AND 5)," + "recovery_state INTEGER NOT NULL CHECK(recovery_state BETWEEN 0 AND 5)," + "progress INTEGER NOT NULL CHECK(progress BETWEEN 0 AND 100),sequence_count INTEGER NOT NULL CHECK(sequence_count>=0)," + "started_sec INTEGER NOT NULL,started_nsec INTEGER NOT NULL CHECK(started_nsec BETWEEN 0 AND 999999999)," + "finished_sec INTEGER NOT NULL,finished_nsec INTEGER NOT NULL CHECK(finished_nsec BETWEEN 0 AND 999999999),updated_at INTEGER NOT NULL);" + "CREATE INDEX tasks_recovery_state_idx ON tasks(recovery_state,task_id);" + "CREATE TABLE checkpoints(task_id INTEGER PRIMARY KEY REFERENCES tasks(task_id) ON DELETE CASCADE," + "path TEXT NOT NULL,format_version INTEGER NOT NULL CHECK(format_version>0)," + "durability INTEGER NOT NULL CHECK(durability BETWEEN 0 AND 1),updated_at INTEGER NOT NULL);" + "CREATE TABLE artifacts(artifact_id TEXT PRIMARY KEY,kind TEXT NOT NULL,path TEXT NOT NULL," + "state INTEGER NOT NULL CHECK(state BETWEEN 0 AND 1),size_bytes INTEGER NOT NULL CHECK(size_bytes>=0)," + "producer_task_id INTEGER REFERENCES tasks(task_id),created_at INTEGER NOT NULL,updated_at INTEGER NOT NULL);" + "CREATE INDEX artifacts_state_idx ON artifacts(state,artifact_id);" + "CREATE INDEX artifacts_producer_idx ON artifacts(producer_task_id);"; + +static void +copy_error(char destination[LARDON3D_PROJECT_DB_ERROR_CAPACITY], const char *text) +{ + if (destination) { + (void)snprintf(destination, LARDON3D_PROJECT_DB_ERROR_CAPACITY, "%s", text ? text : ""); + } +} + +static Lardon3DProjectDbResult +sqlite_result(Lardon3DProjectDb *database, int code, const char *context) +{ + (void)snprintf(database->error, sizeof(database->error), "%s: %s", context, sqlite3_errmsg(database->connection)); + if (code == SQLITE_BUSY || code == SQLITE_LOCKED) return LARDON3D_PROJECT_DB_BUSY; + if (code == SQLITE_CONSTRAINT) return LARDON3D_PROJECT_DB_CONSTRAINT; + if (code == SQLITE_CORRUPT || code == SQLITE_NOTADB) return LARDON3D_PROJECT_DB_CORRUPT; + return LARDON3D_PROJECT_DB_IO_ERROR; +} + +static bool +bounded_text(const char *text, size_t capacity, bool allow_empty) +{ + if (!text) return false; + size_t length = strnlen(text, capacity); + return length < capacity && (allow_empty || length > 0); +} + +static bool +valid_task_id(uint64_t id) +{ + return id > 0 && id <= INT64_MAX; +} + +static bool +valid_state(Lardon3DTaskState state) +{ + return state >= TASK_PENDING && state <= TASK_COMPLETED; +} + +static bool +database_time(sqlite3_int64 value, time_t *output) +{ + if (value < 0) return false; + time_t converted = (time_t)value; + if (converted < 0 || (uint64_t)converted != (uint64_t)value) return false; + *output = converted; + return true; +} + +static Lardon3DProjectDbResult +execute(Lardon3DProjectDb *database, const char *sql, const char *context) +{ + char *message = NULL; + int code = sqlite3_exec(database->connection, sql, NULL, NULL, &message); + if (code == SQLITE_OK) return LARDON3D_PROJECT_DB_OK; + (void)snprintf(database->error, sizeof(database->error), "%s: %s", context, message ? message : sqlite3_errmsg(database->connection)); + sqlite3_free(message); + return sqlite_result(database, code, context); +} + +static Lardon3DProjectDbResult +prepare(Lardon3DProjectDb *database, const char *sql, sqlite3_stmt **statement) +{ + int code = sqlite3_prepare_v2(database->connection, sql, -1, statement, NULL); + return code == SQLITE_OK ? LARDON3D_PROJECT_DB_OK : sqlite_result(database, code, "prepare"); +} + +static bool +table_exists(sqlite3 *connection, const char *name) +{ + sqlite3_stmt *statement = NULL; + if (sqlite3_prepare_v2(connection, "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1", -1, &statement, NULL) != SQLITE_OK) return false; + (void)sqlite3_bind_text(statement, 1, name, -1, SQLITE_STATIC); + bool exists = sqlite3_step(statement) == SQLITE_ROW; + (void)sqlite3_finalize(statement); + return exists; +} + +static Lardon3DProjectDbResult +migrate(Lardon3DProjectDb *database, unsigned int from_version) +{ + if (from_version > LARDON3D_PROJECT_DB_SCHEMA_VERSION) { + copy_error(database->error, "Version de schéma future non supportée."); + return LARDON3D_PROJECT_DB_UNSUPPORTED_SCHEMA; + } + if (from_version == 1) return LARDON3D_PROJECT_DB_OK; + if (from_version != 0) return LARDON3D_PROJECT_DB_CORRUPT; + Lardon3DProjectDbResult result = execute(database, "BEGIN IMMEDIATE", "begin migration"); + if (result == LARDON3D_PROJECT_DB_OK) result = execute(database, schema_v1, "create schema v1"); + if (result == LARDON3D_PROJECT_DB_OK) result = execute(database, "COMMIT", "commit migration"); + if (result != LARDON3D_PROJECT_DB_OK) (void)execute(database, "ROLLBACK", "rollback migration"); + return result; +} + +static Lardon3DProjectDbResult +read_schema_version(Lardon3DProjectDb *database, unsigned int *version) +{ + if (!table_exists(database->connection, "metadata")) { + sqlite3_stmt *statement = NULL; + int code = sqlite3_prepare_v2(database->connection, "SELECT 1 FROM sqlite_master WHERE type='table' LIMIT 1", -1, &statement, NULL); + if (code != SQLITE_OK) return sqlite_result(database, code, "inspect schema"); + code = sqlite3_step(statement); + bool empty = code == SQLITE_DONE; + (void)sqlite3_finalize(statement); + if (!empty) { + copy_error(database->error, "Base incohérente sans metadata."); + return LARDON3D_PROJECT_DB_CORRUPT; + } + *version = 0; + return LARDON3D_PROJECT_DB_OK; + } + sqlite3_stmt *statement = NULL; + Lardon3DProjectDbResult result = prepare(database, "SELECT value FROM metadata WHERE key='schema_version'", &statement); + if (result != LARDON3D_PROJECT_DB_OK) return result; + int code = sqlite3_step(statement); + if (code != SQLITE_ROW || sqlite3_column_type(statement, 0) != SQLITE_INTEGER) { + (void)sqlite3_finalize(statement); + copy_error(database->error, "schema_version absente ou invalide."); + return LARDON3D_PROJECT_DB_CORRUPT; + } + sqlite3_int64 value = sqlite3_column_int64(statement, 0); + code = sqlite3_step(statement); + (void)sqlite3_finalize(statement); + if (code != SQLITE_DONE || value < 1 || value > UINT_MAX) return LARDON3D_PROJECT_DB_CORRUPT; + *version = (unsigned int)value; + return LARDON3D_PROJECT_DB_OK; +} + +Lardon3DProjectDbResult +lardon3d_project_db_open(const char *path, Lardon3DProjectDb **output, char error[LARDON3D_PROJECT_DB_ERROR_CAPACITY]) +{ + if (output) *output = NULL; + if (!path || !path[0] || !output || strnlen(path, LARDON3D_PROJECT_DB_PATH_CAPACITY) >= LARDON3D_PROJECT_DB_PATH_CAPACITY) { + copy_error(error, "Chemin de base invalide."); + return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; + } + Lardon3DProjectDb *database = calloc(1, sizeof(*database)); + if (!database) { copy_error(error, "Allocation impossible."); return LARDON3D_PROJECT_DB_IO_ERROR; } + if (pthread_mutex_init(&database->mutex, NULL) != 0) { free(database); copy_error(error, "Mutex impossible."); return LARDON3D_PROJECT_DB_IO_ERROR; } + int code = sqlite3_open_v2(path, &database->connection, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_NOMUTEX, NULL); + if (code != SQLITE_OK) { + copy_error(error, database->connection ? sqlite3_errmsg(database->connection) : "Ouverture SQLite impossible."); + if (database->connection) (void)sqlite3_close(database->connection); + (void)pthread_mutex_destroy(&database->mutex); free(database); + return code == SQLITE_CANTOPEN ? LARDON3D_PROJECT_DB_IO_ERROR : LARDON3D_PROJECT_DB_CORRUPT; + } + Lardon3DProjectDbResult result = execute(database, "PRAGMA foreign_keys=ON;PRAGMA journal_mode=DELETE;PRAGMA synchronous=FULL;PRAGMA busy_timeout=5000", "configure SQLite"); + unsigned int version = 0; + if (result == LARDON3D_PROJECT_DB_OK) result = read_schema_version(database, &version); + if (result == LARDON3D_PROJECT_DB_OK) result = migrate(database, version); + if (result == LARDON3D_PROJECT_DB_OK && version == 1) { + const char *required[] = {"project", "tasks", "checkpoints", "artifacts"}; + for (size_t index = 0; index < 4 && result == LARDON3D_PROJECT_DB_OK; ++index) { + if (!table_exists(database->connection, required[index])) { + copy_error(database->error, "Schéma v1 incomplet."); + result = LARDON3D_PROJECT_DB_CORRUPT; + } + } + } + if (result != LARDON3D_PROJECT_DB_OK) { + copy_error(error, database->error); (void)sqlite3_close(database->connection); + (void)pthread_mutex_destroy(&database->mutex); free(database); return result; + } + database->error[0] = '\0'; copy_error(error, ""); *output = database; + return LARDON3D_PROJECT_DB_OK; +} + +void lardon3d_project_db_close(Lardon3DProjectDb *database) +{ + if (!database) return; + (void)pthread_mutex_lock(&database->mutex); + (void)sqlite3_close(database->connection); + (void)pthread_mutex_unlock(&database->mutex); + (void)pthread_mutex_destroy(&database->mutex); + free(database); +} + +bool +lardon3d_project_db_last_error(Lardon3DProjectDb *database, char error[LARDON3D_PROJECT_DB_ERROR_CAPACITY]) +{ + if (!database || !error) return false; + (void)pthread_mutex_lock(&database->mutex); + copy_error(error, database->error); + (void)pthread_mutex_unlock(&database->mutex); + return true; +} +unsigned int lardon3d_project_db_schema_version(Lardon3DProjectDb *database) { return database ? 1U : 0U; } + +static Lardon3DProjectDbResult +step_done(Lardon3DProjectDb *database, sqlite3_stmt *statement, const char *context) +{ + int code = sqlite3_step(statement); + if (code != SQLITE_DONE) { + Lardon3DProjectDbResult result = sqlite_result(database, code, context); + (void)sqlite3_finalize(statement); + return result; + } + code = sqlite3_finalize(statement); + return code == SQLITE_OK ? LARDON3D_PROJECT_DB_OK : sqlite_result(database, code, context); +} + +static bool +copy_column(sqlite3_stmt *statement, int column, char *destination, size_t capacity) +{ + if (sqlite3_column_type(statement, column) != SQLITE_TEXT) return false; + int bytes = sqlite3_column_bytes(statement, column); + const unsigned char *text = sqlite3_column_text(statement, column); + if (!text || bytes < 0 || (size_t)bytes >= capacity) return false; + memcpy(destination, text, (size_t)bytes); + destination[bytes] = '\0'; + return true; +} + +Lardon3DProjectDbResult +lardon3d_project_db_set_project(Lardon3DProjectDb *database, const Lardon3DProjectDbProject *project) +{ + if (!database || !project + || !bounded_text(project->stable_id, sizeof(project->stable_id), false) + || !bounded_text(project->name, sizeof(project->name), false) + || project->created_at < 0 || project->updated_at < project->created_at) return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; + (void)pthread_mutex_lock(&database->mutex); + sqlite3_stmt *statement = NULL; + Lardon3DProjectDbResult result = prepare(database, + "INSERT INTO project(singleton,stable_id,name,created_at,updated_at) VALUES(1,?1,?2,?3,?4) " + "ON CONFLICT(singleton) DO UPDATE SET name=excluded.name,updated_at=excluded.updated_at " + "WHERE project.stable_id=excluded.stable_id AND project.created_at=excluded.created_at", &statement); + if (result == LARDON3D_PROJECT_DB_OK) { + (void)sqlite3_bind_text(statement, 1, project->stable_id, -1, SQLITE_TRANSIENT); + (void)sqlite3_bind_text(statement, 2, project->name, -1, SQLITE_TRANSIENT); + (void)sqlite3_bind_int64(statement, 3, project->created_at); + (void)sqlite3_bind_int64(statement, 4, project->updated_at); + result = step_done(database, statement, "set project"); + if (result == LARDON3D_PROJECT_DB_OK && sqlite3_changes(database->connection) != 1) { + copy_error(database->error, "Identité de projet contradictoire."); + result = LARDON3D_PROJECT_DB_CONSTRAINT; + } + } + (void)pthread_mutex_unlock(&database->mutex); + return result; +} + +Lardon3DProjectDbResult +lardon3d_project_db_get_project(Lardon3DProjectDb *database, Lardon3DProjectDbProject *project) +{ + if (!database || !project) return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; + (void)pthread_mutex_lock(&database->mutex); + sqlite3_stmt *statement = NULL; + Lardon3DProjectDbResult result = prepare(database, "SELECT stable_id,name,created_at,updated_at FROM project WHERE singleton=1", &statement); + if (result == LARDON3D_PROJECT_DB_OK) { + int code = sqlite3_step(statement); + if (code == SQLITE_DONE) result = LARDON3D_PROJECT_DB_NOT_FOUND; + else if (code != SQLITE_ROW || !copy_column(statement, 0, project->stable_id, sizeof(project->stable_id)) + || !copy_column(statement, 1, project->name, sizeof(project->name))) result = LARDON3D_PROJECT_DB_CORRUPT; + else { project->created_at = sqlite3_column_int64(statement, 2); project->updated_at = sqlite3_column_int64(statement, 3); } + (void)sqlite3_finalize(statement); + } + (void)pthread_mutex_unlock(&database->mutex); + return result; +} + +static bool +valid_checkpoint(const Lardon3DProjectDbCheckpoint *checkpoint) +{ + return checkpoint && bounded_text(checkpoint->path, sizeof(checkpoint->path), false) + && checkpoint->format_version > 0 && checkpoint->updated_at >= 0 + && checkpoint->durability >= LARDON3D_DB_CHECKPOINT_DURABLE + && checkpoint->durability <= LARDON3D_DB_CHECKPOINT_PUBLISHED_NOT_DURABLE; +} + +static bool +valid_durable_task(const Lardon3DTaskDurableSnapshot *snapshot, int64_t updated_at) +{ + return snapshot && valid_task_id(snapshot->id) + && bounded_text(snapshot->name, sizeof(snapshot->name), false) + && valid_state(snapshot->saved_state) && valid_state(snapshot->recovery_state) + && snapshot->recovery_state == ((snapshot->saved_state == TASK_RUNNING || snapshot->saved_state == TASK_PAUSED) ? TASK_PENDING : snapshot->saved_state) + && snapshot->progress <= 100 && snapshot->sequence_count <= INT_MAX + && snapshot->started_at.tv_sec >= 0 && snapshot->started_at.tv_sec <= INT64_MAX + && snapshot->started_at.tv_nsec >= 0 && snapshot->started_at.tv_nsec < 1000000000L + && snapshot->finished_at.tv_sec >= 0 && snapshot->finished_at.tv_sec <= INT64_MAX + && snapshot->finished_at.tv_nsec >= 0 && snapshot->finished_at.tv_nsec < 1000000000L + && updated_at >= 0; +} + +Lardon3DProjectDbResult +lardon3d_project_db_record_task(Lardon3DProjectDb *database, const Lardon3DTaskDurableSnapshot *snapshot, + const Lardon3DProjectDbCheckpoint *checkpoint, int64_t updated_at) +{ + if (!database || !valid_durable_task(snapshot, updated_at) || (checkpoint && !valid_checkpoint(checkpoint))) return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; +#ifdef LARDON3D_PROJECT_DB_TESTING + const char *forced_busy = getenv("LARDON3D_TEST_PROJECT_DB_BUSY_CHECKPOINT"); + if (forced_busy && checkpoint && strcmp(forced_busy, "1") == 0) { + copy_error(database->error, "Verrou DB injecté pour test."); + return LARDON3D_PROJECT_DB_BUSY; + } +#endif + (void)pthread_mutex_lock(&database->mutex); + Lardon3DProjectDbResult result = execute(database, "BEGIN IMMEDIATE", "begin task record"); + sqlite3_stmt *statement = NULL; + if (result == LARDON3D_PROJECT_DB_OK) result = prepare(database, + "INSERT INTO tasks(task_id,name,saved_state,recovery_state,progress,sequence_count,started_sec,started_nsec,finished_sec,finished_nsec,updated_at)" + "VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11) ON CONFLICT(task_id) DO UPDATE SET name=excluded.name,saved_state=excluded.saved_state," + "recovery_state=excluded.recovery_state,progress=excluded.progress,sequence_count=excluded.sequence_count,started_sec=excluded.started_sec," + "started_nsec=excluded.started_nsec,finished_sec=excluded.finished_sec,finished_nsec=excluded.finished_nsec,updated_at=excluded.updated_at", &statement); + if (result == LARDON3D_PROJECT_DB_OK) { + (void)sqlite3_bind_int64(statement, 1, (sqlite3_int64)snapshot->id); (void)sqlite3_bind_text(statement, 2, snapshot->name, -1, SQLITE_TRANSIENT); + (void)sqlite3_bind_int(statement, 3, (int)snapshot->saved_state); (void)sqlite3_bind_int(statement, 4, (int)snapshot->recovery_state); + (void)sqlite3_bind_int(statement, 5, (int)snapshot->progress); (void)sqlite3_bind_int(statement, 6, (int)snapshot->sequence_count); + (void)sqlite3_bind_int64(statement, 7, snapshot->started_at.tv_sec); (void)sqlite3_bind_int64(statement, 8, snapshot->started_at.tv_nsec); + (void)sqlite3_bind_int64(statement, 9, snapshot->finished_at.tv_sec); (void)sqlite3_bind_int64(statement, 10, snapshot->finished_at.tv_nsec); + (void)sqlite3_bind_int64(statement, 11, updated_at); result = step_done(database, statement, "upsert task"); + } + if (result == LARDON3D_PROJECT_DB_OK && checkpoint) { +#ifdef LARDON3D_PROJECT_DB_TESTING + const char *forced_failure = getenv("LARDON3D_TEST_PROJECT_DB_FAIL_CHECKPOINT"); + if (forced_failure && strcmp(forced_failure, "1") == 0) { + result = execute(database, "INSERT INTO missing_test_table VALUES(1)", "forced checkpoint failure"); + } +#endif + } + if (result == LARDON3D_PROJECT_DB_OK && checkpoint) { + result = prepare(database, "INSERT INTO checkpoints(task_id,path,format_version,durability,updated_at) VALUES(?1,?2,?3,?4,?5) " + "ON CONFLICT(task_id) DO UPDATE SET path=excluded.path,format_version=excluded.format_version,durability=excluded.durability,updated_at=excluded.updated_at", &statement); + if (result == LARDON3D_PROJECT_DB_OK) { + (void)sqlite3_bind_int64(statement, 1, (sqlite3_int64)snapshot->id); (void)sqlite3_bind_text(statement, 2, checkpoint->path, -1, SQLITE_TRANSIENT); + (void)sqlite3_bind_int64(statement, 3, checkpoint->format_version); (void)sqlite3_bind_int(statement, 4, (int)checkpoint->durability); + (void)sqlite3_bind_int64(statement, 5, checkpoint->updated_at); result = step_done(database, statement, "upsert checkpoint"); + } + } + if (result == LARDON3D_PROJECT_DB_OK) result = execute(database, "COMMIT", "commit task record"); + if (result != LARDON3D_PROJECT_DB_OK) (void)execute(database, "ROLLBACK", "rollback task record"); + (void)pthread_mutex_unlock(&database->mutex); + return result; +} + +static bool +read_task(sqlite3_stmt *statement, Lardon3DProjectDbTask *task) +{ + memset(task, 0, sizeof(*task)); + sqlite3_int64 id = sqlite3_column_int64(statement, 0); + int progress = sqlite3_column_int(statement, 4); + sqlite3_int64 sequence_count = sqlite3_column_int64(statement, 5); + sqlite3_int64 started_nsec = sqlite3_column_int64(statement, 7); + sqlite3_int64 finished_nsec = sqlite3_column_int64(statement, 9); + if (id <= 0 || !copy_column(statement, 1, task->name, sizeof(task->name))) return false; + task->task_id = (uint64_t)id; task->saved_state = (Lardon3DTaskState)sqlite3_column_int(statement, 2); + task->recovery_state = (Lardon3DTaskState)sqlite3_column_int(statement, 3); + if (progress < 0 || progress > 100 || sequence_count < 0 || sequence_count > UINT_MAX + || started_nsec < 0 || started_nsec >= 1000000000 || finished_nsec < 0 || finished_nsec >= 1000000000 + || !database_time(sqlite3_column_int64(statement, 6), &task->started_at.tv_sec) + || !database_time(sqlite3_column_int64(statement, 8), &task->finished_at.tv_sec)) return false; + task->progress = (unsigned int)progress; task->sequence_count = (unsigned int)sequence_count; + task->started_at.tv_nsec = (long)started_nsec; task->finished_at.tv_nsec = (long)finished_nsec; task->updated_at = sqlite3_column_int64(statement, 10); + if (sqlite3_column_type(statement, 11) != SQLITE_NULL) { + task->has_checkpoint = true; + if (!copy_column(statement, 11, task->checkpoint.path, sizeof(task->checkpoint.path))) return false; + sqlite3_int64 format_version = sqlite3_column_int64(statement, 12); + if (format_version <= 0 || format_version > UINT32_MAX) return false; + task->checkpoint.format_version = (uint32_t)format_version; + task->checkpoint.durability = (Lardon3DProjectDbCheckpointDurability)sqlite3_column_int(statement, 13); + task->checkpoint.updated_at = sqlite3_column_int64(statement, 14); + if (task->checkpoint.durability < LARDON3D_DB_CHECKPOINT_DURABLE + || task->checkpoint.durability > LARDON3D_DB_CHECKPOINT_PUBLISHED_NOT_DURABLE) return false; + } + return valid_state(task->saved_state) && valid_state(task->recovery_state) && task->progress <= 100; +} + +static const char task_select[] = "SELECT t.task_id,t.name,t.saved_state,t.recovery_state,t.progress,t.sequence_count,t.started_sec,t.started_nsec," + "t.finished_sec,t.finished_nsec,t.updated_at,c.path,c.format_version,c.durability,c.updated_at FROM tasks t LEFT JOIN checkpoints c ON c.task_id=t.task_id "; + +Lardon3DProjectDbResult +lardon3d_project_db_load_task(Lardon3DProjectDb *database, uint64_t task_id, Lardon3DProjectDbTask *task) +{ + if (!database || !valid_task_id(task_id) || !task) return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; + (void)pthread_mutex_lock(&database->mutex); sqlite3_stmt *statement = NULL; + char sql[768]; (void)snprintf(sql, sizeof(sql), "%s WHERE t.task_id=?1", task_select); + Lardon3DProjectDbResult result = prepare(database, sql, &statement); + if (result == LARDON3D_PROJECT_DB_OK) { (void)sqlite3_bind_int64(statement, 1, (sqlite3_int64)task_id); int code = sqlite3_step(statement); + if (code == SQLITE_DONE) result = LARDON3D_PROJECT_DB_NOT_FOUND; else if (code != SQLITE_ROW || !read_task(statement, task)) result = LARDON3D_PROJECT_DB_CORRUPT; + (void)sqlite3_finalize(statement); } + (void)pthread_mutex_unlock(&database->mutex); return result; +} + +Lardon3DProjectDbResult +lardon3d_project_db_list_recoverable(Lardon3DProjectDb *database, uint64_t after_task_id, Lardon3DProjectDbTask *tasks, size_t capacity, size_t *count) +{ + if (count) *count = 0; + if (!database || !tasks || !count || after_task_id > INT64_MAX || capacity == 0 || capacity > LARDON3D_PROJECT_DB_RECOVERY_PAGE_MAX) return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; + (void)pthread_mutex_lock(&database->mutex); sqlite3_stmt *statement = NULL; char sql[896]; + (void)snprintf(sql, sizeof(sql), "%s WHERE t.recovery_state=?1 AND c.task_id IS NOT NULL AND t.task_id>?2 ORDER BY t.task_id LIMIT ?3", task_select); + Lardon3DProjectDbResult result = prepare(database, sql, &statement); + if (result == LARDON3D_PROJECT_DB_OK) { + (void)sqlite3_bind_int(statement, 1, TASK_PENDING); (void)sqlite3_bind_int64(statement, 2, (sqlite3_int64)after_task_id); (void)sqlite3_bind_int64(statement, 3, (sqlite3_int64)capacity); + int code = SQLITE_DONE; while (*count < capacity && (code = sqlite3_step(statement)) == SQLITE_ROW) { if (!read_task(statement, &tasks[*count])) { result = LARDON3D_PROJECT_DB_CORRUPT; break; } ++*count; } + if (result == LARDON3D_PROJECT_DB_OK && *count < capacity && code != SQLITE_DONE) result = sqlite_result(database, code, "list recoverable"); + (void)sqlite3_finalize(statement); + } + (void)pthread_mutex_unlock(&database->mutex); return result; +} + +Lardon3DProjectDbResult +lardon3d_project_db_create_artifact(Lardon3DProjectDb *database, const Lardon3DProjectDbArtifact *artifact) +{ + if (!database || !artifact || !bounded_text(artifact->artifact_id, sizeof(artifact->artifact_id), false) + || !bounded_text(artifact->kind, sizeof(artifact->kind), false) || !bounded_text(artifact->path, sizeof(artifact->path), false) + || artifact->state != LARDON3D_DB_ARTIFACT_STAGED || artifact->size_bytes != 0 || artifact->created_at < 0 + || artifact->updated_at < artifact->created_at || (artifact->has_producer_task && !valid_task_id(artifact->producer_task_id))) return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; + (void)pthread_mutex_lock(&database->mutex); sqlite3_stmt *statement = NULL; + Lardon3DProjectDbResult result = prepare(database, "INSERT INTO artifacts(artifact_id,kind,path,state,size_bytes,producer_task_id,created_at,updated_at) VALUES(?1,?2,?3,0,0,?4,?5,?6)", &statement); + if (result == LARDON3D_PROJECT_DB_OK) { (void)sqlite3_bind_text(statement, 1, artifact->artifact_id, -1, SQLITE_TRANSIENT); (void)sqlite3_bind_text(statement, 2, artifact->kind, -1, SQLITE_TRANSIENT); + (void)sqlite3_bind_text(statement, 3, artifact->path, -1, SQLITE_TRANSIENT); if (artifact->has_producer_task) (void)sqlite3_bind_int64(statement, 4, (sqlite3_int64)artifact->producer_task_id); else (void)sqlite3_bind_null(statement, 4); + (void)sqlite3_bind_int64(statement, 5, artifact->created_at); (void)sqlite3_bind_int64(statement, 6, artifact->updated_at); result = step_done(database, statement, "create artifact"); } + (void)pthread_mutex_unlock(&database->mutex); return result; +} + +Lardon3DProjectDbResult +lardon3d_project_db_mark_artifact_ready(Lardon3DProjectDb *database, const char *artifact_id, int64_t updated_at) +{ + if (!database || !bounded_text(artifact_id, LARDON3D_PROJECT_DB_ID_CAPACITY, false) || updated_at < 0) return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; + char path[LARDON3D_PROJECT_DB_PATH_CAPACITY]; Lardon3DProjectDbArtifact artifact; + Lardon3DProjectDbResult result = lardon3d_project_db_load_artifact(database, artifact_id, &artifact); + if (result != LARDON3D_PROJECT_DB_OK) return result; (void)snprintf(path, sizeof(path), "%s", artifact.path); + struct stat information; if (stat(path, &information) != 0 || !S_ISREG(information.st_mode) || information.st_size < 0) return LARDON3D_PROJECT_DB_IO_ERROR; + (void)pthread_mutex_lock(&database->mutex); sqlite3_stmt *statement = NULL; + result = prepare(database, "UPDATE artifacts SET state=1,size_bytes=?1,updated_at=?2 WHERE artifact_id=?3 AND path=?4", &statement); + if (result == LARDON3D_PROJECT_DB_OK) { (void)sqlite3_bind_int64(statement, 1, information.st_size); (void)sqlite3_bind_int64(statement, 2, updated_at); + (void)sqlite3_bind_text(statement, 3, artifact_id, -1, SQLITE_TRANSIENT); (void)sqlite3_bind_text(statement, 4, path, -1, SQLITE_TRANSIENT); result = step_done(database, statement, "mark artifact ready"); + if (result == LARDON3D_PROJECT_DB_OK && sqlite3_changes(database->connection) != 1) result = LARDON3D_PROJECT_DB_NOT_FOUND; } + (void)pthread_mutex_unlock(&database->mutex); return result; +} + +Lardon3DProjectDbResult +lardon3d_project_db_load_artifact(Lardon3DProjectDb *database, const char *artifact_id, Lardon3DProjectDbArtifact *artifact) +{ + if (!database || !artifact || !bounded_text(artifact_id, LARDON3D_PROJECT_DB_ID_CAPACITY, false)) return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; + (void)pthread_mutex_lock(&database->mutex); sqlite3_stmt *statement = NULL; + Lardon3DProjectDbResult result = prepare(database, "SELECT artifact_id,kind,path,state,size_bytes,producer_task_id,created_at,updated_at FROM artifacts WHERE artifact_id=?1", &statement); + if (result == LARDON3D_PROJECT_DB_OK) { (void)sqlite3_bind_text(statement, 1, artifact_id, -1, SQLITE_TRANSIENT); int code = sqlite3_step(statement); + if (code == SQLITE_DONE) result = LARDON3D_PROJECT_DB_NOT_FOUND; + else if (code != SQLITE_ROW || !copy_column(statement, 0, artifact->artifact_id, sizeof(artifact->artifact_id)) || !copy_column(statement, 1, artifact->kind, sizeof(artifact->kind)) || !copy_column(statement, 2, artifact->path, sizeof(artifact->path))) result = LARDON3D_PROJECT_DB_CORRUPT; + else { sqlite3_int64 size = sqlite3_column_int64(statement, 4); sqlite3_int64 producer = sqlite3_column_int64(statement, 5); + artifact->state = (Lardon3DProjectDbArtifactState)sqlite3_column_int(statement, 3); artifact->size_bytes = size >= 0 ? (uint64_t)size : 0; + artifact->has_producer_task = sqlite3_column_type(statement, 5) != SQLITE_NULL; artifact->producer_task_id = artifact->has_producer_task && producer > 0 ? (uint64_t)producer : 0; + artifact->created_at = sqlite3_column_int64(statement, 6); artifact->updated_at = sqlite3_column_int64(statement, 7); } + (void)sqlite3_finalize(statement); } + if (result == LARDON3D_PROJECT_DB_OK && (artifact->state < LARDON3D_DB_ARTIFACT_STAGED + || artifact->state > LARDON3D_DB_ARTIFACT_READY || (artifact->has_producer_task && artifact->producer_task_id == 0))) result = LARDON3D_PROJECT_DB_CORRUPT; + (void)pthread_mutex_unlock(&database->mutex); return result; +} + +#ifdef LARDON3D_PROJECT_DB_TESTING +Lardon3DProjectDbResult +lardon3d_project_db_test_orphan_checkpoint(Lardon3DProjectDb *database) +{ + if (!database) return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; + (void)pthread_mutex_lock(&database->mutex); + sqlite3_stmt *statement = NULL; + Lardon3DProjectDbResult result = prepare(database, + "INSERT INTO checkpoints(task_id,path,format_version,durability,updated_at) VALUES(9223372036854775807,'orphan',1,0,0)", + &statement); + if (result == LARDON3D_PROJECT_DB_OK) result = step_done(database, statement, "orphan checkpoint"); + (void)pthread_mutex_unlock(&database->mutex); + return result; +} +#endif diff --git a/src/task_checkpoint.c b/src/task_checkpoint.c index eb743cc..f35ba64 100644 --- a/src/task_checkpoint.c +++ b/src/task_checkpoint.c @@ -291,6 +291,16 @@ lardon3d_task_checkpoint_save( if (!path || !path[0] || !valid_snapshot(snapshot)) { return LARDON3D_TASK_CHECKPOINT_INVALID; } +#ifdef LARDON3D_CHECKPOINT_TESTING + const char *forced_prepublication_failure = getenv( + "LARDON3D_TEST_CHECKPOINT_PREPUBLICATION_FAILURE" + ); + if (forced_prepublication_failure + && strcmp(forced_prepublication_failure, "1") == 0) { + errno = EIO; + return LARDON3D_TASK_CHECKPOINT_IO_ERROR; + } +#endif char temporary[4096]; int length = snprintf(temporary, sizeof(temporary), "%s.tmp.XXXXXX", path); if (length < 0 || (size_t)length >= sizeof(temporary)) { diff --git a/tests/test_project.c b/tests/test_project.c new file mode 100644 index 0000000..5f8035d --- /dev/null +++ b/tests/test_project.c @@ -0,0 +1,211 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#define CHECK(condition) do { if (!(condition)) { \ + (void)fprintf(stderr, "Échec ligne %d : %s\n", __LINE__, #condition); return false; \ +} } while (0) + +static bool +unused_callback(Lardon3DTask *task, void *userdata) +{ + (void)task; + (void)userdata; + return true; +} + +static bool +write_ini(const char *path, const char *name, const char *stable_id, unsigned int version) +{ + FILE *file = fopen(path, "w"); + if (!file) return false; + bool written = version == 1 + ? fprintf(file, "[project]\nname=%s\nversion=1\n", name) > 0 + : fprintf(file, "[project]\nname=%s\nstable_id=%s\nversion=2\n", name, stable_id) > 0; + return fclose(file) == 0 && written; +} + +typedef struct { + Lardon3DAppState *state; + Lardon3DTask *task; + Lardon3DProjectTaskCheckpointResult result; +} CheckpointThread; + +static void * +checkpoint_thread(void *userdata) +{ + CheckpointThread *context = userdata; + context->result = lardon3d_project_checkpoint_task(context->state, context->task); + return NULL; +} + +static bool +run_test(void) +{ + char root[] = "/tmp/lardon3d-project-life-XXXXXX"; + CHECK(mkdtemp(root)); + CHECK(setenv("LARDON3D_PROJECTS_ROOT", root, 1) == 0); + Lardon3DAppState state; + lardon3d_app_state_init(&state); + CHECK(lardon3d_project_create(&state, "Projet Cycle")); + CHECK(state.project_loaded && state.project_db && strlen(state.project_stable_id) == 32); + + char project_path[512], database_path[512], ini_path[512], checkpoint_path[512]; + CHECK(snprintf(project_path, sizeof(project_path), "%s/Projet Cycle", root) > 0); + CHECK(snprintf(database_path, sizeof(database_path), "%s/project.db", project_path) > 0); + CHECK(snprintf(ini_path, sizeof(ini_path), "%s/project.ini", project_path) > 0); + CHECK(snprintf(checkpoint_path, sizeof(checkpoint_path), "%s/.lardon3d/checkpoints/1.chk", project_path) > 0); + struct stat information; + CHECK(stat(database_path, &information) == 0 && S_ISREG(information.st_mode)); + char stable_id[65]; (void)snprintf(stable_id, sizeof(stable_id), "%s", state.project_stable_id); + for (size_t index = 0; index < 32; ++index) { + CHECK((stable_id[index] >= '0' && stable_id[index] <= '9') + || (stable_id[index] >= 'a' && stable_id[index] <= 'f')); + } + Lardon3DProjectDbProject db_project; + CHECK(lardon3d_project_db_get_project(state.project_db, &db_project) == LARDON3D_PROJECT_DB_OK); + CHECK(strcmp(db_project.stable_id, stable_id) == 0); + + lardon3d_project_close(&state); + CHECK(!state.project_loaded && !state.project_db && !state.project_stable_id[0]); + CHECK(lardon3d_project_open(&state, "Projet Cycle")); + CHECK(strcmp(state.project_stable_id, stable_id) == 0); + lardon3d_project_close(&state); + CHECK(write_ini(ini_path, "Projet Cycle", "A0000000000000000000000000000000", 2)); + CHECK(!lardon3d_project_open(&state, "Projet Cycle")); + CHECK(!state.project_loaded && !state.project_db); + CHECK(strstr(state.status_message, "project.ini invalide") != NULL); + CHECK(write_ini(ini_path, "Projet Cycle", "", 1)); + CHECK(lardon3d_project_open(&state, "Projet Cycle")); + CHECK(strcmp(state.project_stable_id, stable_id) == 0); + lardon3d_project_close(&state); + CHECK(unlink(database_path) == 0); + CHECK(lardon3d_project_open(&state, "Projet Cycle")); + CHECK(strcmp(state.project_stable_id, stable_id) == 0); + + const Lardon3DResourceEstimate estimate = { + .minimum_batch_size = 1, + .maximum_batch_size = 1, + .desired_cpu_threads = 1, + }; + Lardon3DTask *task = lardon3d_task_create("Persistée", &estimate, unused_callback, NULL); + CHECK(task && lardon3d_task_assign_id(task, 1)); + CHECK(lardon3d_task_set_progress(task, 10, "frontière 10")); + CHECK(lardon3d_project_checkpoint_task(&state, task) == LARDON3D_PROJECT_TASK_CHECKPOINT_OK); + CHECK(stat(checkpoint_path, &information) == 0 && S_ISREG(information.st_mode)); + Lardon3DProjectDbTask db_task; + Lardon3DProjectRecoveryEntry entries[2]; size_t count = 0; + Lardon3DTaskDurableSnapshot disk_snapshot; uint32_t version = 0; + CHECK(lardon3d_project_db_load_task(state.project_db, 1, &db_task) == LARDON3D_PROJECT_DB_OK); + CHECK(db_task.progress == 10 && strcmp(db_task.checkpoint.path, ".lardon3d/checkpoints/1.chk") == 0); + + CHECK(setenv("LARDON3D_TEST_CHECKPOINT_SYNC_DIRECTORY_FAILURE", "1", 1) == 0); + CHECK(lardon3d_project_checkpoint_task(&state, task) == LARDON3D_PROJECT_TASK_CHECKPOINT_PUBLISHED_NOT_DURABLE); + CHECK(unsetenv("LARDON3D_TEST_CHECKPOINT_SYNC_DIRECTORY_FAILURE") == 0); + CHECK(lardon3d_project_db_load_task(state.project_db, 1, &db_task) == LARDON3D_PROJECT_DB_OK); + CHECK(db_task.checkpoint.durability == LARDON3D_DB_CHECKPOINT_PUBLISHED_NOT_DURABLE); + CHECK(lardon3d_project_list_recoverable(&state, 0, entries, 2, &count) == LARDON3D_PROJECT_DB_OK); + CHECK(count == 1 && entries[0].status == LARDON3D_PROJECT_RECOVERABLE_PUBLISHED_NOT_DURABLE); + + CHECK(lardon3d_task_set_progress(task, 20, "frontière 20")); + CHECK(setenv("LARDON3D_TEST_CHECKPOINT_PREPUBLICATION_FAILURE", "1", 1) == 0); + CHECK(lardon3d_project_checkpoint_task(&state, task) == LARDON3D_PROJECT_TASK_CHECKPOINT_IO_ERROR); + CHECK(unsetenv("LARDON3D_TEST_CHECKPOINT_PREPUBLICATION_FAILURE") == 0); + CHECK(lardon3d_project_db_load_task(state.project_db, 1, &db_task) == LARDON3D_PROJECT_DB_OK && db_task.progress == 10); + Lardon3DTask *unpublished = lardon3d_task_create("Non publiée", &estimate, unused_callback, NULL); + CHECK(unpublished && lardon3d_task_assign_id(unpublished, 3)); + CHECK(setenv("LARDON3D_TEST_CHECKPOINT_PREPUBLICATION_FAILURE", "1", 1) == 0); + CHECK(lardon3d_project_checkpoint_task(&state, unpublished) == LARDON3D_PROJECT_TASK_CHECKPOINT_IO_ERROR); + CHECK(unsetenv("LARDON3D_TEST_CHECKPOINT_PREPUBLICATION_FAILURE") == 0); + CHECK(lardon3d_project_db_load_task(state.project_db, 3, &db_task) == LARDON3D_PROJECT_DB_NOT_FOUND); + lardon3d_task_destroy(unpublished); + + CHECK(lardon3d_task_set_progress(task, 25, "frontière 25")); + CHECK(setenv("LARDON3D_TEST_PROJECT_DB_BUSY_CHECKPOINT", "1", 1) == 0); + CHECK(lardon3d_project_checkpoint_task(&state, task) == LARDON3D_PROJECT_TASK_CHECKPOINT_DB_BUSY); + CHECK(unsetenv("LARDON3D_TEST_PROJECT_DB_BUSY_CHECKPOINT") == 0); + CHECK(lardon3d_task_checkpoint_load(checkpoint_path, &disk_snapshot, &version) == LARDON3D_TASK_CHECKPOINT_OK); + CHECK(disk_snapshot.progress == 25); + CHECK(lardon3d_project_db_load_task(state.project_db, 1, &db_task) == LARDON3D_PROJECT_DB_OK && db_task.progress == 10); + CHECK(lardon3d_task_set_progress(task, 20, "frontière 20")); + + CHECK(setenv("LARDON3D_TEST_PROJECT_DB_FAIL_CHECKPOINT", "1", 1) == 0); + CHECK(lardon3d_project_checkpoint_task(&state, task) == LARDON3D_PROJECT_TASK_CHECKPOINT_DB_ERROR); + CHECK(unsetenv("LARDON3D_TEST_PROJECT_DB_FAIL_CHECKPOINT") == 0); + CHECK(lardon3d_task_checkpoint_load(checkpoint_path, &disk_snapshot, &version) == LARDON3D_TASK_CHECKPOINT_OK); + CHECK(disk_snapshot.progress == 20); + CHECK(lardon3d_project_db_load_task(state.project_db, 1, &db_task) == LARDON3D_PROJECT_DB_OK && db_task.progress == 10); + CHECK(lardon3d_project_checkpoint_task(&state, task) == LARDON3D_PROJECT_TASK_CHECKPOINT_OK); + + CHECK(lardon3d_project_list_recoverable(&state, 0, entries, 2, &count) == LARDON3D_PROJECT_DB_OK); + CHECK(count == 1 && entries[0].status == LARDON3D_PROJECT_RECOVERABLE && entries[0].snapshot.progress == 20); + CHECK(lardon3d_project_list_recoverable(&state, 0, entries, 257, &count) == LARDON3D_PROJECT_DB_INVALID_ARGUMENT); + + CHECK(unlink(checkpoint_path) == 0); + CHECK(lardon3d_project_list_recoverable(&state, 0, entries, 2, &count) == LARDON3D_PROJECT_DB_OK); + CHECK(count == 1 && entries[0].status == LARDON3D_PROJECT_RECOVERY_MISSING_CHECKPOINT); + CHECK(lardon3d_project_checkpoint_task(&state, task) == LARDON3D_PROJECT_TASK_CHECKPOINT_OK); + int descriptor = open(checkpoint_path, O_WRONLY | O_TRUNC); CHECK(descriptor >= 0); + CHECK(write(descriptor, "corrupt", 7) == 7 && close(descriptor) == 0); + CHECK(lardon3d_project_list_recoverable(&state, 0, entries, 2, &count) == LARDON3D_PROJECT_DB_OK); + CHECK(entries[0].status == LARDON3D_PROJECT_RECOVERY_INVALID_CHECKPOINT); + CHECK(lardon3d_project_checkpoint_task(&state, task) == LARDON3D_PROJECT_TASK_CHECKPOINT_OK); + descriptor = open(checkpoint_path, O_RDWR); CHECK(descriptor >= 0); + unsigned char future_version[4] = {2, 0, 0, 0}; + CHECK(pwrite(descriptor, future_version, sizeof(future_version), 8) == 4 && close(descriptor) == 0); + CHECK(lardon3d_project_list_recoverable(&state, 0, entries, 2, &count) == LARDON3D_PROJECT_DB_OK); + CHECK(entries[0].status == LARDON3D_PROJECT_RECOVERY_UNSUPPORTED_CHECKPOINT); + CHECK(lardon3d_project_checkpoint_task(&state, task) == LARDON3D_PROJECT_TASK_CHECKPOINT_OK); + + CheckpointThread contexts[2] = {{.state = &state, .task = task}, {.state = &state, .task = task}}; + pthread_t threads[2]; CHECK(pthread_create(&threads[0], NULL, checkpoint_thread, &contexts[0]) == 0); + CHECK(pthread_create(&threads[1], NULL, checkpoint_thread, &contexts[1]) == 0); + CHECK(pthread_join(threads[0], NULL) == 0 && pthread_join(threads[1], NULL) == 0); + CHECK(contexts[0].result == LARDON3D_PROJECT_TASK_CHECKPOINT_OK && contexts[1].result == LARDON3D_PROJECT_TASK_CHECKPOINT_OK); + + Lardon3DTask *terminal = lardon3d_task_create("Terminale", &estimate, unused_callback, NULL); + CHECK(terminal && lardon3d_task_assign_id(terminal, 2)); lardon3d_task_request_cancel(terminal); + CHECK(lardon3d_project_checkpoint_task(&state, terminal) == LARDON3D_PROJECT_TASK_CHECKPOINT_OK); + CHECK(lardon3d_project_list_recoverable(&state, 0, entries, 2, &count) == LARDON3D_PROJECT_DB_OK && count == 1); + lardon3d_task_destroy(terminal); + + lardon3d_project_close(&state); + CHECK(lardon3d_project_checkpoint_task(&state, task) == LARDON3D_PROJECT_TASK_CHECKPOINT_NO_PROJECT); + CHECK(lardon3d_project_open(&state, "Projet Cycle")); + CHECK(lardon3d_project_list_recoverable(&state, 0, entries, 2, &count) == LARDON3D_PROJECT_DB_OK); + CHECK(count == 1 && entries[0].status == LARDON3D_PROJECT_RECOVERABLE); + lardon3d_project_close(&state); + + CHECK(write_ini(ini_path, "Projet Cycle", "00000000000000000000000000000000", 2)); + CHECK(!lardon3d_project_open(&state, "Projet Cycle")); + CHECK(!state.project_loaded && !state.project_db); + CHECK(write_ini(ini_path, "Projet Cycle", stable_id, 2)); + descriptor = open(database_path, O_WRONLY | O_TRUNC); CHECK(descriptor >= 0); + CHECK(write(descriptor, "not sqlite", 10) == 10 && close(descriptor) == 0); + CHECK(!lardon3d_project_open(&state, "Projet Cycle")); + CHECK(!state.project_loaded && !state.project_db); + lardon3d_task_destroy(task); + + char terminal_checkpoint[512]; CHECK(snprintf(terminal_checkpoint, sizeof(terminal_checkpoint), "%s/.lardon3d/checkpoints/2.chk", project_path) > 0); + CHECK(unlink(checkpoint_path) == 0); CHECK(unlink(terminal_checkpoint) == 0); + CHECK(unlink(database_path) == 0); CHECK(unlink(ini_path) == 0); + char path[512]; + CHECK(snprintf(path, sizeof(path), "%s/.lardon3d/checkpoints", project_path) > 0 && rmdir(path) == 0); + CHECK(snprintf(path, sizeof(path), "%s/.lardon3d", project_path) > 0 && rmdir(path) == 0); + const char *directories[] = {"images", "reconstruction", "exports", "logs"}; + for (size_t index = 0; index < 4; ++index) { CHECK(snprintf(path, sizeof(path), "%s/%s", project_path, directories[index]) > 0); CHECK(rmdir(path) == 0); } + CHECK(rmdir(project_path) == 0); CHECK(rmdir(root) == 0); + CHECK(unsetenv("LARDON3D_PROJECTS_ROOT") == 0); + return true; +} + +int main(void) { return run_test() ? EXIT_SUCCESS : EXIT_FAILURE; } diff --git a/tests/test_project_db.c b/tests/test_project_db.c new file mode 100644 index 0000000..5805a9b --- /dev/null +++ b/tests/test_project_db.c @@ -0,0 +1,196 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#define CHECK(condition) do { if (!(condition)) { \ + (void)fprintf(stderr, "Échec ligne %d : %s\n", __LINE__, #condition); return false; \ +} } while (0) + +Lardon3DProjectDbResult lardon3d_project_db_test_orphan_checkpoint(Lardon3DProjectDb *database); + +static Lardon3DTaskDurableSnapshot +task_snapshot(uint64_t id, Lardon3DTaskState saved) +{ + Lardon3DTaskDurableSnapshot snapshot = { + .id = id, + .estimate = {.minimum_batch_size = 1, .maximum_batch_size = 1, .desired_cpu_threads = 1}, + .progress = saved == TASK_COMPLETED ? 100 : 25, + .saved_state = saved, + .recovery_state = saved == TASK_RUNNING || saved == TASK_PAUSED ? TASK_PENDING : saved, + .started_at = {.tv_sec = 10, .tv_nsec = 20}, + .finished_at = {.tv_sec = 30, .tv_nsec = 40}, + .sequence_count = 2, + }; + (void)snprintf(snapshot.name, sizeof(snapshot.name), "Tâche %llu", (unsigned long long)id); + return snapshot; +} + +typedef struct { Lardon3DProjectDb *database; bool success; } ThreadContext; + +static void * +read_thread(void *userdata) +{ + ThreadContext *context = userdata; + context->success = true; + for (size_t index = 0; index < 100; ++index) { + Lardon3DProjectDbTask task; + if (lardon3d_project_db_load_task(context->database, 1, &task) != LARDON3D_PROJECT_DB_OK) { + context->success = false; + break; + } + } + return NULL; +} + +static bool +create_future_database(const char *path) +{ + sqlite3 *connection = NULL; + if (sqlite3_open(path, &connection) != SQLITE_OK) return false; + bool ok = sqlite3_exec(connection, "CREATE TABLE metadata(key TEXT PRIMARY KEY,value INTEGER NOT NULL);" + "INSERT INTO metadata VALUES('schema_version',2);", NULL, NULL, NULL) == SQLITE_OK; + return sqlite3_close(connection) == SQLITE_OK && ok; +} + +static bool +query_integer(const char *path, const char *sql, sqlite3_int64 expected) +{ + sqlite3 *connection = NULL; + sqlite3_stmt *statement = NULL; + if (sqlite3_open_v2(path, &connection, SQLITE_OPEN_READONLY, NULL) != SQLITE_OK + || sqlite3_prepare_v2(connection, sql, -1, &statement, NULL) != SQLITE_OK) { + if (statement) (void)sqlite3_finalize(statement); + if (connection) (void)sqlite3_close(connection); + return false; + } + bool matches = sqlite3_step(statement) == SQLITE_ROW + && sqlite3_column_int64(statement, 0) == expected + && sqlite3_step(statement) == SQLITE_DONE; + return sqlite3_finalize(statement) == SQLITE_OK + && sqlite3_close(connection) == SQLITE_OK && matches; +} + +static bool +run_test(void) +{ + char directory[] = "/tmp/lardon3d-project-db-XXXXXX"; + CHECK(mkdtemp(directory)); + char database_path[512], artifact_path[512], future_path[512], corrupt_path[512]; + CHECK(snprintf(database_path, sizeof(database_path), "%s/project.db", directory) > 0); + CHECK(snprintf(artifact_path, sizeof(artifact_path), "%s/artifact.bin", directory) > 0); + CHECK(snprintf(future_path, sizeof(future_path), "%s/future.db", directory) > 0); + CHECK(snprintf(corrupt_path, sizeof(corrupt_path), "%s/corrupt.db", directory) > 0); + + char error[LARDON3D_PROJECT_DB_ERROR_CAPACITY]; + Lardon3DProjectDb *database = NULL; + CHECK(lardon3d_project_db_open(database_path, &database, error) == LARDON3D_PROJECT_DB_OK); + CHECK(database && lardon3d_project_db_schema_version(database) == 1); + + Lardon3DProjectDbProject project = {.created_at = 100, .updated_at = 100}; + (void)snprintf(project.stable_id, sizeof(project.stable_id), "project-0001"); + (void)snprintf(project.name, sizeof(project.name), "Projet test"); + CHECK(lardon3d_project_db_set_project(database, &project) == LARDON3D_PROJECT_DB_OK); + Lardon3DProjectDbProject loaded_project; + CHECK(lardon3d_project_db_get_project(database, &loaded_project) == LARDON3D_PROJECT_DB_OK); + CHECK(strcmp(loaded_project.stable_id, project.stable_id) == 0); + project.updated_at = 101; (void)snprintf(project.name, sizeof(project.name), "Projet renommé"); + CHECK(lardon3d_project_db_set_project(database, &project) == LARDON3D_PROJECT_DB_OK); + Lardon3DProjectDbProject conflict = project; (void)snprintf(conflict.stable_id, sizeof(conflict.stable_id), "other"); + CHECK(lardon3d_project_db_set_project(database, &conflict) == LARDON3D_PROJECT_DB_CONSTRAINT); + char copied_error[LARDON3D_PROJECT_DB_ERROR_CAPACITY]; + CHECK(lardon3d_project_db_last_error(database, copied_error)); + CHECK(copied_error[0] != '\0'); + project = loaded_project; memset(project.name, 'x', sizeof(project.name)); + CHECK(lardon3d_project_db_set_project(database, &project) == LARDON3D_PROJECT_DB_INVALID_ARGUMENT); + + Lardon3DTaskDurableSnapshot running = task_snapshot(1, TASK_RUNNING); + Lardon3DProjectDbCheckpoint checkpoint = {.format_version = 1, .durability = LARDON3D_DB_CHECKPOINT_DURABLE, .updated_at = 200}; + (void)snprintf(checkpoint.path, sizeof(checkpoint.path), "%s/checkpoints/task-1.chk", directory); + CHECK(lardon3d_project_db_record_task(database, &running, &checkpoint, 200) == LARDON3D_PROJECT_DB_OK); + running.progress = 30; running.sequence_count = 3; + CHECK(lardon3d_project_db_record_task(database, &running, &checkpoint, 201) == LARDON3D_PROJECT_DB_OK); + Lardon3DProjectDbTask task; + CHECK(lardon3d_project_db_load_task(database, 1, &task) == LARDON3D_PROJECT_DB_OK); + CHECK(task.recovery_state == TASK_PENDING && task.progress == 30 && task.sequence_count == 3); + CHECK(task.has_checkpoint && strcmp(task.checkpoint.path, checkpoint.path) == 0); + checkpoint.durability = LARDON3D_DB_CHECKPOINT_PUBLISHED_NOT_DURABLE; + CHECK(lardon3d_project_db_record_task(database, &running, &checkpoint, 201) == LARDON3D_PROJECT_DB_OK); + CHECK(lardon3d_project_db_load_task(database, 1, &task) == LARDON3D_PROJECT_DB_OK); + CHECK(task.checkpoint.durability == LARDON3D_DB_CHECKPOINT_PUBLISHED_NOT_DURABLE); + + Lardon3DTaskDurableSnapshot completed = task_snapshot(2, TASK_COMPLETED); + CHECK(lardon3d_project_db_record_task(database, &completed, NULL, 202) == LARDON3D_PROJECT_DB_OK); + Lardon3DTaskDurableSnapshot no_checkpoint = task_snapshot(4, TASK_PENDING); + CHECK(lardon3d_project_db_record_task(database, &no_checkpoint, NULL, 202) == LARDON3D_PROJECT_DB_OK); + Lardon3DProjectDbTask page[1]; size_t count = 0; + CHECK(lardon3d_project_db_list_recoverable(database, 0, page, 1, &count) == LARDON3D_PROJECT_DB_OK); + CHECK(count == 1 && page[0].task_id == 1); + CHECK(lardon3d_project_db_list_recoverable(database, 1, page, 1, &count) == LARDON3D_PROJECT_DB_OK && count == 0); + CHECK(lardon3d_project_db_list_recoverable(database, 0, page, LARDON3D_PROJECT_DB_RECOVERY_PAGE_MAX + 1, &count) == LARDON3D_PROJECT_DB_INVALID_ARGUMENT); + + Lardon3DTaskDurableSnapshot rollback_task = task_snapshot(3, TASK_PENDING); + CHECK(setenv("LARDON3D_TEST_PROJECT_DB_FAIL_CHECKPOINT", "1", 1) == 0); + CHECK(lardon3d_project_db_record_task(database, &rollback_task, &checkpoint, 203) == LARDON3D_PROJECT_DB_IO_ERROR); + CHECK(unsetenv("LARDON3D_TEST_PROJECT_DB_FAIL_CHECKPOINT") == 0); + CHECK(lardon3d_project_db_load_task(database, 3, &task) == LARDON3D_PROJECT_DB_NOT_FOUND); + CHECK(lardon3d_project_db_test_orphan_checkpoint(database) == LARDON3D_PROJECT_DB_CONSTRAINT); + + Lardon3DProjectDbArtifact artifact = {.state = LARDON3D_DB_ARTIFACT_STAGED, .has_producer_task = true, .producer_task_id = 1, .created_at = 300, .updated_at = 300}; + (void)snprintf(artifact.artifact_id, sizeof(artifact.artifact_id), "artifact-1"); + (void)snprintf(artifact.kind, sizeof(artifact.kind), "generic-test"); + (void)snprintf(artifact.path, sizeof(artifact.path), "%s", artifact_path); + CHECK(lardon3d_project_db_create_artifact(database, &artifact) == LARDON3D_PROJECT_DB_OK); + Lardon3DProjectDbArtifact loaded_artifact; + CHECK(lardon3d_project_db_load_artifact(database, artifact.artifact_id, &loaded_artifact) == LARDON3D_PROJECT_DB_OK); + CHECK(loaded_artifact.state == LARDON3D_DB_ARTIFACT_STAGED); + CHECK(lardon3d_project_db_mark_artifact_ready(database, artifact.artifact_id, 301) == LARDON3D_PROJECT_DB_IO_ERROR); + int descriptor = open(artifact_path, O_WRONLY | O_CREAT | O_TRUNC, 0600); CHECK(descriptor >= 0); + const char payload[] = "validated"; CHECK(write(descriptor, payload, sizeof(payload)) == (ssize_t)sizeof(payload)); CHECK(close(descriptor) == 0); + CHECK(lardon3d_project_db_mark_artifact_ready(database, artifact.artifact_id, 301) == LARDON3D_PROJECT_DB_OK); + CHECK(lardon3d_project_db_load_artifact(database, artifact.artifact_id, &loaded_artifact) == LARDON3D_PROJECT_DB_OK); + CHECK(loaded_artifact.state == LARDON3D_DB_ARTIFACT_READY && loaded_artifact.size_bytes == sizeof(payload)); + CHECK(lardon3d_project_db_create_artifact(database, &artifact) == LARDON3D_PROJECT_DB_CONSTRAINT); + artifact.producer_task_id = 999; + (void)snprintf(artifact.artifact_id, sizeof(artifact.artifact_id), "orphan-artifact"); + CHECK(lardon3d_project_db_create_artifact(database, &artifact) == LARDON3D_PROJECT_DB_CONSTRAINT); + artifact = loaded_artifact; artifact.state = LARDON3D_DB_ARTIFACT_STAGED; artifact.size_bytes = 0; + memset(artifact.path, 'x', sizeof(artifact.path)); + CHECK(lardon3d_project_db_create_artifact(database, &artifact) == LARDON3D_PROJECT_DB_INVALID_ARGUMENT); + + ThreadContext contexts[2] = {{.database = database}, {.database = database}}; pthread_t threads[2]; + CHECK(pthread_create(&threads[0], NULL, read_thread, &contexts[0]) == 0); + CHECK(pthread_create(&threads[1], NULL, read_thread, &contexts[1]) == 0); + CHECK(pthread_join(threads[0], NULL) == 0 && pthread_join(threads[1], NULL) == 0); + CHECK(contexts[0].success && contexts[1].success); + + char too_long[LARDON3D_PROJECT_DB_PATH_CAPACITY + 1]; memset(too_long, 'x', sizeof(too_long)); too_long[sizeof(too_long) - 1] = '\0'; + CHECK(lardon3d_project_db_open(too_long, &database, error) == LARDON3D_PROJECT_DB_INVALID_ARGUMENT); + lardon3d_project_db_close(contexts[0].database); database = NULL; + CHECK(query_integer(database_path, "SELECT value FROM metadata WHERE key='schema_version'", 1)); + CHECK(query_integer(database_path, "SELECT count(*) FROM tasks WHERE task_id=1", 1)); + CHECK(lardon3d_project_db_open(database_path, &database, error) == LARDON3D_PROJECT_DB_OK); + CHECK(lardon3d_project_db_load_task(database, 1, &task) == LARDON3D_PROJECT_DB_OK); + CHECK(lardon3d_project_db_load_artifact(database, "artifact-1", &loaded_artifact) == LARDON3D_PROJECT_DB_OK); + lardon3d_project_db_close(database); + + CHECK(create_future_database(future_path)); + CHECK(lardon3d_project_db_open(future_path, &database, error) == LARDON3D_PROJECT_DB_UNSUPPORTED_SCHEMA); + descriptor = open(corrupt_path, O_WRONLY | O_CREAT | O_TRUNC, 0600); CHECK(descriptor >= 0); + CHECK(write(descriptor, "not sqlite", 10) == 10 && close(descriptor) == 0); + CHECK(lardon3d_project_db_open(corrupt_path, &database, error) == LARDON3D_PROJECT_DB_CORRUPT); + + CHECK(unlink(artifact_path) == 0); CHECK(unlink(database_path) == 0); CHECK(unlink(future_path) == 0); CHECK(unlink(corrupt_path) == 0); + CHECK(rmdir(directory) == 0); + return true; +} + +int main(void) { return run_test() ? EXIT_SUCCESS : EXIT_FAILURE; } diff --git a/tests/test_task_checkpoint.c b/tests/test_task_checkpoint.c index 58a18ad..7d9f029 100644 --- a/tests/test_task_checkpoint.c +++ b/tests/test_task_checkpoint.c @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -326,7 +327,7 @@ run_test(void) CHECK(check_restored_state(TASK_PAUSED, TASK_PENDING)); Lardon3DHardwareProfile profile = { - .logical_cpu_count = 2, + .logical_cpu_count = UINT_MAX, .page_size_bytes = 4096, .memory_total_bytes = UINT64_MAX, .cpu_architecture = "test",