diff --git a/AGENTS.md b/AGENTS.md index 0706a8d..1ed0836 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,8 +65,8 @@ disponible. Ne jamais annoncer une vérification non exécutée. - Gouverneur thread-safe avec estimations, budgets et réservations opaques. - Scheduler relié au gouverneur ; réservation obligatoire avant callback. - Aucun DAG, aucune priorité, aucun pool de workers. -- L'import asynchrone utilise encore son worker dédié et n'est pas migré vers - le scheduler générique. +- L'import asynchrone `import.images` utilise le scheduler générique, ses lots + adaptatifs et ses checkpoints persistants. - Le viewer Vulkan n'est pas commencé. ## Prochains tickets recommandés diff --git a/README.md b/README.md index a395d43..8809dc7 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Lardon3D ne vise pas simplement "dossier de photos → objet 3D", mais "ensemble - **Task Checkpoint v1** : snapshot durable, fichier atomique et reprise sûre - **Project Database v3** : identité, tâches typées/checkpoints, paramètres d'import et inventaire d'artefacts SQLite - **Task Kind Registry** : identité métier durable et reconstruction runtime explicite +- **Recovery projet** : reprise automatique sélective et bornée des imports récupérables - **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 @@ -40,7 +41,7 @@ Lardon3D ne vise pas simplement "dossier de photos → objet 3D", mais "ensemble ### Briques prévues (PLANNED) -- Resoumission contrôlée des tâches récupérables à l'ouverture +- Graphe de dépendances pour ordonner les futures reprises interdépendantes - DAG de dépendances - Pools de workers multiples (CPU/GPU/IO) - Publication live validée diff --git a/docs/architecture/persistence.md b/docs/architecture/persistence.md index ad909fc..fb64d9f 100644 --- a/docs/architecture/persistence.md +++ b/docs/architecture/persistence.md @@ -172,8 +172,17 @@ checkpoint de fin de lot. Si ce checkpoint ou sa transaction DB échoue, le manifeste demeure la frontière idempotente plus récente et un checkpoint orphelin peut subsister selon le protocole filesystem puis SQLite. -**NOT_YET_WIRED** — autosave complet, resoumission scheduler et réconciliation -des fichiers orphelins. +**IMPLEMENTED** — reprise automatique sélective à l'ouverture : pagination +bornée, validation checkpoint/kind, reconstruction production et enqueue sans +claim persistant supplémentaire. + +Les records sont parcourus par task ID croissant. Un checkpoint +`PUBLISHED_NOT_DURABLE` présent, valide et cohérent peut être repris ; le résumé +conserve cet avertissement jusqu'au prochain checkpoint durable. Une tâche +terminale n'appartient pas à la requête de reprise. + +**NOT_YET_WIRED** — autosave complet, réconciliation des fichiers orphelins et +retry piloté par l'utilisateur pour les sources indisponibles. **PLANNED** — catalogue d'artefacts photogrammétriques réels, migrations v4+ et -reprise globale. +reprise avec dépendances. diff --git a/docs/architecture/project_database.md b/docs/architecture/project_database.md index 154332e..626bf05 100644 --- a/docs/architecture/project_database.md +++ b/docs/architecture/project_database.md @@ -274,6 +274,18 @@ distingue `LEGACY_UNTYPED`, `UNKNOWN_TASK_KIND` et `UNSUPPORTED_TASK_KIND_VERSION`. Aucun reconstructeur métier n'est appelé sous le mutex DB. Un upsert ne peut pas changer le couple kind/version d'un task ID. +À l'ouverture, le projet lit des pages de 8 dans l'ordre croissant des task +IDs. Chaque record est copié hors mutex DB avant reconstruction et enqueue. Une +fenêtre pleine interrompt le scan sans modifier les tâches restantes. Le résumé +borné expose `inspected`, `resumed`, `skipped`, `failed`, le nombre de +checkpoints `PUBLISHED_NOT_DURABLE` repris et la saturation éventuelle. + +Les erreurs d'ouverture/migration, de schéma ou d'identité restent fatales. +Les erreurs propres à une tâche — legacy, kind inconnu/futur, checkpoint +absent/invalide/futur, source indisponible ou reconstruction — sont non fatales. +Un `BUSY` après le timeout SQLite arrête le scan sans boucle et laisse le projet +ouvert. + ## Statut **IMPLEMENTED** — SQLite système, schéma v3 et migrations v1→v2→v3, identité @@ -289,8 +301,11 @@ reconstruction explicite testée hors scheduler. **IMPLEMENTED** — allocation transactionnelle de task IDs, paramètres immuables de `import.images` et reconstruction production explicite. -**NOT_YET_WIRED** — resoumission automatique, autosave -à toutes les transitions et réconciliation des checkpoints orphelins, ScanSet +**IMPLEMENTED** — reprise automatique sélective, pagination de 8, ordre par ID, +fenêtre de queue non bloquante et résumé consultable. + +**NOT_YET_WIRED** — autosave à toutes les transitions, retry UI des sources +indisponibles et réconciliation des checkpoints orphelins, ScanSet et catalogue image persistants, Feature Store et Visual Index. **PLANNED** — migrations v4+, dépendances d'artefacts, graphe géométrique et diff --git a/docs/architecture/runtime.md b/docs/architecture/runtime.md index 3f843f4..1a9d65c 100644 --- a/docs/architecture/runtime.md +++ b/docs/architecture/runtime.md @@ -57,7 +57,7 @@ - Worker unique (pas de pools multiples) - Pas de parallélisme inter-tâches -- Checkpoints isolés disponibles mais pas encore orchestrés au démarrage +- Reprise automatique limitée aux tâches indépendantes reconstructibles ## Reprise durable @@ -78,8 +78,17 @@ qu'à la terminaison de cette tentative. **IMPLEMENTED** — l'import `import.images` se sauvegarde à chaque fin de lot et se reconstruit explicitement avec un userdata neuf lié au projet rouvert. -**NOT_YET_WIRED** — resoumission automatique à l'ouverture et autosave -générique des autres kinds. +**IMPLEMENTED** — `project_open()` inventorie par pages de 8, restaure puis +resoumet automatiquement les tâches production valides. Il retourne après +l'enqueue et n'attend jamais leur terminaison. + +L'ordre d'initialisation production est : profil matériel, governor, queue et +worker, TUI, puis ouverture DB/projet et reprise synchrone. Une fermeture ne +peut commencer qu'après le retour de `project_open()`. Le worker peut consommer +pendant le scan ; chaque tâche exécutée est néanmoins réadmise normalement. + +**NOT_YET_WIRED** — autosave générique des autres kinds et reprise ordonnée par +dépendances. **PLANNED** — reprise globale du scheduler via la Project Database. @@ -99,7 +108,10 @@ mutex DB pour un kind connu ; elle ne soumet aucune tâche. n'entre en collision avec aucune tâche connue. L'import production peut donc être reconstruit puis soumis explicitement. -**NOT_YET_WIRED** — aucune resoumission automatique à `project_open()`. +**IMPLEMENTED** — la resoumission automatique utilise la registry production, +conserve le task ID et laisse le worker obtenir une nouvelle réservation. +Kinds inconnus, tâches legacy, checkpoints invalides et sources absentes ne +bloquent pas l'ouverture. ## Invariants diff --git a/docs/architecture/task_kind_registry.md b/docs/architecture/task_kind_registry.md index 615b354..da2a343 100644 --- a/docs/architecture/task_kind_registry.md +++ b/docs/architecture/task_kind_registry.md @@ -41,8 +41,10 @@ migration DB v1→v2, classification recovery et restauration explicite testée. le chemin source borné depuis la table dédiée et reconstruit callback et userdata sans `AppState *` ancien. -**NOT_YET_WIRED** — autosave complet, resoumission automatique et -réconciliation orpheline. +**IMPLEMENTED** — `project_open()` utilise la registry production immutable +pour restaurer hors mutex DB et transférer chaque tâche acceptée à la queue. + +**NOT_YET_WIRED** — autosave complet et réconciliation orpheline. **PLANNED** — kinds des tâches ScanSet, Image Catalog, Feature Store, Visual Index et reconstruction lorsque ces traitements existeront réellement. diff --git a/docs/architecture/task_queue.md b/docs/architecture/task_queue.md index 8f7bded..1a717a2 100644 --- a/docs/architecture/task_queue.md +++ b/docs/architecture/task_queue.md @@ -29,6 +29,8 @@ Le type public réel est l'opaque `Lardon3DTaskQueue`. | `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_try_add_ex()` | Distingue succès, saturation, arrêt, collision d'ID et erreur | +| `lardon3d_task_queue_pause()` / `resume()` | Contrôle une tâche par son ID stable | | `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 | @@ -75,14 +77,20 @@ le blocage par la tête de file lorsqu'une tâche ne peut pas démarrer. ## Statut -**IMPLÉMENTÉ** — file FIFO avec worker unique, sélection adaptative, pause et -annulation coopératives. +**IMPLÉMENTÉ** — file FIFO avec worker unique, sélection adaptative, pause, +annulation coopérative et accueil des tâches restaurées avec ID préassigné. + +La reprise projet utilise `try_add_ex()` et ne bloque jamais `project_open()`. +À saturation, elle arrête sa fenêtre : les tâches non transférées restent +`PENDING` en DB et seront réévaluées lors d'une ouverture ultérieure. Ce n'est +pas un second scheduler. ## Limites - Worker unique : pas de parallélisme interne. - Pas de DAG ni de dépendances inter-tâches. - Pas de priorités (FIFO strict). -- La reprise depuis Project Database n'est pas encore branchée. +- La reprise ne possède pas encore de DAG ni de déclenchement différé lorsque + une place se libère pendant la session courante. - Pas de pool de workers CPU/IO/GPU. - La backpressure borne les producteurs à la capacité configurée. diff --git a/docs/architecture/task_system.md b/docs/architecture/task_system.md index e16bb5b..b93ea23 100644 --- a/docs/architecture/task_system.md +++ b/docs/architecture/task_system.md @@ -83,7 +83,10 @@ par la tâche restaurée et exécuté après arrêt de son exécution. de séquence et les checkpoints génériques ; une tâche restaurée conserve son ID lors de sa soumission explicite à la file. -**NOT_YET_WIRED** — autosave générique et resoumission automatique. +**IMPLEMENTED** — resoumission automatique sélective des tâches production à +l'ouverture du projet. + +**NOT_YET_WIRED** — autosave générique et dépendances entre tâches. Le chemin de production de l'import ne possède plus de thread ni de drapeau d'annulation privés. Son wrapper TUI ne fait qu'enqueue/cancel/observer la @@ -98,6 +101,11 @@ avant l'appel hors mutex. `join()` attend la fin du callback ; le userdata reste donc valide pendant celui-ci et son destructeur n'est appelé qu'ensuite par la destruction de la tâche. +Une tâche reconstruite mais refusée avant transfert à la queue est abandonnée +localement : son userdata est détruit, sans callback terminal ni écriture +durable d'une fausse annulation. Une annulation explicitement demandée conserve +le contrat de notification terminale. + La Project Database v3 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 @@ -111,6 +119,6 @@ d'inventaire retourne des snapshots durables validés, mais ne peut pas appeler ## Limites - Aucune priorité interne : l'ordre est uniquement FIFO. -- Pas encore de reprise globale au démarrage du projet. +- Pas encore de DAG pour ordonner des reprises interdépendantes. - Aucune dépendance inter-tâches (pas de DAG). - Pas encore de références d'artefacts métier validés. diff --git a/docs/roadmap/roadmap.md b/docs/roadmap/roadmap.md index e3b4a4f..98bba67 100644 --- a/docs/roadmap/roadmap.md +++ b/docs/roadmap/roadmap.md @@ -33,7 +33,7 @@ Lardon3D suit une feuille de route ordonnée qui privilégie la stabilité et la - ✅ Branchement Project Database au cycle de vie projet et inventaire de reprise - ✅ Registry durable des types métier de tâches - ✅ Premier type métier reconstructible (`import.images`) -- 📋 Resoumission automatique contrôlée des tâches récupérables +- ✅ Resoumission automatique contrôlée et bornée des tâches récupérables - 📋 ScanSet et Image Catalog persistants ### Phase 4 : Pipeline avancé diff --git a/include/lardon3d/app_state.h b/include/lardon3d/app_state.h index 8a364b1..44cef27 100644 --- a/include/lardon3d/app_state.h +++ b/include/lardon3d/app_state.h @@ -2,6 +2,7 @@ #define LARDON3D_APP_STATE_H #include +#include #include #include typedef struct Lardon3DImageCatalog Lardon3DImageCatalog; @@ -34,6 +35,12 @@ typedef struct { Lardon3DHardwareProfile hardware_profile; Lardon3DResourceGovernor *resource_governor; Lardon3DProjectDb *project_db; + size_t recovery_inspected; + size_t recovery_resumed; + size_t recovery_skipped; + size_t recovery_failed; + size_t recovery_published_not_durable; + bool recovery_queue_full; } Lardon3DAppState; void lardon3d_app_state_init(Lardon3DAppState *state); diff --git a/include/lardon3d/project.h b/include/lardon3d/project.h index b5faf4d..779a16a 100644 --- a/include/lardon3d/project.h +++ b/include/lardon3d/project.h @@ -39,6 +39,15 @@ typedef struct { Lardon3DTaskDurableSnapshot snapshot; } Lardon3DProjectRecoveryEntry; +typedef struct { + size_t inspected; + size_t resumed; + size_t skipped; + size_t failed; + size_t published_not_durable; + bool queue_full; +} Lardon3DProjectRecoverySummary; + bool lardon3d_project_create( Lardon3DAppState *state, const char *name @@ -68,5 +77,14 @@ Lardon3DProjectDbResult lardon3d_project_list_recoverable( size_t capacity, size_t *count ); +Lardon3DProjectDbResult lardon3d_project_resume_recoverable_tasks( + Lardon3DAppState *state, + const Lardon3DTaskKindRegistry *registry, + Lardon3DProjectRecoverySummary *summary +); +bool lardon3d_project_last_recovery_summary( + const Lardon3DAppState *state, + Lardon3DProjectRecoverySummary *summary +); #endif diff --git a/include/lardon3d/task_queue.h b/include/lardon3d/task_queue.h index 418dac1..b0c6d48 100644 --- a/include/lardon3d/task_queue.h +++ b/include/lardon3d/task_queue.h @@ -9,6 +9,14 @@ typedef struct Lardon3DTaskQueue Lardon3DTaskQueue; +typedef enum { + LARDON3D_TASK_QUEUE_ADD_OK = 0, + LARDON3D_TASK_QUEUE_ADD_FULL, + LARDON3D_TASK_QUEUE_ADD_STOPPING, + LARDON3D_TASK_QUEUE_ADD_DUPLICATE_ID, + LARDON3D_TASK_QUEUE_ADD_ERROR +} Lardon3DTaskQueueAddResult; + typedef struct { size_t running; size_t pending; @@ -35,8 +43,15 @@ bool lardon3d_task_queue_try_add( Lardon3DTask *task, uint64_t *task_id ); +Lardon3DTaskQueueAddResult lardon3d_task_queue_try_add_ex( + Lardon3DTaskQueue *queue, + Lardon3DTask *task, + uint64_t *task_id +); bool lardon3d_task_queue_remove(Lardon3DTaskQueue *queue, uint64_t task_id); bool lardon3d_task_queue_cancel(Lardon3DTaskQueue *queue, uint64_t task_id); +bool lardon3d_task_queue_pause(Lardon3DTaskQueue *queue, uint64_t task_id); +bool lardon3d_task_queue_resume(Lardon3DTaskQueue *queue, uint64_t task_id); void lardon3d_task_queue_resources_changed(Lardon3DTaskQueue *queue); size_t lardon3d_task_queue_count(Lardon3DTaskQueue *queue); bool lardon3d_task_queue_get( diff --git a/meson.build b/meson.build index 16e9fe8..de6426d 100644 --- a/meson.build +++ b/meson.build @@ -172,6 +172,10 @@ project_test = executable( 'src/task.c', 'src/task_checkpoint.c', 'src/task_kind_registry.c', + 'src/task_kinds.c', + 'src/task_queue.c', + 'src/import.c', + 'src/import_task.c', 'src/image_catalog.c', 'src/image_view.c', 'src/resource_governor.c', diff --git a/src/project.c b/src/project.c index 41ca0f5..94e102a 100644 --- a/src/project.c +++ b/src/project.c @@ -14,8 +14,10 @@ #include #include #include +#include #include #include +#include enum { MAX_CREATED_DIRECTORIES = 16, @@ -44,6 +46,19 @@ set_status(Lardon3DAppState *state, const char *message) ); } +static void +store_recovery_summary(Lardon3DAppState *state, + const Lardon3DProjectRecoverySummary *summary) +{ + state->recovery_inspected = summary ? summary->inspected : 0; + state->recovery_resumed = summary ? summary->resumed : 0; + state->recovery_skipped = summary ? summary->skipped : 0; + state->recovery_failed = summary ? summary->failed : 0; + state->recovery_published_not_durable = summary + ? summary->published_not_durable : 0; + state->recovery_queue_full = summary && summary->queue_full; +} + static void set_database_status( Lardon3DAppState *state, @@ -475,6 +490,7 @@ lardon3d_project_create(Lardon3DAppState *state, const char *name) clear_catalog(state); state->project_loaded = true; + store_recovery_summary(state, NULL); state->project_db = database; (void)copy_path( state->project_name, @@ -798,12 +814,23 @@ lardon3d_project_open( sizeof(state->project_path), project_path ); - (void)snprintf( - state->status_message, - sizeof(state->status_message), - "Projet ouvert : %s", - state->project_name - ); + store_recovery_summary(state, NULL); + if (state->task_queue && state->resource_governor) { + Lardon3DProjectRecoverySummary summary; + (void)lardon3d_project_resume_recoverable_tasks(state, + lardon3d_task_kind_registry_production(), &summary); + (void)snprintf(state->status_message, sizeof(state->status_message), + "Projet ouvert — %zu tâche(s) reprise(s), %zu ignorée(s), %zu en échec%s.", + summary.resumed, summary.skipped, summary.failed, + summary.queue_full ? ", fenêtre de reprise saturée" : ""); + } else { + (void)snprintf( + state->status_message, + sizeof(state->status_message), + "Projet ouvert : %s", + state->project_name + ); + } return true; } @@ -826,6 +853,7 @@ lardon3d_project_close(Lardon3DAppState *state) state->project_name[0] = '\0'; state->project_path[0] = '\0'; state->project_stable_id[0] = '\0'; + store_recovery_summary(state, NULL); set_status(state, "Projet fermé."); } @@ -1051,3 +1079,109 @@ lardon3d_project_list_recoverable( } return LARDON3D_PROJECT_DB_OK; } + +Lardon3DProjectDbResult +lardon3d_project_resume_recoverable_tasks( + Lardon3DAppState *state, + const Lardon3DTaskKindRegistry *registry, + Lardon3DProjectRecoverySummary *summary +) +{ + if (summary) memset(summary, 0, sizeof(*summary)); + if (!state || !state->project_loaded || !state->project_db + || !state->task_queue || !state->resource_governor || !registry + || !summary) { + return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; + } + enum { RECOVERY_PAGE_SIZE = 8 }; + uint64_t cursor = 0; + Lardon3DProjectDbResult result = LARDON3D_PROJECT_DB_OK; + bool stop = false; + do { + Lardon3DProjectRecoveryEntry entries[RECOVERY_PAGE_SIZE]; + size_t count = 0; + result = lardon3d_project_list_recoverable(state, registry, cursor, + entries, RECOVERY_PAGE_SIZE, &count); + if (result != LARDON3D_PROJECT_DB_OK) { + ++summary->failed; + break; + } + for (size_t index = 0; index < count; ++index) { + Lardon3DProjectRecoveryEntry *entry = &entries[index]; + cursor = entry->task_id; + ++summary->inspected; + bool recoverable = entry->status == LARDON3D_PROJECT_RECOVERABLE + || entry->status + == LARDON3D_PROJECT_RECOVERABLE_PUBLISHED_NOT_DURABLE; + if (!recoverable || entry->snapshot.recovery_state != TASK_PENDING) { + ++summary->skipped; + continue; + } + if (entry->status + == LARDON3D_PROJECT_RECOVERABLE_PUBLISHED_NOT_DURABLE) { + ++summary->published_not_durable; + } + Lardon3DTaskSnapshot existing; + if (lardon3d_task_queue_get(state->task_queue, entry->task_id, + &existing)) { + ++summary->skipped; + continue; + } + Lardon3DImageImportReconstructionContext context = { + .project_path = state->project_path, + .project_db = state->project_db, + .resource_governor = state->resource_governor, + }; + Lardon3DTask *task = NULL; + Lardon3DTaskKindResult restored = + lardon3d_task_kind_registry_restore(registry, + entry->task_kind, entry->task_kind_version, + &entry->snapshot, &context, &task); + if (restored != LARDON3D_TASK_KIND_OK || !task) { + ++summary->failed; + continue; + } + Lardon3DTaskQueueAddResult added = + lardon3d_task_queue_try_add_ex(state->task_queue, task, NULL); + if (added == LARDON3D_TASK_QUEUE_ADD_OK) { + ++summary->resumed; + continue; + } + lardon3d_task_destroy(task); + if (added == LARDON3D_TASK_QUEUE_ADD_DUPLICATE_ID) { + ++summary->skipped; + } else if (added == LARDON3D_TASK_QUEUE_ADD_FULL) { + ++summary->skipped; + } else { + ++summary->failed; + } + if (added == LARDON3D_TASK_QUEUE_ADD_FULL + || added == LARDON3D_TASK_QUEUE_ADD_STOPPING) { + summary->queue_full = added == LARDON3D_TASK_QUEUE_ADD_FULL; + stop = true; + break; + } + } + if (stop || count < RECOVERY_PAGE_SIZE) break; + } while (cursor > 0); + store_recovery_summary(state, summary); + return result; +} + +bool +lardon3d_project_last_recovery_summary( + const Lardon3DAppState *state, + Lardon3DProjectRecoverySummary *summary +) +{ + if (!state || !summary) return false; + *summary = (Lardon3DProjectRecoverySummary) { + .inspected = state->recovery_inspected, + .resumed = state->recovery_resumed, + .skipped = state->recovery_skipped, + .failed = state->recovery_failed, + .published_not_durable = state->recovery_published_not_durable, + .queue_full = state->recovery_queue_full, + }; + return true; +} diff --git a/src/project_db.c b/src/project_db.c index a519798..75535f5 100644 --- a/src/project_db.c +++ b/src/project_db.c @@ -565,6 +565,12 @@ lardon3d_project_db_list_recoverable(Lardon3DProjectDb *database, uint64_t after { 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; +#ifdef LARDON3D_PROJECT_DB_TESTING + const char *forced_busy = getenv("LARDON3D_TEST_PROJECT_DB_BUSY_RECOVERY"); + if (forced_busy && strcmp(forced_busy, "1") == 0) { + return LARDON3D_PROJECT_DB_BUSY; + } +#endif (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); diff --git a/src/task.c b/src/task.c index ff73884..8c6adc5 100644 --- a/src/task.c +++ b/src/task.c @@ -189,7 +189,21 @@ lardon3d_task_destroy(Lardon3DTask *task) if (!task) { return; } - lardon3d_task_request_cancel(task); + (void)pthread_mutex_lock(&task->mutex); + if (!is_terminal(task->state)) { + if (task->executing) { + task->cancel_requested = true; + copy_text(task->message, sizeof(task->message), + "Annulation demandée."); + (void)pthread_cond_broadcast(&task->condition); + } else { + /* Une tâche locale jamais soumise peut être abandonnée sans + * publier une fausse annulation métier. */ + finish_locked(task, TASK_CANCELLED, "Tâche abandonnée."); + task->finished_notified = true; + } + } + (void)pthread_mutex_unlock(&task->mutex); (void)lardon3d_task_join(task); (void)pthread_cond_destroy(&task->condition); (void)pthread_mutex_destroy(&task->mutex); diff --git a/src/task_queue.c b/src/task_queue.c index 96b41a4..6341c52 100644 --- a/src/task_queue.c +++ b/src/task_queue.c @@ -228,6 +228,35 @@ lardon3d_task_queue_cancel(Lardon3DTaskQueue *queue, uint64_t task_id) return node != NULL; } +bool +lardon3d_task_queue_pause(Lardon3DTaskQueue *queue, uint64_t task_id) +{ + if (!queue || task_id == 0) return false; + (void)pthread_mutex_lock(&queue->mutex); + TaskNode *node = queue->all_head; + while (node && lardon3d_task_id(node->task) != task_id) { + node = node->next_all; + } + bool paused = node && lardon3d_task_pause(node->task); + (void)pthread_mutex_unlock(&queue->mutex); + return paused; +} + +bool +lardon3d_task_queue_resume(Lardon3DTaskQueue *queue, uint64_t task_id) +{ + if (!queue || task_id == 0) return false; + (void)pthread_mutex_lock(&queue->mutex); + TaskNode *node = queue->all_head; + while (node && lardon3d_task_id(node->task) != task_id) { + node = node->next_all; + } + bool resumed = node && lardon3d_task_resume(node->task); + if (resumed) (void)pthread_cond_broadcast(&queue->not_empty); + (void)pthread_mutex_unlock(&queue->mutex); + return resumed; +} + void lardon3d_task_queue_resources_changed(Lardon3DTaskQueue *queue) { @@ -356,22 +385,50 @@ lardon3d_task_queue_try_add( Lardon3DTask *task, uint64_t *task_id ) +{ + return lardon3d_task_queue_try_add_ex(queue, task, task_id) + == LARDON3D_TASK_QUEUE_ADD_OK; +} + +Lardon3DTaskQueueAddResult +lardon3d_task_queue_try_add_ex( + Lardon3DTaskQueue *queue, + Lardon3DTask *task, + uint64_t *task_id +) { if (!queue || !task) { - return false; + return LARDON3D_TASK_QUEUE_ADD_ERROR; } TaskNode *node = calloc(1, sizeof(*node)); if (!node) { - return false; + return LARDON3D_TASK_QUEUE_ADD_ERROR; } (void)pthread_mutex_lock(&queue->mutex); - bool accepted = queue->pending_count < queue->capacity - && enqueue_locked(queue, node, task, task_id); + Lardon3DTaskQueueAddResult result = LARDON3D_TASK_QUEUE_ADD_OK; + if (queue->stopping) { + result = LARDON3D_TASK_QUEUE_ADD_STOPPING; + } else if (queue->pending_count >= queue->capacity) { + result = LARDON3D_TASK_QUEUE_ADD_FULL; + } else { + uint64_t id = lardon3d_task_id(task); + for (TaskNode *existing = queue->all_head; existing; + existing = existing->next_all) { + if (id != 0 && lardon3d_task_id(existing->task) == id) { + result = LARDON3D_TASK_QUEUE_ADD_DUPLICATE_ID; + break; + } + } + if (result == LARDON3D_TASK_QUEUE_ADD_OK + && !enqueue_locked(queue, node, task, task_id)) { + result = LARDON3D_TASK_QUEUE_ADD_ERROR; + } + } (void)pthread_mutex_unlock(&queue->mutex); - if (!accepted) { + if (result != LARDON3D_TASK_QUEUE_ADD_OK) { free(node); } - return accepted; + return result; } bool diff --git a/tests/test_import_task.c b/tests/test_import_task.c index 0a1a22f..0dff608 100644 --- a/tests/test_import_task.c +++ b/tests/test_import_task.c @@ -12,6 +12,7 @@ #include #include #include +#include #include #define CHECK(condition) do { if (!(condition)) { \ @@ -55,7 +56,7 @@ remove_tree(const char *path) } static bool -setup_runtime(Lardon3DAppState *state) +setup_runtime(Lardon3DAppState *state, size_t queue_capacity) { state->hardware_profile = (Lardon3DHardwareProfile) { .logical_cpu_count = 1024, @@ -71,7 +72,8 @@ setup_runtime(Lardon3DAppState *state) state->resource_governor = lardon3d_resource_governor_create( &state->hardware_profile, &policy); state->task_queue = state->resource_governor - ? lardon3d_task_queue_create(state->resource_governor, 4) : NULL; + ? lardon3d_task_queue_create(state->resource_governor, queue_capacity) + : NULL; return state->task_queue != NULL; } @@ -101,7 +103,7 @@ run_test(void) } CHECK(setenv("LARDON3D_PROJECTS_ROOT", root, 1) == 0); Lardon3DAppState state; lardon3d_app_state_init(&state); - CHECK(setup_runtime(&state)); + CHECK(setup_runtime(&state, 4)); CHECK(lardon3d_project_create(&state, "Persistent Import")); CHECK(setenv("LARDON3D_TEST_IMPORT_PAUSE_AFTER_BATCH", "1", 1) == 0); @@ -113,42 +115,45 @@ run_test(void) CHECK(wait_for_state(state.task_queue, task_id, TASK_PAUSED, &runtime)); CHECK(runtime.progress > 0 && runtime.progress < 100); lardon3d_task_queue_destroy(state.task_queue); state.task_queue = NULL; + char checkpoint_path[PATH_MAX]; + CHECK(snprintf(checkpoint_path, sizeof(checkpoint_path), + "%s/.lardon3d/checkpoints/%llu.chk", state.project_path, + (unsigned long long)task_id) > 0); + Lardon3DTaskDurableSnapshot persisted_snapshot; + CHECK(lardon3d_task_checkpoint_load(checkpoint_path, &persisted_snapshot, + NULL) == LARDON3D_TASK_CHECKPOINT_OK); + Lardon3DProjectDbCheckpoint published_not_durable = { + .format_version = LARDON3D_TASK_CHECKPOINT_VERSION, + .durability = LARDON3D_DB_CHECKPOINT_PUBLISHED_NOT_DURABLE, + .updated_at = 1, + }; + CHECK(snprintf(published_not_durable.path, + sizeof(published_not_durable.path), + ".lardon3d/checkpoints/%llu.chk", + (unsigned long long)task_id) > 0); + CHECK(lardon3d_project_db_record_image_import_task(state.project_db, + &persisted_snapshot, LARDON3D_IMAGE_IMPORT_TASK_KIND, + LARDON3D_IMAGE_IMPORT_TASK_KIND_VERSION, &published_not_durable, + source, 1) == LARDON3D_PROJECT_DB_OK); lardon3d_project_close(&state); CHECK(unsetenv("LARDON3D_TEST_IMPORT_PAUSE_AFTER_BATCH") == 0); CHECK(unsetenv("LARDON3D_TEST_IMPORT_SKIP_FINISHED_CHECKPOINT") == 0); state.task_queue = lardon3d_task_queue_create(state.resource_governor, 4); + CHECK(setenv("LARDON3D_TEST_IMPORT_PAUSE_AFTER_BATCH", "1", 1) == 0); CHECK(state.task_queue && lardon3d_project_open(&state, "Persistent Import")); - Lardon3DProjectRecoveryEntry entry; size_t count = 0; - const Lardon3DTaskKindRegistry *registry = - lardon3d_task_kind_registry_production(); - CHECK(lardon3d_project_list_recoverable(&state, registry, 0, &entry, 1, - &count) == LARDON3D_PROJECT_DB_OK && count == 1); - CHECK(entry.task_id == task_id - && strcmp(entry.task_kind, LARDON3D_IMAGE_IMPORT_TASK_KIND) == 0 - && entry.task_kind_version == LARDON3D_IMAGE_IMPORT_TASK_KIND_VERSION); + Lardon3DProjectRecoverySummary recovery; + CHECK(lardon3d_project_last_recovery_summary(&state, &recovery)); + CHECK(recovery.inspected == 1 && recovery.resumed == 1 + && recovery.skipped == 0 && recovery.failed == 0 + && recovery.published_not_durable == 1); + CHECK(wait_for_state(state.task_queue, task_id, TASK_PAUSED, &runtime)); + CHECK(unsetenv("LARDON3D_TEST_IMPORT_PAUSE_AFTER_BATCH") == 0); + CHECK(lardon3d_task_queue_resume(state.task_queue, task_id)); Lardon3DProjectDbImageImport persisted_parameters; CHECK(lardon3d_project_db_load_image_import(state.project_db, task_id, &persisted_parameters) == LARDON3D_PROJECT_DB_OK); CHECK(strcmp(persisted_parameters.source_path, source) == 0); - Lardon3DImageImportReconstructionContext reconstruction = { - .project_path = state.project_path, - .project_db = state.project_db, - .resource_governor = state.resource_governor, - }; - Lardon3DTask *restored = NULL; - char unavailable_source[PATH_MAX]; - CHECK(join_path(unavailable_source, root, "source-unavailable")); - CHECK(rename(source, unavailable_source) == 0); - CHECK(lardon3d_task_kind_registry_restore(registry, entry.task_kind, - entry.task_kind_version, &entry.snapshot, &reconstruction, &restored) - == LARDON3D_TASK_KIND_RECONSTRUCTION_FAILED); - CHECK(!restored && rename(unavailable_source, source) == 0); - CHECK(lardon3d_task_kind_registry_restore(registry, entry.task_kind, - entry.task_kind_version, &entry.snapshot, &reconstruction, &restored) - == LARDON3D_TASK_KIND_OK); - CHECK(restored && lardon3d_task_id(restored) == task_id); - CHECK(lardon3d_task_queue_add(state.task_queue, restored, NULL)); CHECK(wait_for_state(state.task_queue, task_id, TASK_COMPLETED, &runtime)); CHECK(runtime.progress == 100); @@ -162,8 +167,8 @@ run_test(void) state.task_queue = lardon3d_task_queue_create(state.resource_governor, 4); CHECK(state.task_queue && lardon3d_project_open(&state, "Persistent Import")); - CHECK(lardon3d_project_list_recoverable(&state, registry, 0, &entry, 1, - &count) == LARDON3D_PROJECT_DB_OK && count == 0); + CHECK(lardon3d_project_last_recovery_summary(&state, &recovery) + && recovery.inspected == 0 && recovery.resumed == 0); lardon3d_task_queue_destroy(state.task_queue); state.task_queue = NULL; lardon3d_project_close(&state); lardon3d_resource_governor_destroy(state.resource_governor); @@ -172,4 +177,117 @@ run_test(void) return true; } -int main(void) { return run_test() ? EXIT_SUCCESS : EXIT_FAILURE; } +static bool +test_selective_capacity_one(void) +{ + char root[] = "/tmp/lardon3d-import-recovery-window-XXXXXX"; + CHECK(mkdtemp(root)); + char missing_source[PATH_MAX], valid_source[PATH_MAX], second_valid_source[PATH_MAX]; + char unavailable_source[PATH_MAX]; + CHECK(join_path(missing_source, root, "missing-source")); + CHECK(join_path(valid_source, root, "valid-source")); + CHECK(join_path(second_valid_source, root, "second-valid-source")); + CHECK(join_path(unavailable_source, root, "source-unavailable")); + CHECK(mkdir(missing_source, 0700) == 0 + && mkdir(valid_source, 0700) == 0 + && mkdir(second_valid_source, 0700) == 0); + for (unsigned int index = 0; index < 40; ++index) { + char name[32], path[PATH_MAX]; + CHECK(snprintf(name, sizeof(name), "missing-%03u.jpg", index) > 0); + CHECK(join_path(path, missing_source, name) + && write_fixture(path, index)); + CHECK(snprintf(name, sizeof(name), "valid-%03u.jpg", index) > 0); + CHECK(join_path(path, valid_source, name) + && write_fixture(path, index + 100)); + CHECK(snprintf(name, sizeof(name), "second-%03u.jpg", index) > 0); + CHECK(join_path(path, second_valid_source, name) + && write_fixture(path, index + 200)); + } + CHECK(setenv("LARDON3D_PROJECTS_ROOT", root, 1) == 0); + Lardon3DAppState state; + lardon3d_app_state_init(&state); + CHECK(setup_runtime(&state, 4)); + CHECK(lardon3d_project_create(&state, "Selective Recovery")); + CHECK(setenv("LARDON3D_TEST_IMPORT_PAUSE_AFTER_BATCH", "1", 1) == 0); + CHECK(setenv("LARDON3D_TEST_IMPORT_SKIP_FINISHED_CHECKPOINT", "1", 1) + == 0); + uint64_t missing_id = 0, valid_id = 0, second_valid_id = 0; + CHECK(lardon3d_project_enqueue_image_import(&state, missing_source, + &missing_id)); + CHECK(lardon3d_project_enqueue_image_import(&state, valid_source, + &valid_id)); + CHECK(lardon3d_project_enqueue_image_import(&state, second_valid_source, + &second_valid_id)); + Lardon3DTaskSnapshot snapshot; + CHECK(wait_for_state(state.task_queue, missing_id, TASK_PAUSED, &snapshot)); + lardon3d_task_queue_destroy(state.task_queue); + state.task_queue = NULL; + lardon3d_project_close(&state); + CHECK(unsetenv("LARDON3D_TEST_IMPORT_PAUSE_AFTER_BATCH") == 0); + CHECK(unsetenv("LARDON3D_TEST_IMPORT_SKIP_FINISHED_CHECKPOINT") == 0); + CHECK(rename(missing_source, unavailable_source) == 0); + + const Lardon3DResourceEstimate held_estimate = { + .minimum_batch_size = 1, + .maximum_batch_size = 1, + .desired_cpu_threads = 1, + .desired_io_slots = 1, + .task_class = LARDON3D_RESOURCE_TASK_IMPORT, + }; + Lardon3DResourceSnapshot available = { + .memory_available_bytes = UINT64_MAX, + .cpu_load_1m = 0.0, + }; + Lardon3DResourceDecision decision; + Lardon3DResourceReservation *held = NULL; + CHECK(lardon3d_resource_governor_reserve(state.resource_governor, + &available, &held_estimate, &decision, &held)); + state.task_queue = lardon3d_task_queue_create(state.resource_governor, 1); + CHECK(state.task_queue + && lardon3d_project_open(&state, "Selective Recovery")); + Lardon3DProjectRecoverySummary recovery; + CHECK(lardon3d_project_last_recovery_summary(&state, &recovery)); + CHECK(recovery.inspected == 3 && recovery.resumed == 1 + && recovery.skipped == 1 && recovery.failed == 1 + && recovery.queue_full); + CHECK(lardon3d_resource_governor_release(state.resource_governor, held)); + lardon3d_task_queue_resources_changed(state.task_queue); + CHECK(wait_for_state(state.task_queue, valid_id, TASK_COMPLETED, &snapshot)); + CHECK(snapshot.id == valid_id && snapshot.progress == 100); + CHECK(!lardon3d_task_queue_get(state.task_queue, missing_id, &snapshot)); + lardon3d_task_queue_destroy(state.task_queue); + state.task_queue = NULL; + lardon3d_project_close(&state); + + state.task_queue = lardon3d_task_queue_create(state.resource_governor, 1); + CHECK(state.task_queue + && lardon3d_project_open(&state, "Selective Recovery")); + CHECK(lardon3d_project_last_recovery_summary(&state, &recovery)); + CHECK(recovery.inspected == 2 && recovery.resumed == 1 + && recovery.failed == 1); + CHECK(wait_for_state(state.task_queue, second_valid_id, TASK_COMPLETED, + &snapshot)); + lardon3d_task_queue_destroy(state.task_queue); + state.task_queue = NULL; + lardon3d_project_close(&state); + + state.task_queue = lardon3d_task_queue_create(state.resource_governor, 1); + CHECK(state.task_queue + && lardon3d_project_open(&state, "Selective Recovery")); + CHECK(lardon3d_project_last_recovery_summary(&state, &recovery)); + CHECK(recovery.inspected == 1 && recovery.resumed == 0 + && recovery.failed == 1); + lardon3d_task_queue_destroy(state.task_queue); + state.task_queue = NULL; + lardon3d_project_close(&state); + lardon3d_resource_governor_destroy(state.resource_governor); + CHECK(unsetenv("LARDON3D_PROJECTS_ROOT") == 0); + CHECK(remove_tree(root)); + return true; +} + +int main(void) +{ + return run_test() && test_selective_capacity_one() + ? EXIT_SUCCESS : EXIT_FAILURE; +} diff --git a/tests/test_project.c b/tests/test_project.c index 3c04cd1..8b4c282 100644 --- a/tests/test_project.c +++ b/tests/test_project.c @@ -10,6 +10,7 @@ #include #include +#include #define CHECK(condition) do { if (!(condition)) { \ (void)fprintf(stderr, "Échec ligne %d : %s\n", __LINE__, #condition); return false; \ @@ -220,6 +221,33 @@ run_test(void) CHECK(lardon3d_project_open(&state, "Projet Cycle")); CHECK(lardon3d_project_list_recoverable(&state, ®istry, 0, entries, 2, &count) == LARDON3D_PROJECT_DB_OK); CHECK(count == 1 && entries[0].status == LARDON3D_PROJECT_RECOVERABLE); + Lardon3DHardwareProfile recovery_profile = { + .logical_cpu_count = 2, + .page_size_bytes = 4096, + .memory_total_bytes = UINT64_MAX, + .cpu_architecture = "test", + }; + Lardon3DResourcePolicy recovery_policy = { + .maximum_cpu_load_ratio = 1.0, + .maximum_io_pressure_avg10 = 100.0, + .io_slot_capacity = 1, + }; + state.resource_governor = lardon3d_resource_governor_create( + &recovery_profile, &recovery_policy); + state.task_queue = state.resource_governor + ? lardon3d_task_queue_create(state.resource_governor, 1) : NULL; + CHECK(state.task_queue); + Lardon3DProjectRecoverySummary busy_summary; + CHECK(setenv("LARDON3D_TEST_PROJECT_DB_BUSY_RECOVERY", "1", 1) == 0); + CHECK(lardon3d_project_resume_recoverable_tasks(&state, ®istry, + &busy_summary) == LARDON3D_PROJECT_DB_BUSY); + CHECK(unsetenv("LARDON3D_TEST_PROJECT_DB_BUSY_RECOVERY") == 0); + CHECK(busy_summary.inspected == 0 && busy_summary.resumed == 0 + && busy_summary.failed == 1); + lardon3d_task_queue_destroy(state.task_queue); + state.task_queue = NULL; + lardon3d_resource_governor_destroy(state.resource_governor); + state.resource_governor = NULL; CHECK(strcmp(entries[0].task_kind, "test.persisted") == 0 && entries[0].task_kind_version == 1); Lardon3DTask *restored = NULL; @@ -275,6 +303,25 @@ run_test(void) == LARDON3D_PROJECT_RECOVERY_UNSUPPORTED_TASK_KIND_VERSION); CHECK(negative_entries[3].task_id == 6 && negative_entries[3].status == LARDON3D_PROJECT_RECOVERY_LEGACY_UNTYPED); + state.resource_governor = lardon3d_resource_governor_create( + &recovery_profile, &recovery_policy); + state.task_queue = state.resource_governor + ? lardon3d_task_queue_create(state.resource_governor, 4) : NULL; + CHECK(state.task_queue); + Lardon3DProjectRecoverySummary selective_summary; + CHECK(lardon3d_project_resume_recoverable_tasks(&state, ®istry, + &selective_summary) == LARDON3D_PROJECT_DB_OK); + CHECK(selective_summary.inspected == 4 && selective_summary.resumed == 1 + && selective_summary.skipped == 3 && selective_summary.failed == 0); + Lardon3DTaskSnapshot resumed_snapshot; + CHECK(lardon3d_task_queue_get(state.task_queue, 1, &resumed_snapshot)); + CHECK(lardon3d_project_resume_recoverable_tasks(&state, ®istry, + &selective_summary) == LARDON3D_PROJECT_DB_OK); + CHECK(selective_summary.resumed == 0 && selective_summary.skipped == 4); + lardon3d_task_queue_destroy(state.task_queue); + state.task_queue = NULL; + lardon3d_resource_governor_destroy(state.resource_governor); + state.resource_governor = NULL; lardon3d_task_destroy(legacy); lardon3d_task_destroy(future_kind); lardon3d_task_destroy(unknown); diff --git a/tests/test_task_queue.c b/tests/test_task_queue.c index 9759819..1d72c65 100644 --- a/tests/test_task_queue.c +++ b/tests/test_task_queue.c @@ -233,7 +233,8 @@ run_test(void) Lardon3DTask *duplicate = lardon3d_task_create( "Doublon", &estimate, queue_callback, &restored_work); CHECK(duplicate && lardon3d_task_assign_id(duplicate, 100)); - CHECK(!lardon3d_task_queue_add(queue, duplicate, NULL)); + CHECK(lardon3d_task_queue_try_add_ex(queue, duplicate, NULL) + == LARDON3D_TASK_QUEUE_ADD_DUPLICATE_ID); lardon3d_task_destroy(duplicate); CHECK(wait_terminal(queue, 100, &snapshot)); CHECK(lardon3d_task_queue_remove(queue, 100));