Skip to content

JAMES-4231 S3 Object Compaction - #3193

Open
HesandaLiyanage wants to merge 24 commits into
apache:masterfrom
HesandaLiyanage:JAMES-4231
Open

HesandaLiyanage wants to merge 24 commits into
apache:masterfrom
HesandaLiyanage:JAMES-4231

Conversation

@HesandaLiyanage

Copy link
Copy Markdown

JIRA: https://issues.apache.org/jira/browse/JAMES-4231

Context & Problem

High email ingest volumes in Apache James deployments utilizing S3/MinIO object storage result in hundreds of millions of small S3 objects (average body/header size ~20–50KB). Operating at this granularity introduces high S3 API request fees (PUT/GET/LIST), elevated bucket listing latency during storage maintenance and garbage collection, and reduced overall object storage throughput.

Solution Overview

This PR implements autonomous S3 object compaction for Apache James, packing historical standalone blobs into large, immutable chunk files (~100MB target size) with slot-level virtual addressing and transparent single-request HTTP ranged read support.

Key Components & Architecture

  1. Chunk Format (ChunkFormat):

    • Single binary chunk containing concatenated slots and a trailer footer:
      [Header (16B)][Slot 0]...[Slot N-1][Footer][Footer Size (4B)][End Magic 0x454F4643]
    • Per-slot independent Zstandard compression: Each slot has its own Zstd codec (content-encoding=zstd\n), allowing single-slot ranged reads without decompressing or buffering the full 100MB chunk.
    • Suffix-indexed 64KB footer: Suffix range read (readRange(..., -65536, -1)) fetches chunk slot metadata in $O(1)$ without scanning payloads.
    • Fully documented in src/adr/0076-s3-object-compaction.md.
  2. Virtual Slot Addressing & Ranged Reads (ChunkedBlobStoreDAO & ChunkId):

    • Format: <family>_<generation>_chunk<hash>~<offset>~<limit>.
    • ChunkedBlobStoreDAO transparently intercepts slot references and issues HTTP byte-range requests directly against the underlying storage connector (readRange(bucket, chunkId, offset, offset + limit - 1)).
    • Conforms strictly to ChunkMarker regex (^\d+_\d+_chunk[A-Za-z0-9_-]{16,}(~\d+~\d+)?$) to ensure bloom-filter GC never misclassifies chunks as unreferenced garbage.
  3. Compaction Pipeline & Crash Safety (BlobCompactionAlgorithm):

    • Initial Compaction (initialCompact):
      1. Candidate scanning windowed into batches of 1,000 blobs (DEFAULT_CANDIDATE_BATCH_SIZE = 1000) so candidate byte arrays are packed and freed window-by-window without buffering the generation's payloads in heap.
      2. Chunk assembled and saved to raw S3 storage.
      3. Metadata references updated in Cassandra (messageV3, messageIdToImapUid, messageIdTable).
      4. Original standalone blobs deleted from S3 only for candidates whose references updated successfully. If an update fails mid-batch, deletion is skipped for that candidate, guaranteeing zero dangling references.
    • GC Compaction (gcCompact):
      • Inspects existing chunks via footer-only ranged reads (metadata-only).
      • Orphan chunks (100% dead slots) deleted with 0 payload bytes read.
      • Chunks exceeding dead ratio rewritten; small adjacent chunks merged. Live slots streamed individually via ranged reads, bounding GC heap to $O(\text{maxSlotSize})$.
    • Self-Healing Repairer (CassandraBlobIdRepairer):
      • On missing slot / 404, falls back to original blob ID from blobReferenceMapping and restores Cassandra references. Also heals any transient inconsistency window across Cassandra tables.
  4. Configuration Guards & Scope:

    • Compaction automatically disables itself with an informative log when client-side AES encryption or whole-blob compression is active (AES cipher blocks HTTP range slicing).
    • Generation-scoped: triggered via DELETE /blobs?scope=compaction&generation=<gen>&family=<fam>.
  5. Memory Characteristics & Operational Ceiling:

    • Candidate payload heap: strictly bounded by $O(\min(N \times \text{avgBlobSize}, \text{chunkTargetSize}))$.
    • GC payload heap: strictly bounded by $O(\text{maxSlotSize})$ (~1MB).
    • Reference mapping ceiling: $O(\text{totalLiveGenerationReferences} \times \approx 200\text{ bytes})$ (~200MB heap for 1M live references; ~2GB heap for 10M). Documented in class javadoc; future follow-up can introduce partition-paged lookups.

Verification & Tests

  • server/blob/blob-compaction: 47 tests passed (100%), including allocation-bound proof test and task serialization.
  • server/blob/blob-storage-strategy: 78 tests passed (100%).
  • mailbox/cassandra (CassandraBlobId*IntegrationTest): 3/3 passed with real Cassandra 5.0.9 testcontainers.
  • server/blob/blob-s3: Ranged read and MinIO S3 end-to-end compaction integration tests passed.
  • server/container/guice/distributed: 43 tests passed (100%).
  • Checkstyle: 0 errors across all 7 modified modules.

@Arsnael

Arsnael commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Hi @HesandaLiyanage !

First of all thank you very much for trying to solve this.

However 6k lines changes in one commit is too much for code readability and reviewing properly. Can you cut this work down to smaller step commits?

Thank you :)

@Arsnael

Arsnael commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

And test errors are related to this PR I believe, please have a look too : https://ci-builds.apache.org/job/james/job/ApacheJames/job/PR-3193/1/testReport/

Document the context, architecture, chunk format, crash safety invariants,
and garbage collection strategy for autonomous S3 object compaction.
- Add BlobStoreDAO.readRange(bucket, blobId, offset, endOffset) returning
  a Mono<RangeByteSlice> with total object size and requested slice.
- Define default readRange fallback using readBytes.
- Add ChunkMarker utility for discriminating compaction chunk references.
- Add contract tests for readRange in ReadSaveBlobStoreDAOContract.
- Implement S3BlobStoreDAO.readRange using AWS S3 HTTP Range requests.
- Handle S3 416 RequestedRangeNotSatisfiable when requested offset exceeds
  object size or range is invalid, falling back or erroring appropriately.
- Add contract and MinIO integration tests for S3 range reads.
- Update BloomFilterGCAlgorithm to skip ChunkMarker chunk references during
  GC sweeps so that compacted chunks are never treated as unreferenced blobs.
- Add test coverage in BloomFilterGCAlgorithmContract.
…and compaction tasks

- Implement chunk packing (~100MB chunks) with per-slot Zstandard compression
  and 64KB suffix-indexed footers.
- Introduce ChunkId and BlobSlot representations for slot addressing.
- Implement ChunkedBlobStoreDAO for transparent single-request HTTP ranged reads
  and self-healing slot resolution via BlobIdRepairer.
- Implement BlobCompactionAlgorithm with windowed candidate payload streaming
  (DEFAULT_CANDIDATE_BATCH_SIZE = 1000) to bound heap usage during compaction.
- Implement BlobCompactionTask, DTOs, and full unit test suite.
…bAdmin API

- Implement CassandraBlobReferenceMappingSource, CassandraBlobIdUpdater,
  and CassandraBlobIdRepairer for updating and self-healing blob references.
- Wire BlobCompactionModule in Guice distributed server configuration.
- Expose DELETE /blobs?scope=compaction&generation=<gen>&family=<fam>
  in BlobRoutes to schedule BlobCompactionTask via WebAdmin.
- Add integration tests for Cassandra repair/update and WebAdmin compaction routes.
@HesandaLiyanage

Copy link
Copy Markdown
Author

Hi @Arsnael,

Thank you very much for the review and guidance!

1. CI Test Failures Fixed

The failures in CassandraBlobIdRepairerIntegrationTest were caused by an overzealous loop guard in ChunkedBlobStoreDAO.readChunkSlotWithRepair. When blobIdRepairer.repair() resolved a corrupted slot reference to its canonical counterpart slot reference, the method rejected it because ChunkId.isChunkRef(repairedBlobId) was true, re-throwing ObjectNotFoundException.

  • Updated ChunkedBlobStoreDAO to directly resolve repaired chunk slot references without re-entering the repair loop, preserving loop protection only when repairedBlobId.equals(originalBlobId).
  • Added unit test coverage (readChunkSlotShouldTriggerRepairWhenRepairedToAnotherSlotRef) in ChunkedBlobStoreDAOTest.
  • Verified both CassandraBlobIdRepairerIntegrationTest and CassandraBlobIdUpdaterIntegrationTest pass locally against Cassandra testcontainers.

2. Broken Down Into 6 Logical Step Commits

I have restructured the work from a single monolithic commit into 6 focused, incremental commits:

  1. Commit 1 (eadb532d): JAMES-4231 ADR: Architecture Decision Record for S3 Object Compaction
    • Documents the architectural context, chunk binary layout, Zstd per-slot compression, crash-safety guarantees, and Bloom filter GC invariants.
  2. Commit 2 (c3a6b84c): JAMES-4231 Blob API: Add range read contract and ChunkMarker support
    • Adds BlobStoreDAO.readRange API contract, RangeByteSlice, and ChunkMarker discriminator.
  3. Commit 3 (366bcd22): JAMES-4231 S3 BlobStore: Implement byte range reads for S3BlobStoreDAO
    • Implements S3 HTTP range requests with S3 416 (RequestedRangeNotSatisfiable) handling, contract tests, and MinIO integration tests.
  4. Commit 4 (fa6091bd): JAMES-4231 Storage Strategy: Exclude compacted chunks in BloomFilter GC
    • Updates BloomFilterGCAlgorithm to safely ignore compacted chunk objects during GC sweeps.
  5. Commit 5 (6413283e): JAMES-4231 Compaction Engine: Add chunk format, ChunkedBlobStoreDAO, and compaction tasks
    • Implements ChunkFormat, ChunkId, ChunkedBlobStoreDAO, windowed candidate streaming BlobCompactionAlgorithm, BlobCompactionTask, and unit test suite (48 tests).
  6. Commit 6 (71d52345): JAMES-4231 Cassandra & WebAdmin: Add metadata updater/repairer and WebAdmin API
    • Implements Cassandra reference mapping, updater, and self-healing repairer; wires BlobCompactionModule in Guice; exposes DELETE /blobs?scope=compaction in WebAdmin.

Please let me know if you would like any further adjustments!

@chibenwa chibenwa left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hello,

Thanks a lot for this initial contribution that is going overall in the good direction.

I did do a quick initial review of this work, and have already significant structural comments that I would like to see addressed.

Also I believe we would need to have more integration tests:

Given X, Y Z 200 mail population, compaction yield x, y, z object layout afterward, and we matches integrity constraints (no metadata -> 404). We would need that to be performed on ~5 different layouts, probably on top of memory blob store for decent testing time.

We would also need tests that account for failures updating references and ensure that we remain coherent.

I to not think I saw mailbox/cassandra ObjectStorageNotFoundException upon reading blobs from imapUidTable / messageIdTable - this detail is important for correctness as these table can create entry with past generation blobs (COPY / MOVE)

We are missing /docs documentation for this.

Wouldn't you mind addressing those feedbacks?

}
}

default Mono<RangeByteSlice> readRange(BucketName bucketName, BlobId blobId, long start, long end) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
default Mono<RangeByteSlice> readRange(BucketName bucketName, BlobId blobId, long start, long end) {
default Mono<Blob> readRange(BucketName bucketName, BlobId blobId, long start, long end) {

This also means chunk reading including:

  • blob metadata
  • crc validation

is a responsibility of the blob store.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. readRange now returns Publisher<Blob> preserving metadata and payload, allowing ChunkedBlobStoreDAO to propagate blob metadata through the blob store hierarchy.

Comment on lines +31 to +59
public byte[] decompress() throws ObjectStoreIOException {
if (originalSize < 0 || originalSize > Integer.MAX_VALUE) {
throw new ObjectStoreIOException("Invalid original size in chunk slot: " + originalSize);
}
byte[] decompressed;
if (originalSize == 0) {
decompressed = new byte[0];
} else {
try {
decompressed = Zstd.decompress(compressedContent, (int) originalSize);
} catch (Exception e) {
throw new ObjectStoreIOException("Failed to decompress slot content at " + contentStart, e);
}
}

if (decompressed.length != originalSize) {
throw new ObjectStoreIOException("Decompressed size " + decompressed.length + " does not match expected " + originalSize);
}

CRC32C crc = new CRC32C();
crc.update(decompressed);
int computedCrc = (int) crc.getValue();
if (computedCrc != crc32c) {
throw new ObjectStoreIOException(String.format("CRC mismatch for chunk slot at offset %d: expected %d, got %d",
contentStart, crc32c, computedCrc));
}

return decompressed;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shall not decompress here this is a responsibility of the ZstdBlobStoreDAO

We should instead cary over the metadata for the ZstdBlobStoreDAO to do its job.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Decompression has been removed from BlobSlot and ChunkedBlobStoreDAO, preserving the original content-encoding and content-original-size metadata in chunk slots so that ZstdBlobStoreDAO handles decompression transparently.

* reference lookups in future iterations.
* </p>
*/
private Mono<Map<BlobId, Set<String>>> loadReferenceMapping() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reference mapping is too large to be held in memory.
The proposal was to add a server/data-api interface for it and a Cassandra implementation - I presume.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Candidate references are now queried on-demand in windowed batches from BlobReferenceMappingSource (implemented by CassandraBlobReferenceMappingSource) rather than loading the entire mapping table into memory.

Comment on lines +136 to +139
public Mono<CompactionResult> compact(CompactionRequest request) {
return initialCompact(request)
.flatMap(initialResult -> gcCompact(request).map(initialResult::combine));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe we need to have two distinct tasks, one for initial compaction of a generation

And one other for compacting already compacted generation.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Compaction has been split into two distinct tasks: InitialBlobCompactionTask (for packing standalone blobs of a completed generation into chunks) and GCBlobCompactionTask (for reclaiming space from dead slots in already compacted chunks and merging small chunks). Both tasks have their own dedicated DTOs and WebAdmin scopes.

Comment on lines +151 to +152
return Flux.from(rawStore.listBlobs(request.bucketName()))
.filter(blobId -> matchesGenerationAndFamily(blobId.asString(), request.generation(), request.family()))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can limit this with a prefix ?

At list offer an option for prefix vs post filtering.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Added generation prefix pushdown when listing blobs using BlobStoreDAO.listBlobs(bucket, prefix) with fallback to post-filtering when prefix listing is unsupported.

.filter(blobId -> matchesGenerationAndFamily(blobId.asString(), request.generation(), request.family()))
.filter(blobId -> !ChunkId.isChunkRef(blobId))
.filter(mapping::containsKey)
.window(DEFAULT_CANDIDATE_BATCH_SIZE)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Windowing in terms of count-of-items vs windowing in terms of sum-of-item-size ?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Windowing is now based on cumulative byte size up to chunkTargetSize rather than pure item count.

LOGGER.warn("Failed reading candidate blob {}", blobId.asString(), error);
return Mono.empty();
}))
.filter(candidate -> candidate.payload.length < request.configuration().maxPackableSize())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Imo we need to do this filtering prior windowing

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Candidate blobs exceeding maxPackableSize are now filtered out prior to windowing and kept as standalone objects.

Comment on lines +208 to +210
if (candidates.isEmpty()) {
return Mono.just(CompactionResult.NONE);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Handle also candidate = 1 with no packing while we are at it.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Batches containing only 1 candidate blob are skipped without packing into chunks.

ChunkId chunkId = ChunkId.ofChunk(family, request.generation());

List<BlobSlotContent> slots = batch.stream()
.map(candidate -> BlobSlotContent.of(candidate.payload))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to account for metadata

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Slot headers and chunk format now serialize and restore blob metadata for each slot.

Comment on lines +255 to +266
.concatMap(i -> {
CandidateBlob candidate = batch.get(i);
SlotRange range = writeResult.slotRanges().get(i);
ChunkId slotRef = ChunkId.slotRef(chunkId, range.offset(), range.limit());
Collection<String> messageIds = mapping.getOrDefault(candidate.blobId, Set.of());
return blobIdUpdater.replaceReferences(candidate.blobId, slotRef, messageIds)
.thenReturn(Optional.of(candidate))
.onErrorResume(e -> {
LOGGER.error("Failed to update references for candidate blob {}, skipping deletion of original blob", candidate.blobId.asString(), e);
return Mono.just(Optional.empty());
});
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extract this in a dedicated method

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Extracted reference replacement and original blob deletion into dedicated method updateReferencesAndDeleteOriginalBlobs.

@Arsnael

Arsnael commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

hello,

The structure looks better yes thank you @HesandaLiyanage

https://ci-builds.apache.org/job/james/job/ApacheJames/job/PR-3193/2/

New test failures still related to this work. It seems some guice bindings are missing:

  1. [Guice/MissingImplementation]: No implementation for BlobIdUpdater was bound.

Requested by:
1 : BlobCompactionModule.blobCompactionAlgorithm(BlobCompactionModule.java:63)
_ for 4th parameter blobIdUpdater
at BlobCompactionModule.blobCompactionAlgorithm(BlobCompactionModule.java:63)
_ installed by: Modules$OverrideModule -> Modules$OverrideModule -> Modules$OverrideModule -> Modules$OverrideModule -> Modules$OverrideModule -> Modules$OverrideModule -> Modules$CombinedModule -> Modules$CombinedModule -> Modules$CombinedModule -> BlobCompactionModule

Learn more:
https://github.com/google/guice/wiki/MISSING_IMPLEMENTATION

  1. [Guice/MissingImplementation]: No implementation for BlobReferenceMappingSource was bound.

Requested by:
1 : BlobCompactionModule.blobCompactionAlgorithm(BlobCompactionModule.java:63)
_ for 3rd parameter mappingSource
at BlobCompactionModule.blobCompactionAlgorithm(BlobCompactionModule.java:63)
_ installed by: Modules$OverrideModule -> Modules$OverrideModule -> Modules$OverrideModule -> Modules$OverrideModule -> Modules$OverrideModule -> Modules$OverrideModule -> Modules$CombinedModule -> Modules$CombinedModule -> Modules$CombinedModule -> BlobCompactionModule

Learn more:
https://github.com/google/guice/wiki/MISSING_IMPLEMENTATION

2 errors

- Update BlobStoreDAO.readRange to return Mono<Blob> instead of RangeByteSlice.
- Store totalObjectSize in BlobMetadata (TOTAL_OBJECT_SIZE) and provide
  BlobStoreDAO.totalObjectSize(Blob) utility method.
- Update S3BlobStoreDAO and ChunkedBlobStoreDAO to return Mono<Blob> with metadata.
- Update contract and unit test suites to use Blob.
@HesandaLiyanage

Copy link
Copy Markdown
Author

Hi @chibenwa and @Arsnael,

All requested changes and feedbacks have been addressed and pushed as individual atomic commits:

1. Structural & Algorithmic Improvements

  • BlobStoreDAO API: readRange now returns Publisher<Blob> / Mono<Blob>, allowing ChunkedBlobStoreDAO to propagate blob metadata and slot CRC checks through the blob store hierarchy.
  • Zstd Delegation: Removed decompression from BlobSlot / ChunkedBlobStoreDAO; chunk slots preserve content-encoding and content-original-size so ZstdBlobStoreDAO handles decompression transparently.
  • Metadata Preservation: Chunk format and slot serialization now preserve and restore all blob metadata in chunk slots.
  • Prefix Pushdown: Implemented generation prefix pushdown when listing candidate blobs with BlobStoreDAO.listBlobs(bucket, prefix).
  • Pre-Windowing Size Filtering: Candidate blobs exceeding maxPackableSize are filtered out prior to windowing and kept as standalone objects.
  • Size-Based Windowing: Candidate windowing is now driven by cumulative byte size up to chunkTargetSize rather than pure item count.
  • Single Candidate Handling: Batches with a single candidate are skipped without packing into chunks.
  • Dedicated Deletion Method: Extracted reference replacement and original blob deletion into updateReferencesAndDeleteOriginalBlobs.
  • Streaming References: Replaced in-memory reference loading with on-demand windowed queries against BlobReferenceMappingSource to avoid heap exhaustion.
  • Two Distinct Tasks: Split compaction into InitialBlobCompactionTask (for standalone blobs of a completed generation) and GCBlobCompactionTask (for reclaiming space from dead slots and merging small chunks), each with its own DTO and WebAdmin scope.

2. Guice Dependencies in Non-Cassandra Environments

  • Fixed Guice wiring in BlobCompactionModule to make BlobIdUpdater and BlobReferenceMappingSource optional when non-Cassandra backends (e.g. Postgres, Memory) are used, resolving the CI failure noted by @Arsnael.

3. Cassandra Mailbox Mappers Fallback

  • Added onErrorResume(ObjectNotFoundException.class, ...) in both CassandraMessageMapper and CassandraMessageIdMapper on the FetchType.HEADERS fast path, falling back to messageDAOV3 which retains canonical compacted chunk slot references.

4. Coherence Under Failure Integration Tests

  • Added integration tests in BlobCompactionAlgorithmTest verifying store coherence when reference updates fail during initial compaction and GC recompaction (original standalone blobs and chunks are preserved, readable, and consistent).

5. Layout Matrix Integration Tests (200 Mails)

  • Added BlobCompactionLayoutIntegrationTest on MemoryBlobStoreDAO covering 5 distinct layout scenarios (200 mails each):
    1. Uniform small population (all active): yields single chunk, standalone deleted, integrity constraints verified.
    2. Partially unreferenced population (50% unreferenced): compacts only active candidates, preserves uncompacted.
    3. Mixed sizes with oversized blobs: preserves oversized standalone blobs, compacts remaining.
    4. Multi-generation interleaved population: compacts only target generation blobs.
    5. Two-stage compaction with subsequent deletion and GC recompaction: purges dead slots, yields new compact chunk, and ensures non-existent metadata returns 404.

6. Documentation

  • Added documentation in docs/ covering:
    • Architecture: ChunkedBlobStoreDAO and S3 Object Compaction concepts (docs/modules/servers/partials/architecture/blobstore.adoc).
    • WebAdmin: endpoints for initial compaction, GC recompaction, and full compaction (docs/modules/servers/partials/operate/webadmin.adoc).

All changes are completed and tested. Thank you both for the detailed review!

@chibenwa

chibenwa commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

Thanks for all the fixes.

That's a major changeset. I really woupd support (out of the CI) Property Based Testing for this so that we can develop confidence on it. ( https://www.scalatest.org/user_guide/property_based_testing for instance ) it should be able to generate large set of object within a generation, perform compact and delete in random manner under various profiles (delete heavy or not - configurable and tested accross a wide range of value, possibly randomly set between each compacts) and ensure along several runs that 1- count of object decrease in a predicable fashion and 2- no dead references are created.

The review is massive, and I do not want to delegate this to an AI, I'll try to cover a review of this by the end of the week.

Cheers,

Benoit

@HesandaLiyanage

Copy link
Copy Markdown
Author

Thanks for all the fixes.

That's a major changeset. I really woupd support (out of the CI) Property Based Testing for this so that we can develop confidence on it. ( https://www.scalatest.org/user_guide/property_based_testing for instance ) it should be able to generate large set of object within a generation, perform compact and delete in random manner under various profiles (delete heavy or not - configurable and tested accross a wide range of value, possibly randomly set between each compacts) and ensure along several runs that 1- count of object decrease in a predicable fashion and 2- no dead references are created.

The review is massive, and I do not want to delegate this to an AI, I'll try to cover a review of this by the end of the week.

Cheers,

Benoit

Thanks so much, really appreciate you taking the time on this.

Yeah let's do the property based testing. that's way better than me just eyeballing it after a few manual runs. i'll start working around with that on my side in the meantime.

looking forward to your feedback by end of week, no rush though given how big this changeset is.

@chibenwa

Copy link
Copy Markdown
Contributor

@HesandaLiyanage I see you being a student in your github profile.

Would you be interested in a Google Summer of Code with the ASF James project ? If this is meaningful for you we could keep going on the S3 compaction stuff and plan eventually S3 GC / compaction implemented as Apache Spark jobs (for instance). I think the timing is not bad for 2027, though I recognize I do not have the precise schedule in mind.

@HesandaLiyanage

Copy link
Copy Markdown
Author

@HesandaLiyanage I see you being a student in your github profile.

Would you be interested in a Google Summer of Code with the ASF James project ? If this is meaningful for you we could keep going on the S3 compaction stuff and plan eventually S3 GC / compaction implemented as Apache Spark jobs (for instance). I think the timing is not bad for 2027, though I recognize I do not have the precise schedule in mind.

Yes, absolutely! I would love to participate in Google Summer of Code with James project.

Taking on S3 compaction and exploring large-scale S3 GC/compaction with Apache Spark sounds like a fantastic project, and I'd be honored to work on it with you and the community.

@chibenwa

Copy link
Copy Markdown
Contributor

Taking on S3 compaction and exploring large-scale S3 GC/compaction with Apache Spark sounds like a fantastic project, and I'd be honored to work on it with you and the community.

I'm glad to hear that.
Time schedule is not for now - Jan / february to have mentoring organisation which asf is generally part off.
Mid-march: opens for project.
Let's remember this schedule and get S3 compaction working first.


This discussion cannot stay solely on github. I propose you to introduce yourself on the DEV mailing list, quote your objectives, link this PR, and potentially say that you would eventually be interested by a GSOC proposed by Benoit to continue the work started on S3 compaction task but make it more scalable with Spark.

@HesandaLiyanage

HesandaLiyanage commented Sep 22, 2026

Copy link
Copy Markdown
Author

Taking on S3 compaction and exploring large-scale S3 GC/compaction with Apache Spark sounds like a fantastic project, and I'd be honored to work on it with you and the community.

I'm glad to hear that. Time schedule is not for now - Jan / february to have mentoring organisation which asf is generally part off. Mid-march: opens for project. Let's remember this schedule and get S3 compaction working first.

This discussion cannot stay solely on github. I propose you to introduce yourself on the DEV mailing list, quote your objectives, link this PR, and potentially say that you would eventually be interested by a GSOC proposed by Benoit to continue the work started on S3 compaction task but make it more scalable with Spark.

Sounds good! I'll introduce myself on the dev mailing list and keep working on the S3 compaction for now. Thank you so much for the opportunity .

@Arsnael

Arsnael commented Sep 23, 2026

Copy link
Copy Markdown
Contributor

https://ci-builds.apache.org/job/james/job/ApacheJames/job/PR-3193/3/testReport/

Related errors on CassandraBlobIdRepairerIntegrationTest. Please help review and fix @HesandaLiyanage :)

@prosgarz35

Copy link
Copy Markdown

Which S3 should be used now? SeaweedFS? Silo? RustFS? Garage? ofc i dont want get my data corrupted.

@Arsnael

Arsnael commented Sep 23, 2026

Copy link
Copy Markdown
Contributor

Which S3 should be used now? SeaweedFS? Silo? RustFS? Garage? ofc i dont want get my data corrupted.

That's a good question. We had a try at triaging alternatives to MinIO earlier this year, as you can see here: https://issues.apache.org/jira/browse/JAMES-4167

It wasn't very conclusive back then unfortunately. But maybe now it's worth taking an other look if you want a nice little extra challenge ;) Specially now that MinIO removed all their public images it seems...

I cans ee RustFS for example now released in a v1.0.0 not long ago. Maybe that means they are finally on a more stable and mature version of their product. It would be worth getting an other shot at it IMO

@prosgarz35

prosgarz35 commented Sep 23, 2026

Copy link
Copy Markdown

Which S3 should be used now? SeaweedFS? Silo? RustFS? Garage? ofc i dont want get my data corrupted.

That's a good question. We had a try at triaging alternatives to MinIO earlier this year, as you can see here: https://issues.apache.org/jira/browse/JAMES-4167

It wasn't very conclusive back then unfortunately. But maybe now it's worth taking an other look if you want a nice little extra challenge ;) Specially now that MinIO removed all their public images it seems...

I cans ee RustFS for example now released in a v1.0.0 not long ago. Maybe that means they are finally on a more stable and mature version of their product. It would be worth getting an other shot at it IMO

i tested with James RustFS and for now its bad, about 60-70% compability (used warp to test) also not even close to Silo (MinIO fork 99-100%) and SeaweedFS (which is about 85% combability) perfomance (but yeah much lower ram usage)

I tesing RustFS 1.0.0 version, both original and from sources. But you right... there NO real options for now, for standalone single server setup (postgres-app) i think need focus on RustFS combability, for clusters (distribution-app) CephFS i think? Because SeaweedFS dont have full claster support in opensources version, Garage much worse than Seaweed in any way (compability/perfomance, latency) and not working on Windows (but i DO custom version and make it WORK on windows from sourses and even THIS show better than RustFS...)

So what you think?
RustFS focus for postgres-app
CephFS focus for distribution-app
Seems good?

@prosgarz35

Copy link
Copy Markdown

Also why only S3 or PostgreSQL? Why not filesystem like in Stalwart and Dovecot?

@HesandaLiyanage

Copy link
Copy Markdown
Author

https://ci-builds.apache.org/job/james/job/ApacheJames/job/PR-3193/3/testReport/

Related errors on CassandraBlobIdRepairerIntegrationTest. Please help review and fix @HesandaLiyanage :)

Done!. ChunkedBlobStoreDAO returns slot content tagged with ContentEncoding.ZSTD (decompression is delegated to ZstdBlobStoreDAO in production). Updated CassandraBlobIdRepairerIntegrationTest to decompress the slot payload before asserting content equality. Both tests pass locally and checkstyle is clean.

@prosgarz35

Copy link
Copy Markdown

https://ci-builds.apache.org/job/james/job/ApacheJames/job/PR-3193/3/testReport/
Related errors on CassandraBlobIdRepairerIntegrationTest. Please help review and fix @HesandaLiyanage :)

Done!. ChunkedBlobStoreDAO returns slot content tagged with ContentEncoding.ZSTD (decompression is delegated to ZstdBlobStoreDAO in production). Updated CassandraBlobIdRepairerIntegrationTest to decompress the slot payload before asserting content equality. Both tests pass locally and checkstyle is clean.

so now not James itself makes compression - now it doings S3 storage?

@HesandaLiyanage

HesandaLiyanage commented Sep 23, 2026

Copy link
Copy Markdown
Author

so now not James itself makes compression - now it doings S3 storage?

No, James itself still does all the compression. S3 is just an object store that receives and stores the bytes James sends it.

ZstdBlobStoreDAO (James) ➔ ChunkedBlobStoreDAO (James) ➔ S3BlobStoreDAO (S3)

@prosgarz35

Copy link
Copy Markdown

https://ci-builds.apache.org/job/james/job/ApacheJames/job/PR-3193/3/testReport/
Related errors on CassandraBlobIdRepairerIntegrationTest. Please help review and fix @HesandaLiyanage :)

Done!. ChunkedBlobStoreDAO returns slot content tagged with ContentEncoding.ZSTD (decompression is delegated to ZstdBlobStoreDAO in production). Updated CassandraBlobIdRepairerIntegrationTest to decompress the slot payload before asserting content equality. Both tests pass locally and checkstyle is clean.

so now not James itself makes compression - now it doings S3 storage?

No, James itself still does all the compression. S3 is just an object store that receives and stores the bytes James sends it.

ZstdBlobStoreDAO (James) ➔ ChunkedBlobStoreDAO (James) ➔ S3BlobStoreDAO (S3)

well i looking forward to move this task to RustFS S3 for example which can compress by myself with zstd/lz4, just as for me the more mail server dont do others jobs the better it doing main job - mail exchanging.

@HesandaLiyanage

HesandaLiyanage commented Sep 23, 2026

Copy link
Copy Markdown
Author

well i looking forward to move this task to RustFS S3 for example which can compress by myself with zstd/lz4, just as for me the more mail server dont do others jobs the better it doing main job - mail exchanging.

yeah fair enough, and compression in james isn't forced either way, if the underlying s3 engine you're using already handles it at the storage layer, ceph or rustfs, you can just turn it off on the james side with blobstore.compression.enabled=false and let the backend do its thing.

but for plain cloud s3 like aws, wasabi, backblaze b2 etc, none of those do any packing or compression on their own, so having james handle it out of the box still matters a lot there, otherwise your put costs and storage footprint climb pretty fast.

@prosgarz35

Copy link
Copy Markdown

https://ci-builds.apache.org/job/james/job/ApacheJames/job/PR-3193/3/testReport/
Related errors on CassandraBlobIdRepairerIntegrationTest. Please help review and fix @HesandaLiyanage :)

Done!. ChunkedBlobStoreDAO returns slot content tagged with ContentEncoding.ZSTD (decompression is delegated to ZstdBlobStoreDAO in production). Updated CassandraBlobIdRepairerIntegrationTest to decompress the slot payload before asserting content equality. Both tests pass locally and checkstyle is clean.

so now not James itself makes compression - now it doings S3 storage?

No, James itself still does all the compression. S3 is just an object store that receives and stores the bytes James sends it.
ZstdBlobStoreDAO (James) ➔ ChunkedBlobStoreDAO (James) ➔ S3BlobStoreDAO (S3)

well i looking forward to move this task to RustFS S3 for example which can compress by myself with zstd/lz4, just as for me the more mail server dont do others jobs the better it doing main job - mail exchanging.

yeah fair enough, and compression in james isn't forced either way, if the underlying s3 engine you're using already handles it at the storage layer, ceph or rustfs, you can just turn it off on the james side with blobstore.compression.enabled=false and let the backend do its thing.

but for plain cloud s3 like aws, wasabi, backblaze b2 etc, none of those do any packing or compression on their own, so having james handle it out of the box still matters a lot there, otherwise your put costs and storage footprint climb pretty fast.

yeah customizations always wins! so yeah for cloud its sure userful, also only zstd for now? i think in some cases lz4 needed (lower latency). And deduplication methods its like in ZFS? Just Windows Post-processing dedup wins for now.

@HesandaLiyanage

Copy link
Copy Markdown
Author

ZFS?

Good questions, though i think this is kinda drifting outside the scope of this specific PR, lz4 vs zstd tradeoffs and dedup approaches would probably get a lot more visibility on the dev mailing list or as a github discussion rather than buried in a PR thread.

Really appreciate you thinking through this stuff though, i think its better if we keep this thread focused on the compaction review and the CI fixes for now

@chibenwa

Copy link
Copy Markdown
Contributor

Also why only S3 or PostgreSQL? Why not filesystem like in Stalwart and Dovecot?

We used to have a poorly performing file maildir backend.

We deprecated it: bad performance, directory traversal, no maintainer, no known deployment.

No, James itself still does all the compression. S3 is just an object store that receives and stores the bytes James sends it.

If options exist to tell s3 to compress and do the job that's nice. I am not aware TBH and we could add support for this in S3BlobStoreDAO

ZstdBlobStoreDAO should work out of the box atop file implem. If this is not the case a separated PR is welcomed.

lz4

Bad choice, it won't compress base64 as it doesn't do entropy coding. Most benefit of compression is the base64 overhead on large attachment, which is capped at ~33%.

RustFS S3 for example which can compress by myself with zstd/lz4

If it is standard (ie amazon s3 supports this) then it is welcomed in S3BlobStoreDAO

yeah fair enough, and compression in james isn't forced either way, if the underlying s3 engine you're using already handles it at the storage layer, ceph or rustfs, you can just turn it off on the james side with blobstore.compression.enabled=false and let the backend do its thing.

Yes precisely.

Also bear in mind that one can implement out-of-band-compression, and solely compress old 3+ month old mail as a tiering strategy... I do not know if RustFS for instance is able to automate that with a S3 policy.

but for plain cloud s3 like aws, wasabi, backblaze b2 etc

Event worse if one operate a on prem S3 compatible system like Ceph Rados Gateway.

also only zstd for now ?

Lz4 is not fit cf earlier entropy coding point.

Zstd had decent java port.

More niche algorithm would need to have good perf java implems.

I confess I do not have in mind viable java alternative, but with hashing I did bench SHA-256 vs BLAKE3 and fornow JVM implenm of BLAKE3 is worse than SHA-256. I'd be careful with exotic implem and have their viability backed with a JMH micro benchmark.

And deduplication methods its like in ZFS?

You are free to drop james dedup and experiment with FS level dedup.

My take on it is that FS do not have all the infos James have and would be doing more work to achieve partial results. I'm expecting it not to deduplicate as well as James can do but I do not have solid comparison to answer you and the question indeed is interesting.

Good questions, though i think this is kinda drifting outside the scope of this specific PR

Completly, but this is a nice discussion!

Cheers,

Benoit

@chibenwa

Copy link
Copy Markdown
Contributor

would probably get a lot more visibility on the dev mailing list

I confirm this is the right way to go!

@chibenwa

Copy link
Copy Markdown
Contributor

So what you think?
RustFS focus for postgres-app
CephFS focus for distribution-app
Seems good?

While I 100% subscribe to the point as a James integrator,
The ASF james project users should be able to make their own choice on this complicated topic.
And we can discuss this important matter and exchange opinions on eg the user mailing list.

@HesandaLiyanage

Copy link
Copy Markdown
Author

thanks for laying all that out, @chibenwa , appreciated.

for file/postgres, since ZstdBlobStoreDAO is just a decorator, getting it properly wired and tested on top of FileBlobStoreDAO and PostgresBlobStoreDAO seems like its own clean follow up PR too. I can work on that

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants