feat(project): wire project database and task recovery inventory

This commit is contained in:
fy59 2026-08-08 10:52:35 +02:00
parent 2b6e9bf444
commit efa556a824
19 changed files with 1786 additions and 54 deletions

View file

@ -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

View file

@ -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/<task_id>.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.

View file

@ -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_root>/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/<task_id>.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.

View file

@ -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

View file

@ -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.

View file

@ -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.

View file

@ -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é

View file

@ -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);

View file

@ -4,6 +4,34 @@
#include <stdbool.h>
#include <lardon3d/app_state.h>
#include <lardon3d/project_db.h>
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

View file

@ -0,0 +1,136 @@
#ifndef LARDON3D_PROJECT_DB_H
#define LARDON3D_PROJECT_DB_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <time.h>
#include <lardon3d/task.h>
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

View file

@ -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: [

View file

@ -5,6 +5,7 @@
#include <lardon3d/app_state.h>
#include <lardon3d/image_catalog.h>
#include <lardon3d/image_view.h>
#include <lardon3d/project.h>
#include <lardon3d/resource_governor.h>
#include <lardon3d/task_queue.h>
#include <lardon3d/tui.h>
@ -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;

View file

@ -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",
};
}

View file

@ -1,17 +1,21 @@
#include <ctype.h>
#include <errno.h>
#include <fcntl.h>
#include <inttypes.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <time.h>
#include <unistd.h>
#include <lardon3d/project.h>
#include <lardon3d/image_catalog.h>
#include <lardon3d/image_view.h>
#include <lardon3d/project_db.h>
#include <lardon3d/task_checkpoint.h>
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;
}

504
src/project_db.c Normal file
View file

@ -0,0 +1,504 @@
#include <errno.h>
#include <limits.h>
#include <pthread.h>
#include <sqlite3.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <lardon3d/project_db.h>
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

View file

@ -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)) {

211
tests/test_project.c Normal file
View file

@ -0,0 +1,211 @@
#include <fcntl.h>
#include <pthread.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#include <lardon3d/project.h>
#include <lardon3d/task_checkpoint.h>
#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; }

196
tests/test_project_db.c Normal file
View file

@ -0,0 +1,196 @@
#include <fcntl.h>
#include <pthread.h>
#include <sqlite3.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#include <lardon3d/project_db.h>
#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; }

View file

@ -1,4 +1,5 @@
#include <fcntl.h>
#include <limits.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
@ -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",