v0.3.6-pre.012

This commit is contained in:
2026-09-01 17:42:24 +02:00
parent 9506df9487
commit 42374115c7
18 changed files with 869 additions and 88 deletions

View File

@@ -0,0 +1,72 @@
<!-- file: crates/ksp-job-api/README.md -->
<!-- version: 1 -->
# ksp-job-api
`ksp-job-api` fournit les contrats passifs et runtime-neutral communs aux jobs bornés de KSP.
La crate possède l'identité logique d'un job, son lifecycle terminable, l'intention d'annulation coopérative et le contrat latest-value utilisé pour observer un snapshot complet. Elle ne possède aucun runtime concret, aucune politique métier de backfill, aucun Transport, aucun Store et aucun Worker.
## Responsabilités
La façade crate-root expose :
- `JobId` et `JobKindCode`, bornés et validés ;
- `JobState` et `JobCompletion` ;
- `JobLifecycle`, propriétaire des transitions admises ;
- `JobCancellationToken`, cloneable et idempotent ;
- `JobNotificationSequence`, strictement monotone et sans wrap silencieux ;
- `JobNotification<S>`, valeur observable complète à une position donnée ;
- `JobSnapshotSource`, contrat runtime-neutral de lecture courante et attente d'une valeur plus récente ;
- les codes d'erreur Job et les types `Error`/`Result` communs de Core.
## Lifecycle
Le lifecycle admis reste explicitement borné :
```text
Created
| | +----------------------> Cancelled
v
Running
| | +----> Cancelling -----> Cancelled
| | | | +---------> Failed
| +-------------> Completed(Complete|Partial)
|
+------------------------> Completed(Complete|Partial)
+------------------------> Failed
```
Tout état terminal est immuable. Une transition invalide retourne `ERROR_CODE_JOB_TRANSITION_INVALID` sans modifier l'état source.
## Observation latest-value
`JobSnapshotSource` n'impose ni callback, ni queue d'événements, ni runtime async particulier. Un listener peut :
1. lire la valeur complète courante ;
2. mémoriser sa `JobNotificationSequence` ;
3. attendre une valeur plus récente ;
4. recevoir directement la dernière valeur complète, même si plusieurs mises à jour intermédiaires ont été coalescées.
Le snapshot concret appartient au job qui implémente la source. `ksp-job-api` ne connaît pas son contenu.
## Annulation
`JobCancellationToken` représente uniquement une intention coopérative partagée. Il ne tue pas une tâche, n'annule pas une I/O par lui-même et ne décide pas du résultat terminal. Le runtime concret reste responsable d'observer le token aux frontières sûres et de publier son état final.
## Firewall
La dépendance normale est volontairement minimale :
```text
ksp-job-api
-> ksp-core-lib
```
La crate ne dépend pas de Tokio, Futures, Transport, Store, Config, Logging, serde ni d'une crate de job concret.
## Documentation
- [`USAGE.md`](USAGE.md) — utilisation durable des contrats Job ;
- [`../../docs/architecture/003-COMPONENT_CONTRACTS.md`](../../docs/architecture/003-COMPONENT_CONTRACTS.md) — contrats de composants ;
- [`../../docs/architecture/009-ACQUISITION_WORKERS_AND_JOBS.md`](../../docs/architecture/009-ACQUISITION_WORKERS_AND_JOBS.md) — séparation workers/jobs et premier backfill RAW.

141
crates/ksp-job-api/USAGE.md Normal file
View File

@@ -0,0 +1,141 @@
<!-- file: crates/ksp-job-api/USAGE.md -->
<!-- version: 1 -->
# Utilisation de ksp-job-api
Cette page décrit la façade publique durable de `ksp-job-api`. Les consumers utilisent uniquement les exports du crate-root.
## Construire une identité de job
```rust
fn job_identity() -> ksp_job_api::Result<(ksp_job_api::JobId, ksp_job_api::JobKindCode)> {
let id = match ksp_job_api::JobId::new("raw-backfill-mainnet-0001") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let kind = match ksp_job_api::JobKindCode::new("raw_transaction_backfill") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok((id, kind));
}
```
`JobId` identifie un job logique et ses reprises contrôlées. `JobKindCode` identifie une famille de jobs. Les deux sont bornés et utilisent un alphabet sûr ; leur `Debug` n'est pas une surface destinée à transporter un payload métier.
## Piloter un lifecycle passif
```rust
fn lifecycle(id: ksp_job_api::JobId, kind: ksp_job_api::JobKindCode) -> ksp_job_api::Result<ksp_job_api::JobLifecycle> {
let mut lifecycle = ksp_job_api::JobLifecycle::new(id, kind);
if let std::result::Result::Err(error) = lifecycle.start() {
return std::result::Result::Err(error);
}
if let std::result::Result::Err(error) = lifecycle.complete(ksp_job_api::JobCompletion::Complete) {
return std::result::Result::Err(error);
}
return std::result::Result::Ok(lifecycle);
}
```
Le producer ne doit pas forcer un état directement. Il utilise les opérations de `JobLifecycle`, qui refusent les transitions hors matrice.
Pour une annulation observée pendant l'exécution :
```rust
if let std::result::Result::Err(error) = lifecycle.mark_cancelling() {
return std::result::Result::Err(error);
}
if let std::result::Result::Err(error) = lifecycle.mark_cancelled() {
return std::result::Result::Err(error);
}
```
## Partager une intention d'annulation
```rust
let token = ksp_job_api::JobCancellationToken::new();
let listener = token.clone();
assert!(!listener.is_cancellation_requested());
assert!(token.request_cancellation());
assert!(listener.is_cancellation_requested());
assert!(!token.request_cancellation());
```
Le premier appel qui change l'état retourne `true`. Les demandes suivantes sont idempotentes et retournent `false`.
Le token ne doit pas être interprété comme une primitive de kill : le runtime concret décide où l'annulation peut interrompre l'admission ou une attente et quelles opérations déjà engagées doivent être drainées.
## Publier une valeur latest-value
Un producer concret peut construire une notification complète :
```rust
#[derive(Clone)]
struct Snapshot {
completed: usize,
}
let id = ksp_job_api::JobId::new("job-0001")?;
let kind = ksp_job_api::JobKindCode::new("example")?;
let sequence = ksp_job_api::JobNotificationSequence::initial();
let notification = ksp_job_api::JobNotification::new(
id,
kind,
sequence,
ksp_job_api::JobState::Running,
Snapshot { completed: 0 },
);
assert_eq!(notification.sequence().value(), 0);
assert_eq!(notification.state(), ksp_job_api::JobState::Running);
```
Le `Debug` de `JobNotification<S>` masque volontairement le snapshot. Le type concret `S` doit lui-même rester sûr à exposer lorsque le consumer accède explicitement à `snapshot()`.
## Implémenter une source de snapshots
Une implémentation concrète possède son runtime et expose seulement le contrat `JobSnapshotSource` :
```rust
async fn observe<S>(source: &S)
where
S: ksp_job_api::JobSnapshotSource,
{
let current = source.current();
let observed = current.sequence();
let newer = source.wait_for_change(observed).await;
assert!(newer.sequence().is_after(observed));
}
```
`wait_for_change` retourne la dernière valeur complète connue après coalescence éventuelle. Un consumer ne doit donc pas supposer qu'il recevra chaque mise à jour intermédiaire.
## Faire avancer une séquence
```rust
let first = ksp_job_api::JobNotificationSequence::initial();
let second = match first.next() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
assert!(second.is_after(first));
```
L'épuisement de `u64` est une erreur explicite ; la séquence ne wrappe jamais silencieusement.
## Frontières à respecter
Ne pas ajouter à `ksp-job-api` :
```text
Tokio/Futures runtime concret
Transport ou Store
Config/Logging
DTO métier d'un job précis
scheduler, worker ou control plane
persistence de checkpoint
```
Ces responsabilités appartiennent aux crates concrètes et à la composition supérieure.

View File

@@ -0,0 +1,123 @@
<!-- file: crates/ksp-job-backfill-lib/README.md -->
<!-- version: 1 -->
# ksp-job-backfill-lib
`ksp-job-backfill-lib` implémente le premier job historique concret de KSP : un backfill borné de transactions Solana vers la couche RAW durable.
La crate compose les contrats Job, Transport observé et Store backend-neutral sans posséder leurs politiques internes. Elle couvre l'admission, la découverte historique, l'hydratation `getTransaction`, la conversion RAW v1, la persistance atomique, la concurrence bornée, la frontier contiguë, le checkpoint caller-owned, l'annulation coopérative et les snapshots latest-value.
## Identité
L'identité logique d'une transaction reste :
```text
(RawNetworkId, RawTransactionSignature)
```
Le rôle HTTP, le provider, l'endpoint et le protocole ne font pas partie de cette identité. Ils décrivent la sélection et/ou la provenance d'acquisition.
Le fingerprint d'un scope est déterministe sur sa sémantique de découverte et son réseau ; il n'intègre pas `JobId`, provider, endpoint, protocole ou rôle Transport.
## Scopes
Quatre scopes sont exposés :
```text
LatestAddress
BeforeAddress
AfterAddress
ExplicitSignatures
```
La requête borne explicitement page size, nombre de pages, nombre de candidats et concurrence d'hydratation. Les signatures explicites sont dédupliquées de façon stable à la première occurrence.
## Hydratation et RAW v1
L'hydratation passe uniquement par la voie observée de `ksp-onchain-transport-lib`.
Un résultat disponible produit en mémoire :
- un `RawTransaction` canonique ;
- une `RawTransactionObservation` liée à la même référence logique ;
- une provenance contenant le provider et l'endpoint réellement gagnants ;
- un payload JSON canonique `ksp.solana.raw_transaction`, version `1` ;
- un digest SHA-256 des bytes canoniques.
Un `getTransaction = null` devient `BackfillHydrationOutcome::Missing` et ne fabrique ni payload ni provenance.
## Persistance
La persistance utilise exclusivement `ksp-store-lib` avec `default-features = false` côté dépendance de crate :
```text
ksp-job-backfill-lib
-> ksp-store-lib
-X-> backend imposé par le job
```
Le chemin d'écriture est l'acquisition atomique `RawTransaction + RawTransactionObservation` en mode normal. La crate distingue insertion, déjà présent, tombstone purgé, observation nouvelle/existante, missing et conflit. Un conflit de contenu n'est jamais converti en succès idempotent.
## Concurrence et checkpoint
L'exécution maintient au plus `hydration_concurrency` candidats actifs. Les terminaisons hors ordre sont réconciliées par index stable ; le checkpoint n'avance que sur un préfixe **contigu** d'outcomes durablement sûrs.
`BackfillCheckpoint` est opaque et caller-owned. Il lie :
```text
JobId
scope fingerprint
completed contiguous prefix
private Before resume cursor
```
La crate ne persiste pas elle-même ce checkpoint. Une reprise crash-safe durable nécessite donc que le caller choisisse explicitement où conserver le checkpoint retourné.
## Runtime et annulation
`BackfillJobRuntime` est un coordinator single-run. `BackfillJobHandle` peut être cloné pour :
- demander une annulation coopérative ;
- lire l'état de cette demande ;
- obtenir une source latest-value indépendante.
L'annulation arrête l'admission de nouveau travail et peut interrompre certaines attentes pré-Store. Une persistance Store déjà soumise est toujours drainée avant publication terminale.
Les snapshots exposent uniquement des compteurs, phases, boundary, checkpoint et code d'échec sûrs ; ils ne contiennent ni payload RAW, ni URL/credential Transport.
## Dépendances
Les dépendances normales sont :
```text
ksp-core-lib
ksp-job-api
ksp-logging-lib
ksp-onchain-transport-lib
ksp-store-lib (default-features = false)
futures-util
serde_json
sha2
tokio (macros + sync, détail runtime privé)
```
La crate ne dépend pas de Config, `ksp-store-api` directement, `ksp-store-postgres-lib` ni d'un SDK provider.
## Hors périmètre
La crate ne possède pas :
- application desktop ou CLI ;
- Worker API / worker live ;
- retry, pacing ou sélection d'endpoint Transport ;
- backend Store concret ;
- decoding Program ou matérialisation CORE/DECODE/SPECIALIZED ;
- table dédiée de checkpoint ;
- control plane Job générique.
## Documentation
- [`USAGE.md`](USAGE.md) — construction des scopes/requêtes, runtime, snapshots et reprise ;
- [`../ksp-job-api/README.md`](../ksp-job-api/README.md) — contrats Job runtime-neutral ;
- [`../../docs/architecture/009-ACQUISITION_WORKERS_AND_JOBS.md`](../../docs/architecture/009-ACQUISITION_WORKERS_AND_JOBS.md) — architecture durable acquisition/jobs ;
- [`../../docs/architecture/008-DATA_MATERIALIZATION_AND_STORE.md`](../../docs/architecture/008-DATA_MATERIALIZATION_AND_STORE.md) — frontière RAW/Store.

View File

@@ -0,0 +1,206 @@
<!-- file: crates/ksp-job-backfill-lib/USAGE.md -->
<!-- version: 2 -->
# Utilisation de ksp-job-backfill-lib
Cette page décrit l'utilisation durable de la façade publique de `ksp-job-backfill-lib`. Elle suppose qu'un caller a déjà construit un `HttpTransportPool` et un `Store` compatibles avec le même réseau logique.
## Construire une signature et un scope
```rust
let anchor = match ksp_job_backfill_lib::BackfillSignature::new(
"1111111111111111111111111111111111111111111111111111111111111111",
) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let address = ksp_core_lib::Pubkey::new_from_array([7_u8; 32]);
let scope = ksp_job_backfill_lib::BackfillScope::before_address(address, anchor);
```
Les autres formes sont :
```rust
let latest = ksp_job_backfill_lib::BackfillScope::latest_address(address);
let before = ksp_job_backfill_lib::BackfillScope::before_address(address, anchor.clone());
let after = ksp_job_backfill_lib::BackfillScope::after_address(address, anchor.clone());
let explicit = ksp_job_backfill_lib::BackfillScope::explicit_signatures(vec![anchor]);
```
`ExplicitSignatures` déduplique la liste en conservant la première occurrence. Les scopes address utilisent `getSignaturesForAddress` ; le scope explicite n'effectue aucune découverte address.
## Construire une requête bornée
```rust
fn request(scope: ksp_job_backfill_lib::BackfillScope) -> ksp_core_lib::Result<ksp_job_backfill_lib::BackfillRequest> {
let job_id = match ksp_job_api::JobId::new("raw-backfill-0001") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let network = match ksp_store_lib::RawNetworkId::new("mainnet") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let role = ksp_onchain_transport_lib::HttpRoleName::new("historical");
return ksp_job_backfill_lib::BackfillRequest::new(
job_id,
network,
role,
ksp_job_backfill_lib::BackfillCommitment::Finalized,
scope,
500,
20,
5_000,
16,
std::option::Option::None,
);
}
```
Bornes publiques :
```text
page_size 1 ..= 1_000
max_pages 1 ..= 10_000
max_candidates 1 ..= 10_000
hydration_concurrency 1 ..= 64
```
Le `min_context_slot` n'est pas admis pour `ExplicitSignatures`.
## Comprendre le fingerprint
```rust
let fingerprint = request.scope_fingerprint();
let bytes: &[u8; 32] = fingerprint.as_bytes();
```
Le fingerprint représente le scope sémantique et le réseau. Il ne change pas uniquement parce que le rôle Transport, le provider ou l'endpoint d'acquisition change.
Ne pas utiliser le fingerprint comme identité de transaction : l'identité durable reste `(network, signature)`.
## Exécuter le runtime complet
```rust
async fn run_backfill(
request: ksp_job_backfill_lib::BackfillRequest,
transport: &ksp_onchain_transport_lib::HttpTransportPool,
store: &ksp_store_lib::Store,
) -> ksp_core_lib::Result<ksp_job_backfill_lib::BackfillJobSnapshot> {
let runtime = match ksp_job_backfill_lib::BackfillJobRuntime::new(request) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return runtime.run(transport, store).await;
}
```
Le Store et la requête doivent cibler le même `RawNetworkId`. Un mismatch est rejeté avant l'écriture.
## Observer la progression
Obtenir le handle avant de déplacer le runtime dans `run` :
```rust
let runtime = ksp_job_backfill_lib::BackfillJobRuntime::new(request)?;
let handle = runtime.handle();
let snapshots = handle.snapshots();
let current = ksp_job_api::JobSnapshotSource::current(&snapshots);
let observed = current.sequence();
let newer = ksp_job_api::JobSnapshotSource::wait_for_change(&snapshots, observed).await;
assert!(newer.sequence().is_after(observed));
```
Chaque listener peut cloner sa propre `BackfillSnapshotSource`. La source est latest-value : plusieurs mises à jour intermédiaires peuvent être coalescées, mais la valeur retournée est toujours un snapshot complet.
Les compteurs publics du snapshot couvrent notamment :
```text
candidates_selected / admitted / finished
entities_inserted / existing / purged
observations_inserted / existing
missing / conflicts / holes
maximum_in_flight
contiguous_completed
checkpoint
failure_code
```
## Demander une annulation
```rust
let accepted = handle.cancel();
if accepted {
assert!(handle.is_cancellation_requested());
}
```
L'annulation est coopérative. Elle peut stopper de nouvelles admissions et certaines attentes avant persistance. Une écriture Store déjà soumise est drainée ; le caller ne doit donc pas supposer qu'une demande d'annulation rend immédiatement toutes les opérations in-flight inexistantes.
Une demande faite après publication terminale est rejetée (`false`).
## Reprendre avec un checkpoint
Le snapshot ou le batch d'exécution peut fournir un `BackfillCheckpoint` sûr. Pour une reprise contrôlée :
```rust
let resumed = match request.with_checkpoint(checkpoint) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
```
Le checkpoint doit appartenir au même `JobId` et au même fingerprint de scope.
Sémantique de reprise :
```text
LatestAddress repart du latest courant ; frontier contiguë conservée dans le nouveau run
BeforeAddress reprend avec le dernier cursor before prouvé
AfterAddress rejoue le scope et saute seulement le préfixe contigu prouvé
ExplicitSignatures rejoue la liste et saute seulement le préfixe contigu prouvé
```
`BackfillCheckpoint` n'est pas persisté automatiquement. Si le caller exige une reprise après crash/process restart, il doit stocker ce checkpoint dans une surface durable appropriée puis le réinjecter explicitement.
## Utiliser les primitives séparément
La façade expose aussi les étapes pour des compositions/tests spécialisés :
```text
discover_backfill_candidates
hydrate_backfill_candidate
persist_backfill_hydration
execute_backfill_discovery
```
`hydrate_backfill_candidate` ne persiste rien. `persist_backfill_hydration` n'effectue aucun appel Transport. `execute_backfill_discovery` combine hydratation/persistance sur un résultat de découverte déjà validé.
Pour un flux applicatif normal qui veut lifecycle + snapshots + annulation, préférer `BackfillJobRuntime::run`.
## Interpréter les outcomes de persistance
Les outcomes distinguent explicitement :
```text
entity: Inserted | AlreadyPresent | SkippedPurged | Conflict
observation: Inserted | AlreadyPresent | NotRecorded | NotApplicable
```
`Missing` ne provoque aucune écriture Store. Un conflit de contenu reste observable comme conflit et ne doit pas être traité comme une relance idempotente réussie.
## Frontières à respecter
Le caller ne doit pas :
- pré-lire le Store pour décider s'il faut hydrater une transaction ;
- appeler directement `ksp-store-postgres-lib` depuis le job ;
- ajouter une politique de retry/pacing qui concurrence Transport ;
- utiliser provider/endpoint comme identité transactionnelle ;
- inventer une provenance pour `getTransaction = null` ;
- interpréter un checkpoint caller-owned comme une garantie de persistence crash-safe automatique ;
- utiliser le job RAW comme decoder Program ou processor CORE.