ScyllaDB con Python

ScyllaDB è un database NoSQL distribuito, orientato alle colonne e compatibile con Apache Cassandra a livello di protocollo (CQL) e di driver. È scritto in C++ utilizzando il framework Seastar, che sfrutta un'architettura shared-nothing e thread-per-core per ottenere prestazioni molto elevate, latenze prevedibili e un throughput significativamente superiore rispetto a Cassandra a parità di hardware. In questo articolo vedremo come integrare ScyllaDB in un'applicazione Python, partendo dall'installazione del driver fino alla gestione di query asincrone, prepared statement, batch e pattern di modellazione dei dati.

Perché ScyllaDB

ScyllaDB nasce per superare i limiti architetturali di Cassandra mantenendone però la piena compatibilità. Questo significa che tutte le applicazioni scritte per Cassandra, inclusi i driver ufficiali di DataStax, funzionano senza modifiche. I principali punti di forza sono la bassa latenza con percentili di coda contenuti, la scalabilità orizzontale lineare, la replica multi-datacenter nativa e un consumo di risorse ottimizzato grazie al bypass del kernel su operazioni critiche.

Dal punto di vista di uno sviluppatore Python, interagire con ScyllaDB significa usare il driver cassandra-driver oppure il più recente scylla-driver, che è un fork ottimizzato con supporto specifico per lo shard-awareness di ScyllaDB.

Installazione del driver

Il driver consigliato per ottenere le massime prestazioni è scylla-driver, che estende cassandra-driver aggiungendo la consapevolezza degli shard interni di ScyllaDB. Questo permette al client di instradare ogni richiesta direttamente al core CPU che possiede il dato, eliminando un hop interno al nodo.

# Installazione del driver ottimizzato per ScyllaDB
pip install scylla-driver

In alternativa è possibile usare il driver standard di Cassandra, che rimane pienamente funzionante:

# Driver Cassandra compatibile con ScyllaDB
pip install cassandra-driver

Connessione al cluster

La connessione avviene tramite un oggetto Cluster che accetta una lista di nodi di contatto. Una volta creato il cluster si ottiene una Session, che rappresenta il contesto di esecuzione delle query ed è thread-safe.

from cassandra.cluster import Cluster
from cassandra.auth import PlainTextAuthProvider

# Autenticazione opzionale se il cluster la richiede
auth_provider = PlainTextAuthProvider(
    username="scylla",
    password="secret"
)

# Creazione del cluster con nodi di contatto
cluster = Cluster(
    contact_points=["127.0.0.1"],
    port=9042,
    auth_provider=auth_provider
)

# Apertura della sessione
session = cluster.connect()

# Esecuzione di una query di verifica
row = session.execute("SELECT release_version FROM system.local").one()
print(f"Versione del cluster: {row.release_version}")

È buona pratica fornire più di un nodo come contact point per garantire la resilienza iniziale: il driver scoprirà automaticamente la topologia completa del cluster dopo il primo handshake.

Creazione di keyspace e tabelle

In ScyllaDB i dati sono organizzati in keyspace, che fungono da namespace e definiscono la strategia di replica. All'interno di un keyspace si creano le tabelle, con uno schema che deve riflettere i pattern di accesso previsti.

keyspace_query = """
CREATE KEYSPACE IF NOT EXISTS shop
WITH replication = {
    'class': 'NetworkTopologyStrategy',
    'replication_factor': 3
}
"""

session.execute(keyspace_query)
session.set_keyspace("shop")

# Tabella ordini con chiave di partizione composta
table_query = """
CREATE TABLE IF NOT EXISTS orders (
    customer_id uuid,
    order_id timeuuid,
    product_name text,
    quantity int,
    total decimal,
    created_at timestamp,
    PRIMARY KEY (customer_id, order_id)
) WITH CLUSTERING ORDER BY (order_id DESC)
"""

session.execute(table_query)

Nell'esempio la chiave primaria è composta da customer_id come chiave di partizione e order_id come clustering key. Tutti gli ordini di uno stesso cliente verranno memorizzati nella stessa partizione, ordinati per order_id decrescente, rendendo molto efficiente il recupero degli ultimi ordini di un utente.

Prepared statement

L'uso dei prepared statement è fortemente raccomandato in produzione. Il driver invia il testo della query al cluster una sola volta, riceve un identificativo e può poi eseguirla ripetutamente passando solo i parametri. Questo riduce il carico di parsing sul server, previene attacchi di tipo CQL injection e permette al driver di calcolare correttamente il token della partizione per il routing.

import uuid
from datetime import datetime
from decimal import Decimal

# Preparazione una sola volta
insert_order = session.prepare("""
    INSERT INTO orders (customer_id, order_id, product_name, quantity, total, created_at)
    VALUES (?, ?, ?, ?, ?, ?)
""")

select_orders = session.prepare("""
    SELECT order_id, product_name, quantity, total, created_at
    FROM orders
    WHERE customer_id = ?
    LIMIT ?
""")

customer_id = uuid.uuid4()

# Inserimento di un singolo ordine
session.execute(insert_order, (
    customer_id,
    uuid.uuid1(),
    "Tastiera meccanica",
    1,
    Decimal("129.90"),
    datetime.utcnow()
))

# Lettura degli ultimi dieci ordini del cliente
rows = session.execute(select_orders, (customer_id, 10))
for row in rows:
    print(row.product_name, row.total)

Query asincrone

Il driver supporta l'esecuzione non bloccante tramite il metodo execute_async, che restituisce un oggetto ResponseFuture. Questo pattern è fondamentale quando si devono eseguire molte operazioni in parallelo, sfruttando il parallelismo del cluster senza saturare un singolo thread applicativo.

from cassandra.concurrent import execute_concurrent_with_args

# Preparazione di molti inserimenti concorrenti
orders_to_insert = [
    (uuid.uuid4(), uuid.uuid1(), f"Prodotto {i}", i, Decimal(f"{i * 10}.00"), datetime.utcnow())
    for i in range(1, 1001)
]

# Esecuzione concorrente con controllo del parallelismo
results = execute_concurrent_with_args(
    session,
    insert_order,
    orders_to_insert,
    concurrency=100
)

# Verifica dei risultati
failed = sum(1 for success, _ in results if not success)
print(f"Inserimenti falliti: {failed}")

La funzione execute_concurrent_with_args è un helper di alto livello che gestisce automaticamente il backpressure, limitando il numero di richieste in volo al valore di concurrency. In pratica è il modo più semplice per eseguire migliaia di operazioni ottenendo il massimo throughput senza scrivere codice basato su future.

Paginazione automatica

Quando una query restituisce molte righe, ScyllaDB utilizza la paginazione per evitare di trasferire risultati enormi in un'unica risposta. Il driver Python gestisce questo meccanismo in modo trasparente: iterando sul risultato, le pagine successive vengono richieste automaticamente.

from cassandra.query import SimpleStatement

statement = SimpleStatement(
    "SELECT customer_id, order_id, product_name FROM orders",
    fetch_size=500
)

# Iterazione con paginazione automatica
count = 0
for row in session.execute(statement):
    count += 1

print(f"Righe totali lette: {count}")

Il parametro fetch_size stabilisce quante righe vengono richieste per ogni pagina. Un valore troppo basso aumenta il numero di round-trip, mentre uno troppo alto può causare pressione di memoria sul client.

Batch statement

I batch permettono di raggruppare più operazioni in una singola richiesta. Tuttavia in ScyllaDB i batch hanno semantiche diverse da quelle dei database relazionali: un logged batch garantisce l'atomicità ma ha un costo elevato, mentre un unlogged batch è utile solo quando tutte le operazioni insistono sulla stessa partizione.

from cassandra.query import BatchStatement, BatchType

# Batch non loggato per operazioni sulla stessa partizione
batch = BatchStatement(batch_type=BatchType.UNLOGGED)

same_customer = uuid.uuid4()

for i in range(5):
    batch.add(insert_order, (
        same_customer,
        uuid.uuid1(),
        f"Articolo {i}",
        1,
        Decimal("19.90"),
        datetime.utcnow()
    ))

session.execute(batch)

Come regola generale, se le righe appartengono a partizioni diverse è quasi sempre più efficiente usare execute_concurrent_with_args invece di un batch, perché il driver può parallelizzare le richieste verso i nodi corretti.

Livelli di consistenza

ScyllaDB eredita da Cassandra il modello di consistenza tunabile. Ogni query può specificare un livello di consistenza che determina quanti repliche devono confermare l'operazione prima che venga considerata riuscita.

from cassandra import ConsistencyLevel

# Scrittura con consistenza quorum
insert_order.consistency_level = ConsistencyLevel.QUORUM

# Lettura con consistenza locale per ridurre la latenza cross-DC
select_orders.consistency_level = ConsistencyLevel.LOCAL_ONE

In un cluster multi-datacenter è spesso preferibile usare i livelli LOCAL_ONE o LOCAL_QUORUM, che limitano il coordinamento al datacenter locale evitando latenze di rete geografiche.

Object mapping con cqlengine

Il driver include un ORM leggero chiamato cqlengine che permette di definire i modelli come classi Python, in modo simile a Django ORM o SQLAlchemy. È utile per applicazioni di media complessità dove si desidera astrarre la sintassi CQL.

from cassandra.cqlengine import columns
from cassandra.cqlengine.models import Model
from cassandra.cqlengine.connection import setup
from cassandra.cqlengine.management import sync_table

setup(["127.0.0.1"], "shop", protocol_version=4)

class Product(Model):
    __keyspace__ = "shop"
    category = columns.Text(partition_key=True)
    product_id = columns.UUID(primary_key=True, default=uuid.uuid4)
    name = columns.Text()
    price = columns.Decimal()
    in_stock = columns.Boolean(default=True)

# Sincronizzazione dello schema
sync_table(Product)

# Creazione di un nuovo prodotto
Product.create(
    category="electronics",
    name="Monitor 4K",
    price=Decimal("399.00")
)

# Query sulla partizione
for product in Product.objects(category="electronics"):
    print(product.name, product.price)

Gestione delle eccezioni

Le applicazioni di produzione devono gestire correttamente i fallimenti transitori. Il driver espone una gerarchia di eccezioni che permette di distinguere tra timeout, indisponibilità del cluster ed errori di sintassi.

from cassandra import (
    ReadTimeout,
    WriteTimeout,
    Unavailable,
    InvalidRequest
)

try:
    session.execute(insert_order, (
        customer_id,
        uuid.uuid1(),
        "Cuffie wireless",
        2,
        Decimal("89.00"),
        datetime.utcnow()
    ))
except WriteTimeout as e:
    # La scrittura potrebbe essere stata applicata su alcuni nodi
    print(f"Timeout in scrittura, livello raggiunto: {e.received_responses}")
except Unavailable as e:
    # Repliche insufficienti per soddisfare il livello di consistenza
    print(f"Repliche disponibili: {e.alive_replicas}")
except InvalidRequest as e:
    # Errore di sintassi o schema non valido
    print(f"Richiesta non valida: {e}")

Chiusura delle risorse

Il Cluster e la Session mantengono pool di connessioni persistenti verso i nodi. È importante chiuderli esplicitamente al termine dell'applicazione per rilasciare socket, thread e memoria.

# Chiusura ordinata delle risorse
session.shutdown()
cluster.shutdown()

In applicazioni di lunga durata come web server o worker è consigliabile creare il cluster e la sessione una sola volta all'avvio e riutilizzarli per tutta la vita del processo, evitando la creazione ripetuta che sarebbe estremamente costosa.

Considerazioni sulle prestazioni

Per ottenere il massimo da ScyllaDB con Python è importante tenere presenti alcune regole pratiche. Usare sempre prepared statement, evitare i logged batch tranne quando l'atomicità è strettamente necessaria, preferire l'esecuzione concorrente alla serializzazione sincrona e modellare le tabelle seguendo i pattern di query previsti invece di normalizzare come in un database relazionale. Il token-aware routing, attivo di default, garantisce che ogni richiesta venga inviata direttamente a un nodo replica, eliminando un salto di rete; con il scylla-driver questo meccanismo viene esteso fino al singolo core CPU del nodo.

Conclusioni

ScyllaDB offre a Python un accesso semplice ma potentissimo a un database distribuito ad alte prestazioni. Grazie alla compatibilità con il protocollo di Cassandra è possibile riutilizzare l'intero ecosistema esistente, ottenendo però latenze più basse e un throughput maggiore. Partendo da una connessione di base si può rapidamente arrivare a gestire carichi di lavoro complessi sfruttando prepared statement, esecuzione concorrente, paginazione automatica e un ORM integrato. La combinazione di queste funzionalità rende ScyllaDB una scelta solida per applicazioni Python che richiedono scalabilità orizzontale e prestazioni prevedibili.