Saltar al contenido principal

Almacén de Sesiones con EF Core

Vali-Blob.EFCore proporciona un almacén de sesiones reanudables usando Entity Framework Core. Compatible con PostgreSQL, MySQL/MariaDB, SQL Server y SQLite. Ideal si tu infraestructura no incluye Redis o si necesitas consultar y auditar las sesiones de subida con SQL.

Instalación

dotnet add package Vali-Blob.EFCore

Más el paquete del proveedor de base de datos:

# PostgreSQL
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL

# MySQL / MariaDB
dotnet add package Pomelo.EntityFrameworkCore.MySql

# SQL Server
dotnet add package Microsoft.EntityFrameworkCore.SqlServer

# SQLite (desarrollo y pruebas)
dotnet add package Microsoft.EntityFrameworkCore.Sqlite

Configuración con DbContext existente

Si tu aplicación ya tiene un DbContext, agrega el modelo de sesiones a él:

// AppDbContext.cs
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }

public DbSet<ResumableUploadSessionEntity> SesionesSubida =>
Set<ResumableUploadSessionEntity>();

// ... tus otras entidades

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ConfigureVali-Blob(); // Extensión de Vali-Blob.EFCore
base.OnModelCreating(modelBuilder);
}
}
// Program.cs
builder.Services
.AddVali-Blob(o => o.DefaultProvider = "aws")
.AddProvider<AWSS3Provider>("aws", opts => { /* ... */ })
.UseEFCoreResumableStore<AppDbContext>();

Configuración con DbContext dedicado

Para mantener las sesiones de Vali-Blob separadas de la base de datos principal:

builder.Services
.AddVali-Blob(o => o.DefaultProvider = "aws")
.AddProvider<AWSS3Provider>("aws", opts => { /* ... */ })
.UseEFCoreResumableStore(efcore =>
{
efcore.UseNpgsql(builder.Configuration.GetConnectionString("Vali-Blob")!);
});

EFCoreResumableStoreOptions

public class EFCoreResumableStoreOptions
{
/// <summary>Tiempo de vida predeterminado de las sesiones. Por defecto: 24 horas.</summary>
public TimeSpan DefaultSessionTtl { get; set; } = TimeSpan.FromHours(24);

/// <summary>Nombre del esquema SQL de la tabla. null = esquema predeterminado.</summary>
public string? Schema { get; set; }

/// <summary>Nombre de la tabla de sesiones. Por defecto: "ResumableUploadSessions".</summary>
public string TableName { get; set; } = "ResumableUploadSessions";
}
.UseEFCoreResumableStore<AppDbContext>(efcore =>
{
efcore.DefaultSessionTtl = TimeSpan.FromDays(7);
efcore.Schema = "storage";
efcore.TableName = "sesiones_subida";
})

Cadenas de conexión por proveedor

{
"ConnectionStrings": {
"Default": "Host=localhost;Database=miapp;Username=usuario;Password=contraseña",
"Vali-Blob": "Host=localhost;Database=valiblob_sessions;Username=usuario;Password=contraseña"
}
}
// PostgreSQL
builder.Services.AddDbContext<AppDbContext>(opts =>
opts.UseNpgsql(builder.Configuration.GetConnectionString("Default")));

// MySQL / MariaDB
builder.Services.AddDbContext<AppDbContext>(opts =>
opts.UseMySql(
builder.Configuration.GetConnectionString("Default"),
ServerVersion.AutoDetect(builder.Configuration.GetConnectionString("Default"))));

// SQL Server
builder.Services.AddDbContext<AppDbContext>(opts =>
opts.UseSqlServer(builder.Configuration.GetConnectionString("Default")));

// SQLite (desarrollo)
builder.Services.AddDbContext<AppDbContext>(opts =>
opts.UseSqlite("Data Source=sesiones-valiblob.db"));

Migraciones

# Crear la migración
dotnet ef migrations add AgregarVali-Blob --context AppDbContext

# Aplicar la migración
dotnet ef database update --context AppDbContext

Aplicar migraciones automáticamente al iniciar

var app = builder.Build();

using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync();
}

await app.RunAsync();

Esquema de la tabla generada

CREATE TABLE "ResumableUploadSessions" (
"Id" TEXT NOT NULL PRIMARY KEY, -- SessionId (GUID)
"Path" TEXT NOT NULL, -- Ruta de destino
"TotalSizeBytes" BIGINT NOT NULL, -- Tamaño total del archivo
"UploadedBytes" BIGINT NOT NULL DEFAULT 0, -- Bytes recibidos hasta ahora
"ChunksUploaded" INT NOT NULL DEFAULT 0, -- Número de chunks recibidos
"Status" INT NOT NULL DEFAULT 0, -- 0=Active, 1=Completed, 2=Aborted
"ContentType" TEXT, -- Tipo MIME del archivo
"MetadataJson" TEXT, -- Metadatos en JSON
"CreatedAt" TIMESTAMP WITH TIME ZONE NOT NULL,
"ExpiresAt" TIMESTAMP WITH TIME ZONE NOT NULL,
"CompletedAt" TIMESTAMP WITH TIME ZONE
);

-- Índice para la limpieza de sesiones expiradas
CREATE INDEX "IX_ResumableUploadSessions_Status_ExpiresAt"
ON "ResumableUploadSessions" ("Status", "ExpiresAt");

Consultas de diagnóstico y auditoría

Una ventaja de EF Core es poder consultar las sesiones directamente con LINQ:

// Sesiones activas actualmente
var activas = await db.SesionesSubida
.Where(s => s.Status == ResumableUploadStatus.Active
&& s.ExpiresAt > DateTimeOffset.UtcNow)
.OrderByDescending(s => s.CreatedAt)
.ToListAsync();

// Total de bytes en proceso de subida (sesiones activas)
var bytesEnCurso = await db.SesionesSubida
.Where(s => s.Status == ResumableUploadStatus.Active)
.SumAsync(s => s.TotalSizeBytes);

// Tasa de éxito de las últimas 24 horas
var hace24h = DateTimeOffset.UtcNow.AddHours(-24);
var completadas = await db.SesionesSubida
.Where(s => s.CreatedAt >= hace24h && s.Status == ResumableUploadStatus.Completed)
.CountAsync();
var total = await db.SesionesSubida
.Where(s => s.CreatedAt >= hace24h)
.CountAsync();

Console.WriteLine($"Tasa de éxito: {(total > 0 ? completadas * 100.0 / total : 0):F1}%");

Limpieza de sesiones expiradas

A diferencia de Redis, EF Core no tiene TTL automático. Debes implementar un job de limpieza:

public class LimpiezaSesionesExpiradas(
IServiceScopeFactory scopeFactory,
ILogger<LimpiezaSesionesExpiradas> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(TimeSpan.FromHours(6));

while (await timer.WaitForNextTickAsync(ct))
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var storage = scope.ServiceProvider.GetRequiredService<IResumableStorageProvider>();

// 1. Abortar sesiones expiradas y limpiar sus chunks
var expiradas = await db.SesionesSubida
.Where(s => s.Status == ResumableUploadStatus.Active
&& s.ExpiresAt < DateTimeOffset.UtcNow)
.ToListAsync(ct);

foreach (var sesion in expiradas)
{
await storage.AbortResumableUploadAsync(sesion.Id, ct);
logger.LogInformation("Sesión expirada abortada: {SessionId}", sesion.Id);
}

// 2. Eliminar registros históricos con más de 30 días
var limiteHistorial = DateTimeOffset.UtcNow.AddDays(-30);
var eliminados = await db.SesionesSubida
.Where(s => s.Status != ResumableUploadStatus.Active
&& s.CreatedAt < limiteHistorial)
.ExecuteDeleteAsync(ct);

if (eliminados > 0)
logger.LogInformation("Eliminados {Count} registros históricos de sesiones", eliminados);
}
}
}

builder.Services.AddHostedService<LimpiezaSesionesExpiradas>();
Consejo

Si tu aplicación ya usa Hangfire o Quartz.NET para tareas programadas, integra la limpieza de sesiones en ese sistema. Tendrás mayor control sobre reintentos, monitoreo y alertas ante fallos en la tarea de limpieza.

Información

Para bases de datos con alta carga, agrega índices adicionales sobre CreatedAt si consultas sesiones por fecha frecuentemente, y sobre Path si buscas sesiones por ruta de archivo. La tabla suele ser pequeña (sesiones activas son efímeras), pero el historial puede crecer si no se limpia periódicamente.