Tema
Nodo: pipeline
Istanzia una sub-pipeline: un grafo riusabile con un ingresso e una o più uscite con nome, che non si esegue da solo. È il joblet di Talend. Il nodo esiste nel grafo com'è scritto, non in quello eseguito: build() lo sostituisce coi nodi della sub-pipeline (l'appiattimento), quindi il runner esegue un grafo piatto e non sa nulla di istanze.
Nel DAG, dell'app come di queste docs, l'istanza è un nodo chiuso con una porta per uscita, in cui si entra (vedi Nel disegno).
| Metodo | .pipeline(from, id, sub, { params?, deps?, fromPort?, deadLetter?, purpose? }) |
|---|---|
| Ingressi | 1 |
| Uscite | una per uscita della sub-pipeline, ognuna ≥1 (fan-out libero) |
| Dead-letter | sì, solo nella root: si distribuisce a ogni nodo isolabile dell'istanza, istanze annidate comprese (vedi Errori) |
| Codice | src/core/sub-pipeline.ts, src/core/sub-pipeline-args.ts, src/core/flatten*.ts, src/core/merge-instances.ts, src/core/run-sub-pipeline.ts |
Quando usarlo
Quando la stessa sequenza di nodi serve in più punti, nella stessa pipeline o in pipeline diverse: si scrive una volta, con un contratto d'ingresso e d'uscita, e si prova da sola. Se il pezzo serve una volta sola, restano i nodi della pipeline.
Dichiarare una sub-pipeline
ts
import { groupBy, INPUT, subPipeline } from "@noeva/etl-core";
export const riepilogoOrdini = subPipeline({
name: "riepilogo-ordini", // unico fra pipeline e sub-pipeline
description: "Riepilogo degli ordini per tier cliente, e gli ordini come fatti",
input: enrichedOrderSchema, // lo applica il nodo di confine d'ingresso
outputs: { "per-tier": tierSummarySchema, fatti: enrichedOrderSchema }, // uno schema per uscita
params: z.object({ tierMancante: z.string().min(1).default("unknown") }),
build: (g, { params }) =>
g
.transform(INPUT, "tier-summary", groupBy("tier-summary", { … }))
.output("tier-summary", "per-tier")
.output(INPUT, "fatti"), // un'uscita può partire dall'ingresso
});buildè una funzione, chiamata una volta per istanza coi parametri risolti e le dipendenze passate: ogni transform costruita lì dentro è nuova, quindi due istanze non condividono lo stato per-run, i tetti di memoria né i conteggi.- Il corpo parte da
INPUT("in", riservato) e collega ogni uscita con.output(from, nome, { fromPort? }). Il compilatore rifiuta un nome d'uscita non dichiarato. Vedi Uscite multiple. - Il builder del corpo non ha
source,sink,task,tryCatch,build()né l'opzionedeadLettersui nodi: una sub-pipeline riceve record e li restituisce, i sink stanno nella root. Gli scarti dell'istanza li decide la root, con ladeadLettersull'istanza. Hatransform,lookup,ai,action,route,join,unionepipeline: una sub-pipeline può istanziarne un'altra (vedi Annidamento). - Una sub-pipeline non legge l'orologio: un istante le arriva come campo del record, timbrato da chi la istanzia.
Le sub-pipeline stanno in src/sub-pipelines/<nome>/sub-pipeline.ts (core) o src/sub-pipelines/<cliente>/<nome>/sub-pipeline.ts (di un cliente). Non si registrano: esistono quando una pipeline root le istanzia.
Uscite multiple
Una sub-pipeline dichiara in outputs tutte le uscite che servono, ognuna col suo schema, e il corpo le alimenta con .output():
- da un nodo:
.output("tier-summary", "per-tier"); da una porta di unroute, o di un'istanza annidata, con.output("smista", "pari", { fromPort: "pari" }); - dall'ingresso:
.output(INPUT, "fatti"). Il record esce com'è entrato, dopo lo schema d'ingresso e quello dell'uscita.INPUTpuò alimentare più uscite (fan-out), come un nodo qualunque; - senza nodi interni: un corpo fatto solo di
.output(INPUT, …)è valido, e l'istanza ha solo i nodi di confine. Serve a dare un nome e un contratto a un passaggio.
Ogni uscita dichiarata è alimentata una volta sola: per mandare due flussi alla stessa uscita si riuniscono prima con un union.
Aggiungere un'uscita a una sub-pipeline già istanziata tocca ogni root che la istanzia: ogni uscita va collegata, e da due uscite in su fromPort non si omette più. È il costo accettato per non avere uscite opzionali, che darebbero a due istanze forme diverse.
Parametri e dipendenze
Una sub-pipeline si configura con due cose diverse, e non vanno confuse.
Un parametro è un dato. Si dichiara con uno
z.objectinparams, con i default dove servono; ogni istanza ne fissa i valori. Dopo i default i valori devono scriversi in JSON (stringhe, numeri finiti, booleani,null, array e oggetti semplici: nienteDate, niente funzioni), perché compaiono in chiaro nel piano e nella UI. Il controllo guarda i valori, non lo schema: un defaultz.date()si dichiara, e fallisce alla costruzione della prima istanza.Una dipendenza è un connettore che il corpo usa ma non costruisce. Si dichiara con uno slot in
deps, e chi istanzia passa l'oggetto vero:Slot Riceve Serve a lookupDep()un LookupResolverun lookupo unai: chi risponde alle richiesteactionDep<I, O>()un' Action<I, O>già prontaun action: compreso cosa scrive per ogni recordCosa chiedere e come usare la risposta (
key,merge, prompt, schema d'estrazione) restano nel corpo. Ogni slot è obbligatorio: non esistono slot opzionali, che nasconderebbero una variante di topologia, né slot per funzioni, nemmeno per l'orologio.
ts
export const arricchisci = subPipeline({
// …
deps: { anagrafica: lookupDep(), notifica: actionDep<Cliente, Esito>() },
build: (g, { deps }) =>
g
.lookup(INPUT, "cliente", { resolver: deps.anagrafica, key: …, merge: … })
.action("cliente", "notifica", deps.notifica)
.output("notifica", "ok"),
});Un parametro non è un segreto
Il piano si legge senza autenticazione, e porta i parametri di ogni istanza in chiaro. Una password, un token o una stringa di connessione non sono mai un parametro: le credenziali restano nella pipeline root, che costruisce il connettore e lo passa come dipendenza. Del connettore il piano mostra solo il descriptor non sensibile.
Istanziarla
ts
graph("orders-enriched-daily")
// …
.pipeline("guard", "riepilogo-ordini", riepilogoOrdini)
.sink("riepilogo-ordini", sinks.enrichedOrders, { fromPort: "fatti" })
.sink("riepilogo-ordini", sinks.tierSummary, { fromPort: "per-tier" });Le uscite si collegano a valle come le porte di un route: { fromPort: "<uscita>" }, che si omette solo quando la sub-pipeline ha un'uscita sola. Con più uscite, un collegamento senza fromPort o con un'uscita che non esiste è un errore di costruzione che nomina l'istanza ed elenca le uscite. fromPort nelle opzioni di .pipeline() è invece la porta del nodo a monte, come per ogni altro nodo.
Parametri e dipendenze vanno nel quarto argomento:
ts
.pipeline("clienti", "arricchisci", arricchisci, {
params: { soglia: 10 }, // i mancanti prendono il default
deps: { anagrafica: mssqlLookup(…), notifica: noevaNotifica(…) },
})Il compilatore pretende params quando un parametro non ha default e deps quando c'è uno slot, e li rifiuta quando la sub-pipeline non ne dichiara: senza nulla da passare, la chiamata ha tre argomenti. riepilogo-ordini ha solo tierMancante, col default, quindi orders-enriched-daily la istanzia con tre argomenti. In getSpec() la root passa alle dipendenze ciò che passa ai suoi connettori: gli stub, o i connettori veri costruiti con configurazioneDelPiano, che ricade sull'esempio un'istanza di connettore alla volta. Una dipendenza su un'istanza rotta è d'esempio da sola, con le variabili mancanti, e le altre restano risolte: vedi la convenzione del descrittore.
Scopo
Lo scopo si scrive in due posti, e dice due cose diverse.
- Sui nodi del corpo, nella definizione, con la stessa opzione
purposedella root: cosa fa il nodo dentro la sub-pipeline. Vale per il nodo in ogni istanza. - Sull'istanza, al punto di chiamata: perché questa pipeline usa la sub-pipeline qui.
ts
// nella definizione
.transform(INPUT, "tier-summary", groupBy(…), {
purpose: "Somma ordini e importi per tier cliente, per il cruscotto vendite.",
})
// nella root
.pipeline("guard", "riepilogo-ordini", riepilogoOrdini, {
purpose: "Prepara i fatti degli ordini e il riepilogo per tier da caricare.",
})La description della sub-pipeline non fa da default allo scopo dell'istanza: è la voce di catalogo, dice cosa fa la sub-pipeline in generale, e un'istanza senza purpose resta senza. Il nodo pipeline non sta fra i nodi del piano, quindi lo scopo dell'istanza viaggia sull'istanza (sotto, Nel piano); i nodi di confine non ne hanno uno.
Un'istanza annidata prende lo scopo dal .pipeline() del corpo che la dichiara. build() rifiuta uno scopo vuoto o oltre PURPOSE_MAX_LENGTH caratteri sull'istanza come su un nodo interno, e l'errore nomina l'id qualificato: il nodo "riepilogo-ordini/tier-summary", l'istanza "r/smista". Gli scopi di istanze e nodi interni stanno fuori dallo snapshot del piano, e il gate delle versioni non li conta: scriverli non chiede un bump.
Annidamento
Il corpo di una sub-pipeline istanzia un'altra sub-pipeline con lo stesso .pipeline(from, id, sub, { params?, deps?, fromPort? }) della root, con le stesse regole su parametri e dipendenze e senza deadLetter, perché nel corpo non ci sono sink. Una sub-pipeline annidata riceve i propri connettori da quella che la istanzia, che li dichiara a sua volta come dipendenze e li inoltra da deps: nessuna sub-pipeline costruisce un connettore. Le uscite dell'istanza annidata si collegano a valle come nella root: a un nodo del corpo, a un union, o direttamente a un'uscita con .output("<istanza>", "<uscita>").
ts
export const riunisci = subPipeline({
name: "riunisci",
description: "Smista per parità e riunisce",
input: numero,
outputs: { out: numero },
build: (g) =>
g
.pipeline(INPUT, "smista", smista) // una sub-pipeline con le uscite pari e dispari
.union("tutti", {
from: [
{ node: "smista", port: "pari" },
{ node: "smista", port: "dispari" },
],
})
.output("tutti", "out"),
});- Nessun limite di profondità. L'appiattimento scende a ogni livello; l'id qualificato porta l'intero percorso d'istanza: con
riunisciistanziata comernella root, il nodoparitadismistadiventar/smista/parita. builddella sub-pipeline si chiama albuild()della root, una volta per istanza a ogni profondità, non a.pipeline(): solo lì si conosce il percorso d'istanza.- Un ciclo è un errore di costruzione. Una sub-pipeline che contiene sé stessa, direttamente o attraverso altre, non finirebbe di costruirsi:
build()si ferma prima e nomina il ciclo, per esempioIstanza "x/b/c/b" di "b": ciclo fra sub-pipeline: b → c → b. La stessa sub-pipeline due volte nello stesso corpo, una dopo l'altra, non è un ciclo. - Ogni uscita di un'istanza annidata va collegata nel corpo che la istanzia: un'uscita libera è un errore che nomina l'istanza col percorso completo.
Cosa diventa nel grafo eseguito
| Nel grafo scritto | Nel grafo eseguito |
|---|---|
il nodo pipeline id | il nodo d'ingresso id: una transform che applica input |
un nodo x del corpo | il nodo id/x, con l'id qualificato |
l'uscita u | il nodo d'uscita id/u: una transform che applica lo schema |
A ogni livello di annidamento le righe si ripetono: un nodo y di un'istanza j annidata in id diventa id/j/y.
L'id qualificato è l'unico id del nodo in report, metriche (id/x:extracted), errori (error.context.nodeId) e id dei record. Il nodo d'ingresso ha l'id dell'istanza, quindi id:extracted conta i record entrati nell'istanza. Nessuno spezza un id sul /: istanza e id locale si leggono dal piano.
Nel piano
nodes ed edges restano il grafo eseguito, piatto. I nodi di un'istanza portano instance (l'id dell'istanza più interna che li contiene) e localId (l'id nella sub-pipeline: "in" per l'ingresso, il nome dell'uscita per un'uscita); quelli della root no. Accanto, instances elenca le istanze:
json
{
"id": "riepilogo-ordini",
"localId": "riepilogo-ordini",
"subPipeline": "riepilogo-ordini",
"params": { "tierMancante": "unknown" },
"deps": [],
"outputs": [
{ "name": "per-tier", "nodeId": "riepilogo-ordini/per-tier" },
{ "name": "fatti", "nodeId": "riepilogo-ordini/fatti" }
]
}outputs elenca tutte le uscite, nell'ordine dichiarato, col nodo di confine di ognuna: chi legge il piano non compone <istanza>/<uscita>. Un'uscita alimentata dall'ingresso è un edge dal nodo d'ingresso al suo nodo d'uscita, riepilogo-ordini → riepilogo-ordini/fatti.
params porta i valori risolti, dopo i default ({} se la sub-pipeline non ne dichiara). deps porta una voce per slot, nell'ordine dichiarato: { "slot": "anagrafica", "kind": "lookup", "descriptor": { … } }, col descriptor solo se il connettore lo espone. È il descriptor del connettore com'è, lo stesso dei nodi del corpo che lo usano: ogni riga porta env e origine, e la credenziale è una riga credenziale con la sola variante e il suo stato.
Un'istanza con uno scopo lo porta in purpose, solo quando c'è: è l'unico posto del piano in cui sta. Il report di run non lo copia.
Un'istanza annidata porta anche parent, l'id qualificato dell'istanza che la contiene, e il suo localId è l'id del nodo pipeline nel corpo che la dichiara:
json
{
"id": "r/smista",
"parent": "r",
"localId": "smista",
"subPipeline": "smista",
"params": {},
"deps": [],
"outputs": [
{ "name": "pari", "nodeId": "r/smista/pari" },
{ "name": "dispari", "nodeId": "r/smista/dispari" }
]
}Ogni istanza segue quella che la contiene. L'albero si ricostruisce risalendo i parent. Un piano senza sub-pipeline non ha instances ed è identico a prima.
Nel disegno
Il piano è piatto, ma il DAG no. Nell'app e nelle schede di queste docs ogni istanza è un nodo chiuso: l'id locale, la sub-pipeline, i parametri e una porta per uscita, da cui partono gli edge verso valle. Ci si entra col doppio click o con «Entra», e dentro si vedono i nodi dell'istanza con l'id locale, l'ingresso e le uscite marcati, e le istanze annidate chiuse a loro volta. Gli scarti verso lo scarto della root convergono su un collettore col nome dello scarto e il numero di nodi che lo alimentano; da fuori parte un solo edge dal nodo chiuso, con l'etichetta ×N.
Nel sito della documentazione:
<PipelineDag name="…" />, in testa alla scheda di una pipeline, entra nelle istanze sul posto, con un breadcrumb per risalire. Il livello sta nella query come percorso d'istanza,?istanza=r/smista, quindi un link apre la scheda già dentro un'istanza; un percorso che il piano non conosce mostra la root con un avviso. «Fai passare un record» si ricalcola per livello: alla root il record entra nel nodo chiuso ed esce dalle uscite, dentro un'istanza parte dall'ingresso. Sotto il grafo, la tabella «Lo scopo dei nodi» elenca lo scopo dei nodi del livello mostrato: alla root anche quello del nodo chiuso, dato al punto di chiamata, dentro un'istanza quelli dei nodi interni.<SubPipelineDag name="…" />, in testa alla scheda di una sub-pipeline, disegna il corpo di un'istanza rappresentativa: la prima nell'ordine del catalogo, percorrendo ogni root in profondità. La didascalia dice quale, con i parametri; scarti e guard non compaiono, perché li decide la root. Il breadcrumb parte dalla sub-pipeline e non risale nella root. Sotto, la stessa tabella degli scopi, coi nodi interni dichiarati nella definizione e senza collettore.<SubPipelineUsage name="…" />è la sezione «Dove si usa»: una riga per istanza, con la root, i parametri, chi la alimenta e dove vanno le uscite. L'id dell'istanza apre la scheda della root dentro di lei.
Proiezione e layout si calcolano a build time dal getSpec() delle root.
Nel report di run
Il report resta piatto per id qualificato, e da v7 porta instances: le stesse voci del piano, ognuna con il riepilogo d'istanza in summary (entrati, usciti per uscita, rifiutati, retry, intervallo di tempo). etl runs show lo mostra nella sezione «Istanze». Dettaglio in run-report.
Regole
/è riservato agli id qualificati: un id di nodo, di istanza o un nome d'uscita che lo contiene è un errore che lo nomina, nella root come nel corpo. Un edge della root non cita mai un nodo interno per id qualificato: parte dall'istanza."in"è riservato all'ingresso del corpo.- Un parametro non valido (non rispetta lo schema, o dopo i default non si scrive in JSON) è un errore di costruzione che nomina istanza e sub-pipeline:
Istanza "riepilogo-ordini" di "riepilogo-ordini": parametri non validi — tierMancante: …. - Uno slot dichiarato e non passato, una dipendenza non dichiarata o del tipo sbagliato (una funzione, un'
Actiondove serve unLookupResolver) sono errori di costruzione che nominano istanza, sub-pipeline e slot. Il compilatore li ferma prima, ma il controllo resta per chi lo scavalca. - Ogni uscita dichiarata va alimentata, una volta, e ogni uscita di ogni istanza va collegata nella root: un'uscita libera è un errore che nomina istanza e uscita.
- Uscite e nodi del corpo condividono gli id: un'uscita non può chiamarsi come un nodo.
- Lo stesso oggetto
Transformin due istanze (unaconstdi modulo usata nel corpo) è un errore che nomina i due nodi: costruiscilo dentrobuild. La regola non è delle sub-pipeline: vale per ogni grafo, veditransform. - Due sub-pipeline diverse con lo stesso nome nella stessa root sono un errore, a qualunque profondità stiano.
- Una sub-pipeline che contiene sé stessa, anche indirettamente, è un errore che nomina il ciclo.
- Poi
build()valida il grafo appiattito con le regole di sempre, sugli id qualificati.
Errori
L'istanza non è un'unità d'errore. Un errore nato dentro un'istanza lo gestisce il grafo che la istanzia, coi meccanismi di sempre:
- una
deadLettersul.pipeline()della root (sotto) scarta i record che i nodi dell'istanza rifiutano; - un
try-catchdella root a monte del nodopipelinecopre tutta l'istanza, nodi di confine e istanze annidate compresi, a ogni profondità; - un record che non rispetta lo schema all'ingresso o in uscita è una
ValidationErrorper record, come un'altra: va nelladeadLetterdell'istanza, al ramocatcho ferma la run; - un troncamento dentro un'istanza salta i sink a valle, con
skipped.becauseuguale all'id qualificato del nodo interno.
deadLetter sull'istanza
ts
graph("ordini")
// …
.pipeline("ordini", "riepilogo", riepilogoOrdini, {
deadLetter: sinks.scarti,
})
.sink("riepilogo", sinks.riepilogo);È una scorciatoia che si distribuisce: l'appiattimento assegna sinks.scarti come deadLetter a ogni nodo isolabile dell'istanza, cioè ai nodi di confine, ai nodi interni e a quelli delle istanze annidate, a ogni profondità. Vale per tutti o per nessuno: non c'è un'eccezione per un singolo nodo interno. Senza, ogni nodo andrebbe protetto a mano, e nel corpo non si può.
- Solo nella root. Il corpo di una sub-pipeline non ha sink: il suo
.pipeline()non ha l'opzione, e undeadLetterpassato scavalcando i tipi è un errore di costruzione. - I nodi stateful la ignorano, come una
deadLetterpropria: ungroupByisolato per record aggregherebbe ogni record da solo. Un loro errore resta del guard a monte, o ferma la run. Nel piano non hanno un edge di scarto. rawè il record com'era nel nodo interno che l'ha scartato, non quello entrato nell'istanza: per risalire all'ingresso serve un guard della root conguardSnapshotserecordIds.error.context.nodeIdè l'id qualificato del nodo interno, e non ci sono campi d'istanza: istanza e sub-pipeline si leggono dal piano.- Con un guard a monte, gli errori per record vanno nella
deadLetter, il guard cattura i non isolabili. - Un
loadper run. Tutti i nodi dell'istanza condividono lo stesso sink: il runner raccoglie gli scarti di tutti i nodi che lo usano e lo chiama una volta sola, nell'ordine dei nodi. Vale per qualunque sink di scarto condiviso, anche fra nodi della root. - Nel piano c'è un edge
kind: "dead-letter"per ogni nodo che la alimenta, verso un solo nodo di scarto della root, che porta lo scopo fisso del core (vedi Scopo del nodo). Nell'app, dentro l'istanza, quegli edge convergono su un collettore col nome dello scarto e il numero di nodi che lo alimentano; da fuori parte un solo edge dal nodo chiuso, con l'etichetta×N.
Le regole sull'emissione scattano sul grafo appiattito e nominano il nodo interno con l'id qualificato. Un nodo interno non si sposta da solo, quindi il messaggio dice di spostare fuori dallo scope del guard l'istanza della root che lo contiene. Quando la via d'uscita è una deadLetter, indica quella sull'istanza, perché nel corpo non se ne può mettere una:
text
Il nodo "esp/espandi" emette in streaming e può lasciare un output parziale: […] Metti la
deadLetter sull'istanza "esp" (`.pipeline(…, { deadLetter })` nella root), che la distribuisce
ai suoi nodi, oppure sposta l'istanza "esp" fuori dallo scope del guard.recordIds lo decide solo la root: la stessa sub-pipeline gira con gli id dei record in una root e senza in un'altra.
Provarla da sola
ts
const { outputs, rejected, report } = await runSubPipeline(riepilogoOrdini, {
params: { tierMancante: "senza-cliente" }, // come in .pipeline(), default compresi
input: [ordine, { tier: "gold" }], // unknown[]: anche record fuori contratto
recordIds: true, // provala con e senza
});
outputs["per-tier"]; // i record usciti, per uscita
outputs.fatti;
rejected; // [{ nodeId: "riepilogo-ordini", raw, error }]params e deps seguono le regole di .pipeline(). Nei test le dipendenze sono doppi senza rete: un LookupResolver che risponde da una mappa, un'Action che scrive in un array.
runSubPipeline istanzia la sub-pipeline, con il suo nome come id d'istanza, in una root minima: una source ad array, un try-catch per-record, un sink ad array per uscita. Passa per lo stesso appiattimento e lo stesso runner della produzione. I rifiuti arrivano dal ramo catch, quindi un nodo stateful interno non si isola per record. Ogni sub-pipeline in src/sub-pipelines/ ha un test con runSubPipeline in entrambi i regimi di recordIds, con record validi, rifiutati all'ingresso e in uscita.
Elenco
Non c'è un registro: listSubPipelines(pipelines) (src/sub-pipelines/list.ts) ricava l'elenco dai piani delle pipeline del catalogo (etl.config.ts), che portano le istanze a ogni profondità: una sub-pipeline istanziata solo da un'altra sub-pipeline è nell'elenco, usata dalle root che la contengono. Un test pretende che nessuna sub-pipeline in src/sub-pipelines/ sia orfana e che i nomi siano unici fra pipeline e sub-pipeline. Di chi è una sub-pipeline lo dice il repo in cui vive: non c'è un perimetro per cliente da controllare dentro il catalogo.
L'elenco per intero lo serve GET /sub-pipelines di etl serve: per ogni sub-pipeline descrizione, colonne d'ingresso e d'uscita, parametri con tipo, default e obbligatorietà, slot di dipendenza e usedBy, filtrato sulle pipeline servite come GET /graphs. Lo calcola describeSubPipelines() dalle dichiarazioni che il piano di ogni root porta fuori dal JSON (subPipelineDeclarationsOf), quindi il piano di una root va costruito con GraphPipeline.getSpec(). Formato nel runbook del server.
Una sub-pipeline non si lancia: etl run, ETL_PIPELINE_WORKSPACES e GET /graphs/:name sul suo nome rispondono «è una sub-pipeline, si istanzia da un nodo pipeline».
Anti-pattern
- Transform costruite fuori da
builde riusate nel corpo: condividerebbero lo stato fra istanze, e la costruzione della root fallisce. - Un istante letto con
new Date()nel corpo: si timbra nella root e arriva come campo del record. - Credenziali in un parametro: finirebbero in chiaro nel piano. Vanno nel connettore, costruito dalla root e passato come dipendenza.
- Un connettore costruito dentro
buildleggendo l'ambiente: la sub-pipeline non legge credenziali, le riceve già dentro una dipendenza.

