Salta al contenuto principale

03 - Persistenza sull'Edge: Workers KV, R2 Object Storage e D1 SQLite

Edge Computing 14 min Persistenza sull'Edge: Workers KV, R2 Object Storage e D1 SQLite Confronto pratico tra i tre layer di storage disponibili nei...

Cosa ne pensi di questo articolo?

03 - Persistenza sull'Edge: Workers KV, R2 Object Storage e D1 SQLite
Edge ComputingFoto:Ruben BoekelooPexels

In Breve

Edge Computing 14 min Persistenza sull'Edge: Workers KV, R2 Object Storage e D1 SQLite Confronto pratico tra i tre layer di storage disponibili nei...

Edge Computing 14 min

Persistenza sull'Edge: Workers KV, R2 Object Storage e D1 SQLite

Confronto pratico tra i tre layer di storage disponibili nei Cloudflare Workers: KV per dati globali chiave-valore, R2 per object storage senza egress fee e D1 per query SQL relazionali all'edge.

Workers KV Cloudflare R2 D1 SQLite Edge Storage Edge Database

Il Problema della Persistenza all'Edge

I V8 isolates che alimentano Cloudflare Workers sono stateless per design: ogni isolate può essere creato e distrutto in microsecondi, e lo stato JavaScript locale non persiste tra richieste (o persiste solo occasionalmente, per concorrenza sullo stesso isolate). Per costruire applicazioni reali serve persistenza esterna.

Cloudflare offre tre prodotti di storage nativi, ognuno progettato per un caso d'uso specifico. Scegliere quello sbagliato può significare query lente, costi elevati o inconsistenze difficili da debuggare. Questo articolo li confronta in dettaglio con esempi di codice reali.

Cosa Imparerai

  • Workers KV: architettura eventually consistent, API, limiti e use case ideali
  • Cloudflare R2: S3-compatible object storage senza egress fee, multipart upload
  • D1 SQLite: database relazionale all'edge, query SQL, migrations con Drizzle ORM
  • Confronto di costi: KV vs R2 vs D1 a diverse scale di utilizzo
  • Pattern di integrazione: come combinare KV + D1 per caching intelligente
  • Configurazione dei binding in wrangler.toml per ogni storage type

Workers KV: Key-Value Globale ed Eventually Consistent

Workers KV è un datastore chiave-valore distribuito globalmente. Quando scrivi un valore, viene replicato in tutti i PoP Cloudflare nel giro di secondi. Quando leggi, ottieni il valore dal PoP più vicino: latenza di lettura tipicamente inferiore a 1ms.

Il trade-off fondamentale: KV è eventually consistent. Una scrittura può impiegare fino a 60 secondi per essere visibile globalmente. Questo lo rende inadatto per dati che cambiano frequentemente e devono essere letti immediatamente dopo la scrittura.

Configurazione KV in wrangler.toml

# wrangler.toml

[[kv_namespaces]]
binding = "CACHE"          # Nome del binding nel codice TypeScript
id = "xxxxxxxxxxxxxxxxxx"  # ID del namespace in produzione
preview_id = "yyyyyyyyyy"  # ID del namespace per wrangler dev

[[kv_namespaces]]
binding = "SESSIONS"
id = "aaaaaaaaaaaaaaaaa"
preview_id = "bbbbbbbbbbbbb"

# Per creare i namespace:
# wrangler kv namespace create CACHE
# wrangler kv namespace create CACHE --preview

API Workers KV in TypeScript

// src/services/cache.service.ts

export interface Env {
  CACHE: KVNamespace;
  SESSIONS: KVNamespace;
}

// -------- OPERAZIONI BASE --------

// WRITE: put con TTL opzionale (in secondi)
await env.CACHE.put('user:123', JSON.stringify({ name: 'Mario', role: 'admin' }), {
  expirationTtl: 3600,  // scade tra 1 ora
});

// WRITE senza TTL (valore permanente)
await env.CACHE.put('config:features', JSON.stringify({ darkMode: true }));

// WRITE con expiration assoluta (Unix timestamp)
await env.CACHE.put('promo:summer', 'active', {
  expiration: Math.floor(Date.now() / 1000) + 86400,  // scade tra 24h
});

// READ: get con tipo di decodifica
const raw = await env.CACHE.get('user:123');
// raw: string | null

const parsed = await env.CACHE.get<{ name: string; role: string }>('user:123', 'json');
// parsed: { name: string; role: string } | null

// READ con metadata
const { value, metadata } = await env.CACHE.getWithMetadata('user:123', 'json');

// DELETE
await env.CACHE.delete('user:123');

// -------- LISTING (con limitazioni) --------
// KV supporta listing, ma e lento e con limite di 1000 chiavi per chiamata
const listing = await env.CACHE.list({ prefix: 'user:', limit: 100 });
for (const key of listing.keys) {
  console.log(key.name, key.expiration, key.metadata);
}

// -------- PATTERN: cache-aside --------
async function getUserCached(userId: string, env: Env): Promise<User | null> {
  const cacheKey = `user:${userId}`;

  // 1. Controlla la cache KV
  const cached = await env.CACHE.get<User>(cacheKey, 'json');
  if (cached) {
    return cached;  // Cache hit: risposta da KV < 1ms
  }

  // 2. Cache miss: fetch dall'origine
  const user = await fetchUserFromDatabase(userId);
  if (!user) return null;

  // 3. Popola la cache (eventually consistent, ok per profili utente)
  await env.CACHE.put(cacheKey, JSON.stringify(user), {
    expirationTtl: 300,  // 5 minuti
  });

  return user;
}
Caratteristica Dettaglio KV
Modello di consistenza Eventually consistent (60s max propagazione)
Latenza lettura < 1ms (da PoP locale dopo warm)
Latenza scrittura ~100ms (conferma), replica globale asincrona
Dimensione max valore 25MB per valore
Dimensione max chiave 512 bytes
Costo lettura $0.50 per milione (free: 10M/mese)
Costo scrittura $5 per milione (free: 1M/mese)
Ideale per Configurazione, feature flags, session store, caching API
Non adatto per Contatori in tempo reale, dati che cambiano ogni secondo

Cloudflare R2: Object Storage S3-Compatible Senza Egress Fee

R2 è il prodotto che ha fatto più rumore al lancio nel 2022: object storage compatibile con le API S3 di AWS, con zero costi di egress. Su S3, trasferire 1TB di dati verso internet costa ~90$. Su R2, è gratis.

R2 è ideale per: upload di file utente, asset statici, backup, log archiviati, qualsiasi oggetto binario che deve essere recuperato da browser o da altri Workers.

Configurazione R2 in wrangler.toml

# wrangler.toml

[[r2_buckets]]
binding = "ASSETS"         # Nome del binding TypeScript
bucket_name = "my-assets"  # Nome del bucket in Cloudflare

# Per creare il bucket:
# wrangler r2 bucket create my-assets

API R2 in TypeScript

// src/services/storage.service.ts

export interface Env {
  ASSETS: R2Bucket;
}

// -------- UPLOAD --------

// Upload di testo semplice
await env.ASSETS.put('documents/readme.txt', 'Contenuto del file', {
  httpMetadata: { contentType: 'text/plain; charset=utf-8' },
  customMetadata: { uploadedBy: 'user-123', version: '1' },
});

// Upload di JSON
const data = { users: [{ id: 1, name: 'Mario' }] };
await env.ASSETS.put('data/users.json', JSON.stringify(data), {
  httpMetadata: { contentType: 'application/json' },
});

// Upload di un file binario da una Request (multipart form)
async function handleFileUpload(request: Request, env: Env): Promise<Response> {
  const formData = await request.formData();
  const file = formData.get('file') as File | null;

  if (!file) {
    return new Response('No file provided', { status: 400 });
  }

  // Genera un nome unico per evitare collisioni
  const key = `uploads/${Date.now()}-${file.name}`;

  await env.ASSETS.put(key, file.stream(), {
    httpMetadata: {
      contentType: file.type,
      contentLength: file.size,
    },
    customMetadata: {
      originalName: file.name,
      uploadedAt: new Date().toISOString(),
    },
  });

  return Response.json({ key, size: file.size, type: file.type });
}

// -------- DOWNLOAD --------

async function serveAsset(key: string, env: Env): Promise<Response> {
  const object = await env.ASSETS.get(key);

  if (!object) {
    return new Response('Not Found', { status: 404 });
  }

  // Leggi i metadata HTTP
  const headers = new Headers();
  object.writeHttpMetadata(headers);
  headers.set('etag', object.httpEtag);

  // Aggiungi headers di caching appropriati
  headers.set('Cache-Control', 'public, max-age=31536000, immutable');

  return new Response(object.body, { headers });
}

// -------- LISTING --------

async function listUploads(prefix: string, env: Env) {
  const listed = await env.ASSETS.list({
    prefix: `uploads/${prefix}`,
    limit: 100,
    // cursor: paginationCursor  // per paginare
  });

  return listed.objects.map(obj => ({
    key: obj.key,
    size: obj.size,
    uploaded: obj.uploaded.toISOString(),
    etag: obj.etag,
    customMetadata: obj.customMetadata,
  }));
}

// -------- DELETE --------

await env.ASSETS.delete('uploads/old-file.txt');

// Delete multiplo (fino a 1000 chiavi)
await env.ASSETS.delete(['file1.txt', 'file2.txt', 'file3.txt']);

// -------- HEAD: verifica esistenza senza download --------

const headResult = await env.ASSETS.head('uploads/documento.pdf');
if (headResult) {
  console.log('Size:', headResult.size);
  console.log('Uploaded:', headResult.uploaded);
}

R2 con Accesso Pubblico e URL Presigned

// Pattern: servire file pubblicamente tramite Worker con access control

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    // URL pattern: /files/{key}
    const key = url.pathname.replace('/files/', '');

    if (!key) {
      return new Response('Key required', { status: 400 });
    }

    // Implementa qui la tua logica di autenticazione/autorizzazione
    const authorized = await checkAccess(request, key, env);
    if (!authorized) {
      return new Response('Forbidden', { status: 403 });
    }

    // Gestisci il conditional request (ETag/If-None-Match)
    const etag = request.headers.get('If-None-Match');
    const object = await env.ASSETS.get(key, {
      onlyIf: etag ? { etagDoesNotMatch: etag } : undefined,
      range: request.headers.get('Range') ?? undefined,
    });

    if (!object) {
      // Potrebbe essere 404 o 304 Not Modified
      const head = await env.ASSETS.head(key);
      if (!head) return new Response('Not Found', { status: 404 });
      return new Response(null, { status: 304, headers: { etag: head.httpEtag } });
    }

    const headers = new Headers();
    object.writeHttpMetadata(headers);
    headers.set('etag', object.httpEtag);

    if (object.range) {
      headers.set('Content-Range', `bytes ${object.range.offset}-${
        (object.range.offset ?? 0) + (object.range.length ?? 0) - 1
      }/${object.size}`);
      return new Response(object.body, { status: 206, headers });
    }

    return new Response(object.body, { headers });
  },
};

D1 SQLite: Database Relazionale all'Edge

D1 è il prodotto più recente e ambizioso: SQLite nativo all'edge. Un database relazionale completo, con JOIN, transazioni, query complesse, tutto accessibile dai Workers senza cold start di connessione, senza egress fee, con replica automatica verso tutti i PoP.

D1 usa SQLite internamente e lo replica verso i data center regionali di Cloudflare. Le scritture vanno al nodo primario (eventual consistency per i replica), le letture possono servire da replica locale per latenza minima.

Configurazione D1 in wrangler.toml

# wrangler.toml

[[d1_databases]]
binding = "DB"
database_name = "mio-database"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

# Per creare il database:
# wrangler d1 create mio-database

Migrations e Schema con D1

# Crea il file di migration
wrangler d1 migrations create mio-database "create users table"
# Crea: migrations/0001_create_users_table.sql

# migrations/0001_create_users_table.sql
-- migrations/0001_create_users_table.sql
CREATE TABLE IF NOT EXISTS users (
  id        INTEGER PRIMARY KEY AUTOINCREMENT,
  email     TEXT UNIQUE NOT NULL,
  name      TEXT NOT NULL,
  role      TEXT NOT NULL DEFAULT 'user',
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);

CREATE TABLE IF NOT EXISTS sessions (
  id         TEXT PRIMARY KEY,
  user_id    INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  expires_at DATETIME NOT NULL,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
# Applica le migration in locale (wrangler dev)
wrangler d1 migrations apply mio-database --local

# Applica le migration in produzione
wrangler d1 migrations apply mio-database

API D1 in TypeScript: Query Dirette

// src/services/user.service.ts

export interface Env {
  DB: D1Database;
}

interface User {
  id: number;
  email: string;
  name: string;
  role: string;
  created_at: string;
}

// -------- SELECT --------

// Query prepared statement (SEMPRE usare prepared statements: previene SQL injection)
async function getUserById(id: number, env: Env): Promise<User | null> {
  const result = await env.DB
    .prepare('SELECT * FROM users WHERE id = ?')
    .bind(id)
    .first<User>();
  return result;
}

// SELECT multipli
async function getUsersByRole(role: string, env: Env): Promise<User[]> {
  const { results } = await env.DB
    .prepare('SELECT id, email, name, role, created_at FROM users WHERE role = ? ORDER BY created_at DESC')
    .bind(role)
    .all<User>();
  return results;
}

// SELECT con pagination
async function getUsers(page: number, pageSize: number, env: Env) {
  const offset = (page - 1) * pageSize;

  const [{ results }, { total }] = await Promise.all([
    env.DB
      .prepare('SELECT * FROM users LIMIT ? OFFSET ?')
      .bind(pageSize, offset)
      .all<User>(),
    env.DB
      .prepare('SELECT COUNT(*) as total FROM users')
      .first<{ total: number }>()
      .then(r => r ?? { total: 0 }),
  ]);

  return {
    users: results,
    pagination: { page, pageSize, total: total.total, totalPages: Math.ceil(total.total / pageSize) },
  };
}

// -------- INSERT --------

async function createUser(
  email: string,
  name: string,
  env: Env,
): Promise<User> {
  const result = await env.DB
    .prepare('INSERT INTO users (email, name) VALUES (?, ?) RETURNING *')
    .bind(email, name)
    .first<User>();

  if (!result) {
    throw new Error('Failed to create user');
  }

  return result;
}

// -------- UPDATE --------

async function updateUser(id: number, updates: Partial<Pick<User, 'name' | 'role'>>, env: Env) {
  const setClauses: string[] = [];
  const values: (string | number)[] = [];

  if (updates.name !== undefined) {
    setClauses.push('name = ?');
    values.push(updates.name);
  }
  if (updates.role !== undefined) {
    setClauses.push('role = ?');
    values.push(updates.role);
  }

  if (setClauses.length === 0) return;

  setClauses.push('updated_at = CURRENT_TIMESTAMP');
  values.push(id);

  await env.DB
    .prepare(`UPDATE users SET ${setClauses.join(', ')} WHERE id = ?`)
    .bind(...values)
    .run();
}

// -------- TRANSAZIONI (batch) --------

async function createUserWithSession(
  email: string,
  name: string,
  sessionId: string,
  expiresAt: Date,
  env: Env,
): Promise<User> {
  // D1 supporta batch per eseguire piu statement in una singola round-trip
  const [userResult] = await env.DB.batch([
    env.DB
      .prepare('INSERT INTO users (email, name) VALUES (?, ?) RETURNING *')
      .bind(email, name),
    env.DB
      .prepare('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, last_insert_rowid(), ?)')
      .bind(sessionId, expiresAt.toISOString()),
  ]);

  const user = userResult.results[0] as User;
  return user;
}

D1 con Drizzle ORM: Type Safety Completa

Drizzle ORM supporta D1 nativamente e offre un'esperienza developer eccellente con type inference automatica dallo schema:

// npm install drizzle-orm
// npm install -D drizzle-kit @types/better-sqlite3

// src/db/schema.ts
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';

export const users = sqliteTable('users', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  email: text('email').unique().notNull(),
  name: text('name').notNull(),
  role: text('role', { enum: ['user', 'admin', 'moderator'] }).default('user').notNull(),
  createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
});

export const sessions = sqliteTable('sessions', {
  id: text('id').primaryKey(),
  userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
  expiresAt: text('expires_at').notNull(),
  createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
});

// Tipi TypeScript inferiti dallo schema
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
// src/db/index.ts
import { drizzle } from 'drizzle-orm/d1';
import * as schema from './schema';

export function createDb(d1: D1Database) {
  return drizzle(d1, { schema });
}

// src/services/user.drizzle.service.ts
import { eq, desc, count } from 'drizzle-orm';
import { createDb } from '../db';
import { users, type User, type NewUser } from '../db/schema';

export async function getUserById(id: number, env: { DB: D1Database }): Promise<User | undefined> {
  const db = createDb(env.DB);
  return db.select().from(users).where(eq(users.id, id)).get();
}

export async function createUser(data: NewUser, env: { DB: D1Database }): Promise<User> {
  const db = createDb(env.DB);
  const [user] = await db.insert(users).values(data).returning();
  return user;
}

export async function getAdminUsers(env: { DB: D1Database }): Promise<User[]> {
  const db = createDb(env.DB);
  return db.select()
    .from(users)
    .where(eq(users.role, 'admin'))
    .orderBy(desc(users.createdAt))
    .all();
}

Confronto: KV vs R2 vs D1

Criterio Workers KV R2 D1 SQLite
Tipo di dato Chiave-valore (stringa/blob) Oggetti binari (file) Relazionale (tabelle SQL)
Consistenza Eventually consistent Eventual (strong con Workers) Strong (primary), eventual (replica)
Query capabilities Solo get/put/delete per chiave Solo get/put/delete per chiave SQL completo: JOIN, aggregati, indici
Dimensione massima 25MB per valore 5TB per oggetto 10GB (beta: 2GB)
Costo storage $0.50/GB/mese $0.015/GB/mese $0.75/GB/mese
Costo operazioni $5/M write, $0.50/M read $4.50/M write, $0.36/M read $0.001/M row write, $0.001/M row read
Latenza lettura < 1ms (da cache PoP) ~10-50ms (da storage) ~1-5ms (query semplici)
Use case ideali Sessioni, config, cache API Upload file, asset, backup CRUD app, catalogo, utenti

Pattern Avanzato: KV come Cache Layer per D1

Un pattern efficace combina D1 (dati autorevoli) con KV (cache rapida):

// src/services/cached-user.service.ts

export interface Env {
  DB: D1Database;
  CACHE: KVNamespace;
}

const USER_CACHE_TTL = 300; // 5 minuti

export async function getUserCached(userId: number, env: Env) {
  const cacheKey = `user:v2:${userId}`;

  // 1. Controlla KV cache prima (sub-ms)
  const cached = await env.CACHE.get<User>(cacheKey, 'json');
  if (cached) return cached;

  // 2. Miss: leggi da D1 (query SQL)
  const user = await getUserById(userId, env);
  if (!user) return null;

  // 3. Popola la cache KV in background (non blocca la risposta)
  // (usa ctx.waitUntil() nel fetch handler per non bloccare)
  await env.CACHE.put(cacheKey, JSON.stringify(user), {
    expirationTtl: USER_CACHE_TTL,
  });

  return user;
}

export async function invalidateUserCache(userId: number, env: Env): Promise<void> {
  await env.CACHE.delete(`user:v2:${userId}`);
}

// Quando aggiorni un utente, invalida la cache
export async function updateUserAndInvalidate(
  userId: number,
  updates: Partial<User>,
  env: Env,
): Promise<void> {
  await updateUser(userId, updates, env);
  await invalidateUserCache(userId, env);
}

Conclusioni e Prossimi Passi

La scelta del layer di storage dipende dal tipo di dato: KV per valori chiave-valore con alta frequenza di lettura e bassa frequenza di scrittura, R2 per file binari e asset, D1 per dati strutturati con relazioni e query complesse. In molte applicazioni reali userai tutti e tre in combinazione.

Prossimi Articoli della Serie

  • Articolo 4: Durable Objects - Stato Fortemente Consistente e WebSocket: quando KV non è sufficiente e hai bisogno di consistenza forte e coordinazione distribuita.
  • Articolo 5: Workers AI - Inferenza di LLM e Modelli Vision Direttamente sull'Edge: come eseguire modelli AI nei Workers usando il binding AI.
  • Articolo 10: Architetture Full-Stack sull'Edge - un case study completo che combina Workers + D1 + R2 + CI/CD con GitHub Actions.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti