ScyllaDB è un database NoSQL distribuito, orientato alle colonne, compatibile con Apache Cassandra ma riscritto in C++ con un'architettura shard-per-core basata sul framework Seastar. Questa scelta architetturale consente a ScyllaDB di offrire latenze prevedibili nell'ordine dei millisecondi e un throughput che, su hardware equivalente, risulta tipicamente da cinque a dieci volte superiore a quello di Cassandra. In questo articolo vedremo come integrare ScyllaDB in un'applicazione Node.js, partendo dall'installazione fino ad arrivare a pattern avanzati come le prepared statement, il batching e la gestione asincrona dei risultati su larga scala.

Perché ScyllaDB

Prima di entrare nel codice vale la pena chiarire in quali scenari ScyllaDB dà il meglio di sé. Il database è pensato per carichi di lavoro write-heavy con volumi di dati molto grandi, distribuiti su più nodi, in cui la tolleranza ai guasti e la scalabilità orizzontale sono requisiti fondamentali. Casi d'uso tipici includono piattaforme IoT che ingeriscono milioni di eventi al secondo, sistemi di messaggistica, log di sessioni utente, feed temporali e cataloghi di prodotto con accessi geograficamente distribuiti. Essendo compatibile a livello di protocollo con Cassandra (CQL binary protocol), ScyllaDB può essere utilizzato con gli stessi driver pensati per Cassandra, incluso il driver ufficiale DataStax per Node.js.

Avviare ScyllaDB in locale

Il modo più rapido per avere un'istanza di ScyllaDB a disposizione durante lo sviluppo è utilizzare l'immagine Docker ufficiale. Dopo aver installato Docker, è sufficiente eseguire un singolo comando per ottenere un nodo funzionante in ascolto sulla porta 9042.

docker run --name scylla-dev -p 9042:9042 -d scylladb/scylla \
  --smp 1 --memory 1G --overprovisioned 1

I flag --smp 1 e --memory 1G limitano le risorse a un solo core e a un gigabyte di memoria, valori adatti a una macchina di sviluppo. Il flag --overprovisioned 1 comunica a ScyllaDB che sta girando in un ambiente condiviso e disattiva alcune ottimizzazioni che presuppongono l'accesso esclusivo all'hardware. Una volta avviato il container, è possibile collegarsi con il client cqlsh incluso nell'immagine per verificare il funzionamento.

docker exec -it scylla-dev cqlsh

Creare keyspace e tabella

Prima di scrivere codice Node.js occorre preparare la struttura dati. In ScyllaDB, così come in Cassandra, il keyspace è l'unità logica di più alto livello e contiene le tabelle. Al momento della creazione è necessario specificare la strategia di replica; per lo sviluppo locale SimpleStrategy con un fattore di replica pari a uno è più che sufficiente.

CREATE KEYSPACE IF NOT EXISTS shop
WITH replication = {
  'class': 'SimpleStrategy',
  'replication_factor': 1
};

USE shop;

CREATE TABLE IF NOT EXISTS products (
  id uuid PRIMARY KEY,
  name text,
  price decimal,
  category text,
  created_at timestamp
);

Questa tabella rappresenta un catalogo di prodotti molto semplice. In un'applicazione reale la scelta della chiave primaria sarebbe cruciale per le prestazioni: in ScyllaDB la partition key determina su quale nodo vengono memorizzati i dati, mentre le clustering column determinano l'ordinamento all'interno della partizione. Per ora ci limitiamo a una chiave primaria semplice basata su UUID.

Installare il driver Node.js

Il driver consigliato è cassandra-driver di DataStax, pienamente compatibile con ScyllaDB. Esiste anche un driver specifico chiamato scylladb-driver, un fork che include ottimizzazioni mirate come il supporto nativo allo shard-aware routing, ma per la maggior parte dei casi d'uso il driver DataStax è più che adeguato.

npm init -y
npm install cassandra-driver

Connessione e prima query

Il punto di ingresso del driver è la classe Client. Un'istanza di client gestisce internamente un pool di connessioni verso tutti i nodi del cluster ed è pensata per essere creata una sola volta e condivisa in tutta l'applicazione.

import { Client } from 'cassandra-driver';

// Creazione del client con i punti di contatto iniziali
const client = new Client({
  contactPoints: ['127.0.0.1:9042'],
  localDataCenter: 'datacenter1',
  keyspace: 'shop'
});

async function main() {
  await client.connect();
  console.log('Connessione stabilita con il cluster');

  const result = await client.execute('SELECT release_version FROM system.local');
  console.log('Versione del nodo:', result.rows[0].release_version);

  await client.shutdown();
}

main().catch((err) => {
  console.error('Errore durante l\'esecuzione:', err);
  process.exit(1);
});

Il parametro localDataCenter è obbligatorio a partire dalle versioni recenti del driver e serve a indicare quale datacenter deve essere considerato locale per il load balancing. Nelle installazioni di default di ScyllaDB il nome è datacenter1, ma è possibile verificarlo con la query SELECT data_center FROM system.local.

Inserimento con prepared statement

Ogni volta che una query viene eseguita con execute, il driver può parsarla al volo oppure riutilizzare una versione precompilata. Le prepared statement sono la scelta corretta per qualsiasi query che venga eseguita più di una volta: riducono il carico sul coordinator, migliorano la sicurezza contro le injection e abilitano il token-aware routing, che consente al driver di inviare la richiesta direttamente al nodo che possiede la partizione interessata.

import { Client, types } from 'cassandra-driver';

const client = new Client({
  contactPoints: ['127.0.0.1:9042'],
  localDataCenter: 'datacenter1',
  keyspace: 'shop'
});

async function insertProduct(product) {
  const query = `
    INSERT INTO products (id, name, price, category, created_at)
    VALUES (?, ?, ?, ?, ?)
  `;

  const params = [
    types.Uuid.random(),
    product.name,
    product.price,
    product.category,
    new Date()
  ];

  // Il flag prepare indica al driver di preparare la query una volta sola
  await client.execute(query, params, { prepare: true });
}

async function run() {
  await client.connect();

  await insertProduct({
    name: 'Tastiera meccanica',
    price: 129.90,
    category: 'peripherals'
  });

  await insertProduct({
    name: 'Monitor 27 pollici',
    price: 349.00,
    category: 'displays'
  });

  await client.shutdown();
}

run().catch(console.error);

Il tipo types.Uuid esposto dal driver genera identificatori conformi a UUID v4. ScyllaDB supporta anche i timeuuid, varianti basate su timestamp particolarmente utili come clustering column per ordinare gli eventi cronologicamente.

Lettura e iterazione sui risultati

Per le query di lettura che restituiscono un numero limitato di righe è sufficiente utilizzare execute e accedere direttamente all'array rows del risultato. Quando invece si prevede di leggere centinaia di migliaia o milioni di righe, caricare tutto in memoria non è praticabile ed è necessario ricorrere alla paginazione automatica offerta dal metodo stream o dall'iterazione asincrona con eachRow.

async function listByCategory(category) {
  const query = 'SELECT id, name, price FROM products WHERE category = ? ALLOW FILTERING';
  const result = await client.execute(query, [category], { prepare: true });

  return result.rows.map((row) => ({
    id: row.id.toString(),
    name: row.name,
    price: Number(row.price)
  }));
}

La clausola ALLOW FILTERING è stata aggiunta perché category non fa parte della chiave primaria. In un'applicazione di produzione questo pattern andrebbe evitato: è molto più efficiente creare una tabella secondaria pensata ad hoc per interrogazioni per categoria, adottando il principio della denormalizzazione tipico dei database a colonne larghe.

Streaming di grandi result set

Quando il volume di dati cresce, il metodo stream diventa indispensabile. Restituisce un Readable stream di Node.js che emette un oggetto per ogni riga, sfruttando internamente la paginazione del protocollo CQL. Il driver richiede automaticamente la pagina successiva quando la precedente è stata consumata.

import { pipeline } from 'node:stream/promises';
import { Writable } from 'node:stream';

async function exportAllProducts() {
  const query = 'SELECT id, name, price FROM products';

  // Stream dei risultati con dimensione pagina configurabile
  const source = client.stream(query, [], { prepare: true, fetchSize: 1000 });

  let count = 0;
  const consumer = new Writable({
    objectMode: true,
    write(row, _encoding, callback) {
      count += 1;
      // Elaborazione riga per riga senza caricare tutto in memoria
      console.log(`${count}: ${row.name}`);
      callback();
    }
  });

  await pipeline(source, consumer);
  console.log(`Totale righe elaborate: ${count}`);
}

Il parametro fetchSize controlla quante righe vengono richieste per ogni pagina. Valori più alti riducono il numero di round trip verso il cluster ma aumentano il consumo di memoria lato client; mille è un compromesso ragionevole per la maggior parte dei carichi.

Batch di scritture

Il protocollo CQL permette di raggruppare più statement in un singolo batch. È importante capire che i batch in Cassandra e ScyllaDB non hanno lo stesso significato dei batch nei database relazionali: non servono a ottimizzare le prestazioni delle scritture, bensì a garantire l'atomicità logica tra più operazioni che interessano la stessa partizione. Un batch che coinvolge partizioni diverse può addirittura peggiorare le prestazioni perché forza il coordinator a mantenere lo stato di tutte le operazioni.

async function insertProductWithIndex(product) {
  const productId = types.Uuid.random();
  const createdAt = new Date();

  const queries = [
    {
      query: `
        INSERT INTO products (id, name, price, category, created_at)
        VALUES (?, ?, ?, ?, ?)
      `,
      params: [productId, product.name, product.price, product.category, createdAt]
    },
    {
      query: `
        INSERT INTO products_by_category (category, created_at, id, name)
        VALUES (?, ?, ?, ?)
      `,
      params: [product.category, createdAt, productId, product.name]
    }
  ];

  // Batch logged per garantire atomicità tra le due tabelle
  await client.batch(queries, { prepare: true, logged: true });
}

In questo esempio viene aggiornata sia la tabella principale sia una tabella di indice secondario products_by_category. Il flag logged: true attiva il batch log lato server, che garantisce che tutte le scritture del batch verranno eventualmente applicate anche in caso di guasto parziale. Se l'atomicità non è necessaria, un batch UNLOGGED è più leggero.

Concorrenza controllata

Quando si devono inserire grandi quantità di dati, emettere migliaia di await sequenziali è inefficiente, ma lanciare tutte le promesse insieme con Promise.all rischia di saturare il pool di connessioni. Il driver espone l'utility concurrent.executeConcurrent, pensata proprio per limitare il parallelismo a un valore sostenibile.

import { concurrent } from 'cassandra-driver';

async function bulkInsert(products) {
  const query = `
    INSERT INTO products (id, name, price, category, created_at)
    VALUES (?, ?, ?, ?, ?)
  `;

  const parameters = products.map((product) => [
    types.Uuid.random(),
    product.name,
    product.price,
    product.category,
    new Date()
  ]);

  // Limita il numero di query in volo contemporaneamente
  const result = await concurrent.executeConcurrent(client, query, parameters, {
    concurrencyLevel: 128,
    raiseOnFirstError: false
  });

  console.log(`Scritture completate: ${result.totalExecuted}`);
  console.log(`Errori: ${result.errors.length}`);
}

Un valore di concurrencyLevel compreso tra 64 e 256 è tipicamente un buon punto di partenza; quello ottimale dipende dal numero di core del cluster, dalla latenza di rete e dalla complessità delle query. L'opzione raiseOnFirstError: false fa sì che gli errori vengano accumulati invece di interrompere l'intera operazione, lasciando all'applicazione la decisione su come gestirli.

Tipi di dato e conversioni

Il driver Node.js converte automaticamente molti tipi CQL in tipi JavaScript nativi, ma alcuni richiedono attenzione. I tipi numerici bigint, decimal e varint vengono esposti come istanze di classi dedicate (Long, BigDecimal, Integer) per preservare la precisione, che verrebbe altrimenti persa con i number standard di JavaScript. Quando si legge un valore decimal, come nel caso del prezzo dei prodotti, è importante convertirlo esplicitamente prima di usarlo in calcoli.

import { types } from 'cassandra-driver';

function formatPrice(decimalValue) {
  // BigDecimal espone il metodo toString per una conversione sicura
  const asString = decimalValue.toString();
  return `${asString} EUR`;
}

async function printFirstProduct() {
  const result = await client.execute('SELECT name, price FROM products LIMIT 1');
  const row = result.rows[0];
  if (row) {
    console.log(`${row.name}: ${formatPrice(row.price)}`);
  }
}

Gestione degli errori e retry

In un sistema distribuito gli errori transitori sono la norma: un nodo può essere temporaneamente irraggiungibile, il coordinator può andare in timeout, il livello di consistenza richiesto può non essere soddisfatto. Il driver include una retry policy di default che gestisce molti di questi scenari automaticamente, ma è possibile personalizzarla o sostituirla fornendo un'implementazione custom al momento della creazione del client. Per casi d'uso più elaborati si possono inoltre definire strategie di load balancing specifiche, ad esempio per preferire sempre il datacenter locale e limitare i tentativi remoti.

import { Client, policies } from 'cassandra-driver';

const client = new Client({
  contactPoints: ['127.0.0.1:9042'],
  localDataCenter: 'datacenter1',
  keyspace: 'shop',
  policies: {
    // Preferisce il datacenter locale, usa i nodi remoti solo come fallback
    loadBalancing: new policies.loadBalancing.DCAwareRoundRobinPolicy('datacenter1'),
    retry: new policies.retry.RetryPolicy()
  },
  socketOptions: {
    connectTimeout: 5000,
    readTimeout: 12000
  }
});

Chiusura pulita del client

Un dettaglio spesso trascurato è la chiusura ordinata del client quando il processo Node.js termina. Lasciare connessioni TCP aperte impedisce al processo di uscire e, in scenari di deploy con container, può portare a segnali SIGKILL forzati che interrompono operazioni in corso. Registrare un handler sui segnali di terminazione è una buona pratica.

async function shutdown(signal) {
  console.log(`Ricevuto ${signal}, chiusura del client in corso`);
  try {
    await client.shutdown();
    process.exit(0);
  } catch (err) {
    console.error('Errore durante lo shutdown:', err);
    process.exit(1);
  }
}

process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));

Considerazioni finali

ScyllaDB offre a Node.js un backend NoSQL estremamente performante senza richiedere di imparare un nuovo ecosistema: il driver Cassandra esistente funziona senza modifiche e tutti i pattern consolidati (prepared statement, token-aware routing, paginazione trasparente, batch logici) sono immediatamente disponibili. I punti chiave da tenere a mente sono il modello di dati denormalizzato, che richiede di progettare le tabelle intorno alle query e non intorno alle entità, e la gestione della concorrenza, che va sempre limitata in modo esplicito quando si effettuano scritture di massa. Partendo dagli esempi mostrati in questo articolo è possibile costruire servizi in grado di sostenere carichi di decine di migliaia di operazioni al secondo per singola istanza Node.js, sfruttando appieno l'architettura shard-per-core di ScyllaDB.