Skip to content

The search box knows all the secrets -- try it!

Polecat is part of the Critter Stack ecosystem.

JasperFx Logo JasperFx provides formal support for Polecat and other Critter Stack libraries. Please check our Support Plans for more details.

Archiving Streams ​

Polecat supports archiving event streams to logically remove them from active queries without permanently deleting the data.

Archiving a Stream ​

cs
session.Events.ArchiveStream(streamId);
await session.SaveChangesAsync();

This sets is_archived = 1 on both the pc_streams and pc_events tables for the stream.

Effects of Archiving ​

When a stream is archived:

  • FetchStreamAsync excludes the archived stream
  • The async daemon's event loader skips archived events
  • Attempting to append to an archived stream throws ArchivedStreamException

The append refusal is raised at SaveChangesAsync(), when Polecat reads the stream's state, and it names the way back:

Event stream 'a1b2…' is archived and cannot be appended to. Call UnArchiveStream to reopen it, or start a new stream.

Polecat.Exceptions.ArchivedStreamException derives from JasperFx.Events.ArchivedStreamException, so store-agnostic code can catch the shared type. (It replaces InvalidStreamException, which is now obsolete and never thrown.)

Unarchiving a Stream ​

Restore an archived stream — this is what the refusal above points you at:

cs
session.Events.UnArchiveStream(streamId);
await session.SaveChangesAsync();

This sets is_archived = 0 on both tables, making the stream active again.

Tombstoning (Hard Delete) ​

For permanent removal of a stream and all its events:

cs
session.Events.TombstoneStream(streamId);
await session.SaveChangesAsync();

WARNING

Tombstoning permanently DELETEs the stream record and all associated events from the database. This cannot be undone.

Tombstoning works with both Guid and string stream IDs.

Archiving vs Tombstoning ​

OperationReversibleData PreservedUse Case
ArchiveYesYesSoft removal, compliance holds
TombstoneNoNoGDPR right to erasure, cleanup

Compacting a Stream ​

Compaction is the third option in that table: it replaces a stream's history with a single Compacted<T> snapshot event and deletes the events it folded. The stream keeps its version, so appending carries on as before, but a fold no longer has to replay everything below the compaction point — Compacted<T> fast-forwards it.

cs
await using var session = store.LightweightSession();
await session.Events.CompactStreamAsync<Freighter>(streamId);
await session.SaveChangesAsync();

Like every other session operation, the typed overload only queues the work — the replace, the deletes and the compaction watermark all land on SaveChangesAsync().

WARNING

Compaction DELETEs the events it folds. What survives is the snapshot inside the marker, so an aggregate that cannot be rebuilt from that snapshot alone cannot be rebuilt at all. Use StreamCompactingRequest<T>.Archiver to copy the events somewhere first if you need them.

Compacting without a compile-time type ​

IEventStore.CompactStreamAsync is the untyped overload. It reads the stream's recorded aggregate type from pc_streams and closes the typed operation over it, which is the only form available to a caller holding a runtime Type — a compaction policy that selects streams by aggregate type, for instance, or a tool with a "compact this stream" button:

cs
var streams = ((IEventStore)store).OpenReadOnlyEventStore().QueryStreamStates();

var overgrown = await streams
    .Where(x => x.AggregateType == typeof(Freighter) && x.Version - x.CompactedVersion > 500)
    .ToListAsync();

foreach (var state in overgrown)
{
    // Resolves Freighter from the stream state, and commits on its own
    await ((IEventStore)store).CompactStreamAsync(state.Id, cancellationToken);
}

Two differences from the typed overload are worth knowing:

  • It opens and commits its own session, so there is no SaveChangesAsync() to call.
  • It needs an aggregate type on the stream. A stream started with StartStream<T>() records one; a stream started without a type has nothing to resolve, and the call is refused rather than silently doing nothing. Name the type explicitly with CompactStreamAsync<T> in that case.

StreamState.CompactedVersion is the watermark the last compaction reached, so Version - CompactedVersion is the stream's un-compacted growth — which is what makes a policy like the one above idempotent instead of re-compacting the same streams on every pass.

Compacting a stream in one tenant's scope ​

Both untyped overloads take an optional tenant id. The stream-state read and the compaction itself then run on a session opened for that tenant, which is the action-side twin of OpenReadOnlyEventStore(tenantId):

cs
var streams = ((IEventStore)store).OpenReadOnlyEventStore("blue").QueryStreamStates();

var overgrown = await streams
    .Where(x => x.Version - x.CompactedVersion > 500)
    .ToListAsync();

foreach (var state in overgrown)
{
    await ((IEventStore)store).CompactStreamAsync(state.Id, "blue", cancellationToken);
}

Pass this overload whenever the selector was tenanted, and the tenant-less one only for a single-tenanted store. A policy that selects a tenant's streams through the tenanted reader and then calls the tenant-less action is reading and writing in two different scopes: on a conjoined store the action looks for those stream ids in the default tenant's partition, where they are not visible and are reported as missing, and on a store whose default tenant is disabled the call is refused outright. A null or empty tenant id means the default tenant, matching the reader.

Released under the MIT License.