Skip to content

feat(iceberg): single JVM per process instead of per stream-chunk#962

Open
vikaxsh wants to merge 24 commits into
stagingfrom
feat/unified-jvm
Open

feat(iceberg): single JVM per process instead of per stream-chunk#962
vikaxsh wants to merge 24 commits into
stagingfrom
feat/unified-jvm

Conversation

@vikaxsh

@vikaxsh vikaxsh commented May 26, 2026

Copy link
Copy Markdown
Collaborator

Description

This PR refactors the Iceberg writer architecture to use a single shared JVM per OLake CLI invocation instead of creating one JVM per writer thread/chunk.

Previously, every Iceberg writer initialization spawned a dedicated JVM-backed Iceberg client and gRPC server, resulting in significant memory overhead under concurrent backfill and CDC workloads. Large syncs could create many JVM processes, leading to excessive memory consumption and potential OOM issues.

Changes

  • Introduced a shared JVM architecture where all streams and chunks communicate with a single JVM instance.

  • Added ThreadSession-based isolation to maintain per-stream/per-chunk state within the shared JVM.

  • Moved stream-specific configuration from JVM startup arguments to gRPC request metadata:

    • Namespace
    • Upsert mode
    • Identifier field creation
    • Iceberg partition transforms
  • Added StreamMetaCtx on the Go side to propagate stream-specific metadata with every request.

  • Added ThreadSession management in Java to maintain isolated:

    • Iceberg table handles
    • Table operators
    • Writers
    • Commit state
  • Added CLOSE_SESSION RPC operation for explicit session cleanup and resource release.

  • Retained catalog initialization and other truly global resources at the JVM level.

Benefits

  • JVM heap is allocated only once per OLake run.
  • Significantly reduces memory consumption during concurrent syncs.
  • Eliminates excessive JVM process creation.
  • Preserves stream-level isolation through session-based state management.
  • Improves scalability for parallel backfill and CDC workloads.

Fixes # (issue)

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • This change requires a documentation update

How Has This Been Tested?

  • Validated concurrent backfill execution using multiple chunks sharing a single JVM instance.
  • Validated CDC streams operating concurrently through the shared JVM.
  • Verified session creation and cleanup via CLOSE_SESSION.
  • Verified successful table creation, schema evolution, writes, and commits across multiple sessions.
  • Verified memory utilization remains stable compared to the previous multi-JVM architecture.

Screenshots or Recordings

N/A

Documentation

  • Documentation Link: [link to README, olake.io/docs, or olake-docs]
  • N/A (bug fix, refactor, or test changes only)

Related PR's (If Any):

N/A

@vikaxsh vikaxsh marked this pull request as ready for review May 26, 2026 10:49
@vikaxsh vikaxsh marked this pull request as draft May 26, 2026 10:52
@vikaxsh vikaxsh requested a deployment to integration_tests June 2, 2026 05:49 — with GitHub Actions Waiting
@vikaxsh vikaxsh requested a deployment to integration_tests June 2, 2026 05:49 — with GitHub Actions Waiting
@vikaxsh vikaxsh marked this pull request as ready for review June 2, 2026 05:50
@vikaxsh vikaxsh requested a deployment to integration_tests June 2, 2026 05:50 — with GitHub Actions Waiting
@vikaxsh vikaxsh requested a deployment to integration_tests June 2, 2026 05:50 — with GitHub Actions Waiting
@vikaxsh vikaxsh temporarily deployed to integration_tests June 2, 2026 05:57 — with GitHub Actions Inactive
@vikaxsh vikaxsh temporarily deployed to integration_tests June 2, 2026 05:57 — with GitHub Actions Inactive
@vikaxsh vikaxsh requested a deployment to integration_tests June 2, 2026 08:01 — with GitHub Actions Waiting
@vikaxsh vikaxsh requested a deployment to integration_tests June 2, 2026 08:01 — with GitHub Actions Waiting
@vikaxsh vikaxsh requested a deployment to integration_tests June 3, 2026 05:46 — with GitHub Actions Waiting
@vikaxsh vikaxsh requested a deployment to integration_tests June 3, 2026 05:46 — with GitHub Actions Waiting
@vikaxsh vikaxsh requested a deployment to integration_tests June 3, 2026 06:58 — with GitHub Actions Waiting
@vikaxsh vikaxsh requested a deployment to integration_tests June 3, 2026 06:58 — with GitHub Actions Waiting
@vikaxsh vikaxsh requested a deployment to integration_tests June 3, 2026 07:40 — with GitHub Actions Waiting
@vikaxsh vikaxsh requested a deployment to integration_tests June 3, 2026 07:40 — with GitHub Actions Waiting
@vikaxsh vikaxsh requested a deployment to integration_tests June 4, 2026 18:02 — with GitHub Actions Waiting
@vikaxsh vikaxsh requested a deployment to integration_tests June 4, 2026 18:02 — with GitHub Actions Waiting
@hash-data hash-data requested a deployment to integration_tests June 5, 2026 06:16 — with GitHub Actions Waiting
@hash-data hash-data requested a deployment to integration_tests June 5, 2026 06:16 — with GitHub Actions Waiting

@hash-data hash-data left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

can we think of better RPC request structure? as well as think of more scenerios where things can break?

Comment thread destination/writers.go Outdated
// Shutdown invokes destination-level cleanup if the registered writer
// implements Shutdownable. No-op for destinations without long-lived
// resources (parquet).
func Shutdown(ctx context.Context, config *types.WriterConfig) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

why shutdown? it can be cleanup and it is part of writer pool

Comment thread destination/iceberg/java_client.go Outdated
// The catalog/storage portion of `config` is what drives the JVM CLI; later
// callers that pass a different config still receive the already-running JVM.
// This is intentional: in a single OLake sync the destination config is fixed.
func acquireServer(config *Config) (*serverInstance, error) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

think of function name right now it is not readable?
Example:
acquireServer is not what this function does, it create thread in java side so initJavaThread could be a good name

Comment thread destination/iceberg/java_client.go Outdated
Comment on lines 140 to 142
sharedServerMu.Lock()
defer sharedServerMu.Unlock()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

why lock?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Inside Backfill, for a single stream, chunkProcessor runs concurrently for every chunk of the same stream
so not 2 chunk try to create jvm at same time

Comment thread destination/iceberg/java_client.go Outdated
for attempt := 0; attempt < maxAttempts; attempt++ {
// get available port
port, err = FindAvailablePort(threadID, nextStartPort)
port, err := FindAvailablePort(serverID, nextStartPort)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

why we need port here?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

if a previous failed sync left behind an orphaned JVM that was still holding the port, this can now be handled easily. kept the existing behavior for now to avoid making significant changes

// TODO: research the following flags in arrow writer and legacy writer
// need to do some research on the following flags
var serverCmd *exec.Cmd
if os.Getenv("OLAKE_DEBUG_MODE") != "" {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

how this will work if server already started?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

in each sync ,server starts once, if provided before sync start, it will work

}

public static Table createIcebergTable(Catalog icebergCatalog, TableIdentifier tableIdentifier, Schema schema) {
ensureNamespace(icebergCatalog, tableIdentifier);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

do we have exception handling for table already exist?

BooleanSupplier cancelled) {
// Session already torn down before we started — don't create a writer that
// nothing will commit/close.
if (cancelled.getAsBoolean()) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

will think a better way of it, commenting for reminder

Comment on lines +103 to +106
// Only raise the flag — never touch the writer here. This runs on
// a different gRPC thread than the in-flight write, and closing it
// concurrently with writer.write() is what corrupts Parquet. The
// write loop sees `cancelled` and closes its own writer; we don't wait.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

memory things need to be removed as there will be a lot of memory hogged by the writer even after close


public Table loadIcebergTable(TableIdentifier tableId, Schema schema) {
private Table loadOrCreateTable(TableIdentifier tableId, Schema schema, List<Map<String, String>> partitionTransforms) {
return IcebergUtil.loadIcebergTable(icebergCatalog, tableId).orElseGet(() -> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

we are using iceberg utill directly need to check consequences of it

import io.grpc.stub.StreamObserver;
import jakarta.enterprise.context.Dependent;

/**

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

not reviewing it as of now, it is going to be similar as olakeRowingestor. A review at end is fine of it

@vikaxsh vikaxsh temporarily deployed to integration_tests June 11, 2026 13:59 — with GitHub Actions Inactive
Comment thread destination/iceberg/java_client.go Outdated
// receive the already-running JVM. This is intentional: in a single OLake sync
// the destination config is fixed.
func initializeServer(config *Config) (*serverInstance, error) {
startOnce.Do(func() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

why we need to use DO?

Comment thread destination/writers.go Outdated
// Initialize starts destination-level process resources once, up front, if the
// registered writer implements Initializable. No-op for destinations without
// long-lived resources (parquet). Pair it with a deferred Shutdown.
func Initialize(ctx context.Context, config *types.WriterConfig) error {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

can we do this in writer pool where we dont need to unmarshal config again, these we can do in this way
-> writer pool start the server
-> the object of server will be global in iceberg and can be used by all instances that get created

Comment thread destination/writers.go Outdated
// implements Shutdownable. No-op for destinations without long-lived
// resources (parquet).
func Shutdown(ctx context.Context, config *types.WriterConfig) {
if config == nil {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

shutdown would be writer pool cleanup function where it cleans up the instance of server

Comment thread destination/writers.go Outdated
if err := s.Shutdown(ctx); err != nil {
logger.Warnf("destination.Shutdown: %s", err)
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

not related to above change:

But can we somehow unmarhsal config only once, right now we are unmarshaling it every time while creating new writer

@vikaxsh vikaxsh requested a deployment to integration_tests June 17, 2026 09:49 — with GitHub Actions Waiting
@vikaxsh vikaxsh requested a deployment to integration_tests June 17, 2026 09:49 — with GitHub Actions Waiting
@vikaxsh vikaxsh requested a deployment to integration_tests June 17, 2026 09:54 — with GitHub Actions Waiting
@vikaxsh vikaxsh requested a deployment to integration_tests June 17, 2026 09:54 — with GitHub Actions Waiting
@vikaxsh vikaxsh requested a deployment to integration_tests June 17, 2026 10:04 — with GitHub Actions Waiting
@vikaxsh vikaxsh requested a deployment to integration_tests June 17, 2026 10:04 — with GitHub Actions Waiting
@vikaxsh vikaxsh requested a deployment to integration_tests June 17, 2026 10:36 — with GitHub Actions Waiting
@vikaxsh vikaxsh requested a deployment to integration_tests June 17, 2026 10:36 — with GitHub Actions Waiting
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.

2 participants