Skip to content

S3json-take2 - #354

Merged
alex-thc merged 18 commits into
mainfrom
s3json2
Dec 8, 2025
Merged

S3json-take2#354
alex-thc merged 18 commits into
mainfrom
s3json2

Conversation

@alex-thc

@alex-thc alex-thc commented Dec 6, 2025

Copy link
Copy Markdown
Contributor

Summary by CodeRabbit

  • New Features

    • Added an S3 connector with namespace-aware planning, per-namespace metadata, listing/reading, and writing for JSON/BSON.
    • Added a batched S3 writer with concurrent uploads, configurable file-size and memory limits, backpressure, eviction, and automatic metadata tracking.
  • Chores

    • Upgraded AWS SDK and S3 service dependency.
  • Bug Fixes

    • Removed duplicate CLI flag declaration for S3 configuration.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai

coderabbitai Bot commented Dec 6, 2025

Copy link
Copy Markdown

Walkthrough

Adds a new S3 connector and concurrent batch writer: parses s3:// URIs, configures an AWS S3 client, implements planning/list/read/write flows with per-namespace metadata and optimistic-lock handling, provides a buffered concurrent BatchProcessor, CLI flags, and updates AWS SDK dependencies.

Changes

Cohort / File(s) Summary
S3 Connector Core Implementation
connectors/s3/connector.go
New ConnectorSettings and NewConn; validates/normalizes s3:// URIs, builds AWS config and S3 client, initializes BatchProcessor; exposes ErrBucketRequired, ErrRegionRequired, ErrUnsupportedType; implements GetInfo, GeneratePlan, GetNamespaceMetadata, ListData, WriteData, plus stubs for streaming/update methods, metadata caching, and JSON↔BSON conversion helpers.
S3 Batch Writer
connectors/s3/s3writer.go
New Config and BatchProcessor with per-namespace buffers, global memory accounting, backpressure/eviction, async PutObject uploads with timeout, metadata read/write/update (.metadata.json) including optimistic-lock/retry handling; adds NewBatchProcessor, Add, Close, async flush and eviction logic.
Metadata helper
connectors/s3/metadata.go
Adds MetadataKey(prefix, namespace string) string to compute normalized per-namespace metadata S3 key (trims, replaces dots with slashes, defaults namespace to default, joins prefix).
S3 utilities
connectors/s3/util.go
Adds IsS3OptimisticLockFailedError(apiError smithy.APIError) bool and ErrS3OptimisticLockFailed error type to detect/represent S3 optimistic-lock (ETag/Precondition) failures.
CLI integration
internal/app/options/connectorflags.go
Registers "S3" connector with s3:// detection in GetRegisteredConnectors(); adds S3Flags(settings *s3connector.ConnectorSettings) []cli.Flag exposing flags (PrettyJSON, region, prefix, output-format, profile, endpoint, credentials, path-style, max-file-size, max-total-memory). Note: a duplicate S3Flags declaration exists.
Go module updates
go.mod
Upgrades AWS SDK v2 core and adds S3 service dependency plus related indirect/internal dependencies (s3, eventstream, s3shared, presigned-url, smithy-go, etc.) to align with the AWS SDK surface.

Sequence Diagram(s)

sequenceDiagram
    participant App as Application
    participant Conn as S3 Connector
    participant BP as BatchProcessor
    participant S3 as AWS S3

    App->>Conn: WriteData(namespace, documents)
    activate Conn
    Conn->>Conn: Validate & convert docs (JSON/BSON)
    Conn->>BP: Add(namespace, [][]byte)
    activate BP
    BP->>BP: Check memory & append to buffer
    alt Memory pressure
        BP->>BP: Evict largest buffer / wait for space
    end
    alt Buffer >= MaxFileSize or Close triggered
        BP->>S3: PutObject (async upload)
        activate S3
        S3-->>BP: Upload result (ETag / error)
        deactivate S3
        BP->>S3: Get/Update `.metadata.json` (optimistic-lock / retry)
    end
    BP-->>Conn: Add result / error
    deactivate BP
    Conn-->>App: Return success / error
    deactivate Conn
Loading
sequenceDiagram
    participant App as Application
    participant Conn as S3 Connector
    participant S3 as AWS S3

    App->>Conn: GeneratePlan(requested namespaces)
    activate Conn
    Conn->>S3: ListObjectsV2 (prefix) [paged]
    activate S3
    S3-->>Conn: Object pages
    deactivate S3
    Conn->>Conn: Filter objects by namespaces, build metadata keys
    loop per-namespace
        Conn->>S3: GetObject(.metadata.json)
        alt metadata found
            S3-->>Conn: metadata JSON
            Conn->>Conn: Sum counts, cache
        else not found
            S3-->>Conn: NotFound
            Conn->>Conn: Use zero counts
        end
    end
    Conn->>App: Emit partitions (object key cursor + estimated counts)
    deactivate Conn
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

  • Focus areas:
    • connectors/s3/s3writer.go: concurrency (mutex/cond), eviction/backpressure, memory accounting, async uploads, Close/shutdown behavior, and retry semantics for metadata updates.
    • connectors/s3/connector.go: data conversion (JSON/BSON), error propagation, GeneratePlan pagination and namespace filtering, metadata caching correctness.
    • connectors/s3/util.go and metadata handling: optimistic-lock detection and correct mapping to retries/errors.
    • internal/app/options/connectorflags.go: duplicate S3Flags declaration needs deduplication.
    • go.mod: ensure dependency upgrade is consistent with code usage.

Poem

🐇 I tunneled bytes into a bucket bright,

Carrots of JSON piled through the night,
I batch and hop, then flush with cheer,
Metadata maps my harvest clear,
A rabbit syncing, light and light.

Pre-merge checks and finishing touches

❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
Title check ❓ Inconclusive The title 'S3json-take2' is vague and does not clearly convey the main purpose of the changeset, which is to add a full S3 sink connector implementation. Revise the title to clearly describe the primary change, such as 'Add S3 sink connector implementation' or 'Implement S3 connector with batch processing and metadata management'.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch s3json2

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
go.mod (1)

10-58: Consider minor AWS SDK version updates for latest stability patches.

The AWS SDK v2 dependency structure is correct with proper indirect dependencies declared. However, newer patch versions are available: S3 service can upgrade from v1.92.1 to v1.93.0, and the base SDK from v1.40.0 to v1.40.1. No security advisories exist for either current version, but these patch updates may include bug fixes and stability improvements worth adopting.

🧹 Nitpick comments (4)
connectors/s3/s3writer.go (2)

70-94: Memory accounting may undercount actual usage.

The dataLen calculation (lines 70-73) accounts for the raw input size, but the actual buffer grows larger due to:

  1. JSON array delimiters ([, ], ,)
  2. Pretty-print formatting (newlines, indentation) when PrettyJSON is enabled

This could cause actual memory usage to exceed MaxTotalMemory by a non-trivial margin, especially with many small documents and pretty-printing enabled.

Consider accounting for formatting overhead, or document that MaxTotalMemory is approximate:

 	dataLen := int64(0)
 	for _, d := range data {
-		dataLen += int64(len(d))
+		overhead := int64(2) // comma + potential newline
+		if bp.config.PrettyJSON {
+			overhead += int64(len(d) / 10) // rough estimate for indentation
+		}
+		dataLen += int64(len(d)) + overhead
 	}

242-254: Potential key collision with nanosecond timestamps.

Under high concurrency, two goroutines could generate the same UnixNano() timestamp, causing one upload to overwrite another. While unlikely, this could cause data loss.

Consider adding a unique identifier:

+import "github.com/google/uuid"
+
 func (bp *BatchProcessor) objectKey(namespace string) string {
 	nsPath := strings.ReplaceAll(strings.Trim(namespace, "/"), ".", "/")
 	if nsPath == "" {
 		nsPath = "default"
 	}
-	fileName := fmt.Sprintf("%d.json", time.Now().UnixNano())
+	fileName := fmt.Sprintf("%d-%s.json", time.Now().UnixNano(), uuid.New().String()[:8])
internal/app/options/connectorflags.go (1)

608-622: Credentials in CLI flags may be exposed in process listings.

The access-key-id, secret-access-key, and session-token flags will be visible in process listings (ps aux) and shell history. This is a security concern for production use.

Consider:

  1. Documenting that environment variables (AWS_ACCESS_KEY_ID, etc.) or IAM roles are preferred
  2. Adding a note in the flag usage about security implications
  3. Supporting file-based credential input
 		altsrc.NewStringFlag(&cli.StringFlag{
 			Name:        "access-key-id",
-			Usage:       "Static AWS access key ID",
+			Usage:       "Static AWS access key ID (prefer AWS_ACCESS_KEY_ID env var or IAM roles for security)",
 			Destination: &settings.AccessKeyID,
 		}),
 		altsrc.NewStringFlag(&cli.StringFlag{
 			Name:        "secret-access-key",
-			Usage:       "Static AWS secret access key",
+			Usage:       "Static AWS secret access key (prefer AWS_SECRET_ACCESS_KEY env var or IAM roles for security)",
 			Destination: &settings.SecretAccessKey,
 		}),
connectors/s3/connector.go (1)

465-504: Code duplication with s3writer.go for metadata handling.

The metadataKey and readMetadata functions duplicate logic from s3writer.go (lines 256-291). This creates maintenance burden and risk of inconsistency.

Consider refactoring to share the metadata logic. Options:

  1. Export MetadataKey from the BatchProcessor or make it a standalone function
  2. Create a shared metadata.go file with common helpers
+// In s3writer.go, export the function:
+func MetadataKey(prefix, namespace string) string {
+	nsPath := strings.ReplaceAll(strings.Trim(namespace, "/"), ".", "/")
+	if nsPath == "" {
+		nsPath = "default"
+	}
+	prefix = strings.Trim(prefix, "/")
+	if prefix == "" {
+		return path.Join(nsPath, ".metadata.json")
+	}
+	return path.Join(prefix, nsPath, ".metadata.json")
+}

// In connector.go, use the shared function:
-func (c *connector) metadataKey(namespace string) string {
-	// ... duplicated code ...
-}
+func (c *connector) metadataKey(namespace string) string {
+	return MetadataKey(c.settings.Prefix, namespace)
+}
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e5a1838 and ae019b2.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (4)
  • connectors/s3/connector.go (1 hunks)
  • connectors/s3/s3writer.go (1 hunks)
  • go.mod (2 hunks)
  • internal/app/options/connectorflags.go (3 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
internal/app/options/connectorflags.go (1)
connectors/s3/connector.go (2)
  • ConnectorSettings (37-52)
  • NewConn (84-157)
connectors/s3/connector.go (4)
connectors/s3/s3writer.go (3)
  • BatchProcessor (36-52)
  • NewBatchProcessor (54-63)
  • Config (21-27)
gen/adiom/v1/adiomv1connect/adiom.connect.go (1)
  • ConnectorServiceHandler (214-225)
gen/adiom/v1/messages.pb.go (2)
  • DataType_DATA_TYPE_JSON_ID (29-29)
  • DataType_DATA_TYPE_MONGO_BSON (28-28)
protocol/iface/connector.go (1)
  • Namespace (104-107)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (5)
connectors/s3/s3writer.go (1)

223-240: LGTM - Clean shutdown logic.

The shutdown sequence correctly:

  1. Sets the shutdown flag under lock
  2. Collects buffer names before iteration to avoid map modification during iteration
  3. Releases the lock before wg.Wait() to allow upload goroutines to complete

Consider adding a timeout to wg.Wait() for production resilience, but this is acceptable for initial implementation.

internal/app/options/connectorflags.go (1)

300-316: LGTM - S3 connector registration follows established patterns.

The registration correctly:

  • Uses strings.HasPrefix(strings.ToLower(s), "s3://") for case-insensitive matching
  • Initializes settings with the URI before flag parsing
  • Delegates to CreateHelper consistent with other connectors like CosmosDB and MongoDB
connectors/s3/connector.go (3)

83-157: LGTM - Well-structured connector initialization.

The constructor properly:

  • Validates required fields before proceeding
  • Applies sensible defaults
  • Supports multiple credential sources (default chain, profile, static)
  • Enables S3-compatible service usage via endpoint and path-style options

392-418: LGTM - WriteData handles errors appropriately.

The implementation correctly:

  • Checks for existing errors to fail-fast
  • Returns early for empty batches
  • Propagates errors to block subsequent writes via setError()
  • Converts data types appropriately before batching

425-427: Teardown() is properly part of the connector lifecycle.

The Teardown() method is required by the Connector interface (protocol/iface/connector.go:129), not the ConnectorServiceHandler interface. The S3 connector correctly implements this interface, and the method is invoked during shutdown through the runner orchestration (runners/local/runner.go:312-319 calls r.src.Teardown() and r.dst.Teardown() as part of the application lifecycle). The batchProcessor.Close() call will execute as expected, and there is no risk of data loss.

Likely an incorrect or invalid review comment.

Comment thread connectors/s3/connector.go Outdated
Comment thread connectors/s3/connector.go
Comment thread connectors/s3/connector.go
Comment thread connectors/s3/s3writer.go
Comment thread connectors/s3/s3writer.go Outdated
Comment thread connectors/s3/s3writer.go

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (2)
connectors/s3/connector.go (2)

361-377: JSON to BSON conversion loses Extended JSON type fidelity.

This concern was raised in a previous review. Using json.Unmarshal to map[string]interface{} then bson.Marshal loses MongoDB Extended JSON types ($oid, $date, $numberLong become nested maps instead of proper BSON types).

Consider using bson.UnmarshalExtJSON for proper Extended JSON handling:

 	case adiomv1.DataType_DATA_TYPE_MONGO_BSON:
 		var rawDocs []json.RawMessage
 		if err := json.NewDecoder(getOut.Body).Decode(&rawDocs); err != nil {
 			return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("decode json array from %s: %w", key, err))
 		}
 		for _, d := range rawDocs {
-			// Convert JSON to BSON
-			var doc map[string]interface{}
-			if err := json.Unmarshal(d, &doc); err != nil {
-				return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("unmarshal json to map in %s: %w", key, err))
+			// Convert Extended JSON to BSON
+			var doc bson.D
+			if err := bson.UnmarshalExtJSON(d, true, &doc); err != nil {
+				return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("unmarshal extended json in %s: %w", key, err))
 			}
-			bsonDoc, err := bson.Marshal(doc)
+			bsonDoc, err := bson.Marshal(doc)

456-465: BSON to JSON conversion loses Extended JSON type information.

This concern was raised in a previous review. Using bson.Unmarshal to map[string]any then json.Marshal loses BSON type information (ObjectIds, Dates, Decimals serialize as plain strings/numbers).

Use bson.MarshalExtJSON for type-preserving conversion:

 	case adiomv1.DataType_DATA_TYPE_MONGO_BSON:
-		var doc map[string]any
-		if err := bson.Unmarshal(data, &doc); err != nil {
+		var doc bson.D
+		if err := bson.Unmarshal(data, &doc); err != nil {
 			return nil, fmt.Errorf("bson to json: %w", err)
 		}
-		converted, err := json.Marshal(doc)
+		converted, err := bson.MarshalExtJSON(doc, true, false)
 		if err != nil {
 			return nil, fmt.Errorf("marshal json: %w", err)
 		}
🧹 Nitpick comments (3)
connectors/s3/util.go (1)

5-7: Consider using a switch or set for better readability.

The long OR-chain is hard to scan. A switch or map lookup would be clearer and easier to extend.

-func IsS3OptimisticLockFailedError(apiError smithy.APIError) bool {
-	return apiError.ErrorCode() == "PreconditionFailed" || apiError.ErrorCode() == "412" || apiError.ErrorCode() == "409" || apiError.ErrorCode() == "ConditionalRequestConflict"
-}
+func IsS3OptimisticLockFailedError(apiError smithy.APIError) bool {
+	switch apiError.ErrorCode() {
+	case "PreconditionFailed", "412", "409", "ConditionalRequestConflict":
+		return true
+	default:
+		return false
+	}
+}
connectors/s3/s3writer.go (1)

158-228: LGTM with operational note.

Good improvements: timeout context (line 189) and proper variable capture in the goroutine. The memory tracking strategy is well-documented.

Lines 202-208: Upload failures are logged but silently dropped. Consider whether failed uploads should be tracked for observability (e.g., a counter metric) or reported back to the caller. The comment acknowledges this as a future improvement.

connectors/s3/connector.go (1)

92-95: Original parse error is discarded.

The error message loses the detail from parseS3ConnectionString. Consider wrapping instead:

 	bucket, prefix, err := parseS3ConnectionString(settings.Uri)
 	if err != nil {
-		return nil, fmt.Errorf("bad uri format %v", settings.Uri)
+		return nil, fmt.Errorf("bad uri format: %w", err)
 	}
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 683566d and c50337d.

📒 Files selected for processing (3)
  • connectors/s3/connector.go (1 hunks)
  • connectors/s3/s3writer.go (1 hunks)
  • connectors/s3/util.go (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
connectors/s3/s3writer.go (3)
connectors/s3/connector.go (2)
  • MAX_S3_WRITE_TIMEOUT_SEC (39-39)
  • S3_METADATA_UPDATES_RETRY (40-40)
connectors/s3/metadata.go (1)
  • MetadataKey (9-20)
connectors/s3/util.go (1)
  • IsS3OptimisticLockFailedError (5-7)
🔇 Additional comments (19)
connectors/s3/util.go (1)

9-20: LGTM!

The custom error type correctly implements both Error() and Unwrap() for proper error chain support. The private err field is appropriate.

connectors/s3/s3writer.go (7)

20-33: LGTM!

The Config struct has clear documentation for the limit fields, and bufferInfo appropriately encapsulates buffer state.


35-63: LGTM!

The BatchProcessor struct properly separates concerns (state, lifecycle, metadata), and the constructor correctly initializes the condition variable with the mutex.


65-134: LGTM with minor note.

The backpressure mechanism, buffer management, and JSON formatting are well-implemented. The fix for the infinite loop case (lines 79-81) properly addresses the edge case.

Note: Memory accounting at line 99 uses the input dataLen rather than the actual formatted buffer size. With PrettyJSON enabled, the real memory usage will be higher due to indentation. This is acceptable as an approximation but worth documenting.


136-156: LGTM!

Simple and effective eviction strategy. The O(n) scan is acceptable given typical namespace counts.


230-248: LGTM!

The shutdown sequence correctly flushes remaining buffers and releases the lock before waiting on the WaitGroup to avoid deadlock.


268-314: LGTM!

Proper use of ETag-based optimistic locking with conditional PutObject. The NoSuchKey handling correctly returns nil for non-existent metadata files.


346-366: Concurrency pattern is sound.

The combination of metadataMutex (serializes within-process) and ETag-based optimistic locking with retries (handles S3-level races) correctly addresses the race condition concern from previous reviews. This is a valid read-modify-write pattern for S3.

connectors/s3/connector.go (11)

30-41: LGTM!

Sentinel errors and constants are well-defined with clear names and appropriate default values.


43-70: LGTM!

ConnectorSettings provides comprehensive configuration. The embedded UnimplementedConnectorServiceHandler ensures forward compatibility with interface changes.


72-88: LGTM!

The parsing logic correctly handles both s3://bucket and s3://bucket/prefix formats with case-insensitive prefix matching.


166-188: LGTM!

Capabilities are correctly advertised for S3 as both source and sink with appropriate data type support.


190-295: LGTM!

The plan generation correctly paginates S3 objects, extracts namespaces from paths (fixing the previous asymmetry issue), and efficiently caches metadata per namespace.


297-329: LGTM!

Graceful handling of missing or unreadable metadata files by returning a count of 0.


388-396: LGTM!

Streaming methods correctly return unimplemented errors as S3 doesn't support change streams.


398-424: LGTM!

Proper error state checking before processing, document conversion, and error propagation to prevent further writes after a failure.


426-433: LGTM!

WriteUpdates correctly returns unsupported, and Teardown properly closes the batch processor.


435-447: LGTM!

Thread-safe error state with RWMutex and first-error-wins semantics is appropriate for async error propagation.


471-502: LGTM!

Correct implementation with NoSuchKey handling consistent with the pattern in s3writer.go.

Comment thread connectors/s3/s3writer.go
Comment on lines +250 to +262
func (bp *BatchProcessor) objectKey(namespace string) string {
nsPath := strings.ReplaceAll(strings.Trim(namespace, "/"), ".", "/")
if nsPath == "" {
nsPath = "default"
}
fileName := fmt.Sprintf("%d.json", time.Now().UnixNano())

prefix := strings.Trim(bp.config.Prefix, "/")
if prefix == "" {
return path.Join(nsPath, fileName)
}
return path.Join(prefix, nsPath, fileName)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Potential filename collision under high concurrency.

time.Now().UnixNano() can return the same value for multiple goroutines executing simultaneously, leading to S3 key collisions and data loss.

Consider adding a random suffix or atomic counter:

+import "math/rand"
+
 func (bp *BatchProcessor) objectKey(namespace string) string {
 	nsPath := strings.ReplaceAll(strings.Trim(namespace, "/"), ".", "/")
 	if nsPath == "" {
 		nsPath = "default"
 	}
-	fileName := fmt.Sprintf("%d.json", time.Now().UnixNano())
+	fileName := fmt.Sprintf("%d_%d.json", time.Now().UnixNano(), rand.Int63())
 
 	prefix := strings.Trim(bp.config.Prefix, "/")
 	if prefix == "" {
 		return path.Join(nsPath, fileName)
 	}
 	return path.Join(prefix, nsPath, fileName)
 }

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In connectors/s3/s3writer.go around lines 250 to 262, the objectKey() uses
time.Now().UnixNano() alone which can collide under high concurrency; change the
filename generation to append a short unique suffix (for example a UUID or a
process-wide atomic counter combined with a few random bytes) to ensure
uniqueness across goroutines, make the counter atomic or use a thread-safe
UUID/random generator, and update imports accordingly so the returned key is
time-based plus the unique suffix (e.g., "<timestamp>-<uniq>.json") to eliminate
S3 key collisions.

Comment thread connectors/s3/s3writer.go Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (1)
connectors/s3/s3writer.go (1)

251-263: Filename collision risk remains unaddressed.

The objectKey method still uses only time.Now().UnixNano() (line 256) without additional uniqueness guarantees. Under high concurrency, multiple goroutines can execute within the same nanosecond, leading to S3 key collisions and potential data loss.

As suggested in the previous review, add a random suffix or process-wide atomic counter:

+import (
+	"sync/atomic"
+)
+
+var fileCounter uint64
+
 func (bp *BatchProcessor) objectKey(namespace string) string {
 	nsPath := strings.ReplaceAll(strings.Trim(namespace, "/"), ".", "/")
 	if nsPath == "" {
 		nsPath = "default"
 	}
-	fileName := fmt.Sprintf("%d.json", time.Now().UnixNano())
+	seq := atomic.AddUint64(&fileCounter, 1)
+	fileName := fmt.Sprintf("%d_%d.json", time.Now().UnixNano(), seq)
 
 	prefix := strings.Trim(bp.config.Prefix, "/")
 	if prefix == "" {
 		return path.Join(nsPath, fileName)
 	}
 	return path.Join(prefix, nsPath, fileName)
 }

Alternatively, use a random suffix:

fileName := fmt.Sprintf("%d_%d.json", time.Now().UnixNano(), rand.Int63())
🧹 Nitpick comments (1)
connectors/s3/s3writer.go (1)

36-53: Well-structured processor with proper synchronization primitives.

The BatchProcessor uses appropriate synchronization mechanisms: mutex + condition variable for backpressure, WaitGroup for lifecycle management, and a dedicated metadataMutex for metadata updates.

Optional: Consider per-namespace locks instead of a global metadataMutex to allow concurrent metadata updates for different namespaces. However, since metadata updates involve S3 I/O, the current serialization overhead is likely negligible.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c50337d and 3363219.

📒 Files selected for processing (1)
  • connectors/s3/s3writer.go (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
connectors/s3/s3writer.go (3)
connectors/s3/connector.go (2)
  • MAX_S3_WRITE_TIMEOUT_SEC (39-39)
  • S3_METADATA_UPDATES_RETRY (40-40)
connectors/s3/metadata.go (1)
  • MetadataKey (9-20)
connectors/s3/util.go (2)
  • IsS3OptimisticLockFailedError (5-7)
  • ErrS3OptimisticLockFailed (10-12)
🔇 Additional comments (11)
connectors/s3/s3writer.go (11)

21-34: LGTM! Clear configuration and buffer tracking structures.

The Config struct provides all necessary parameters for S3 batch processing, and bufferInfo appropriately tracks per-namespace buffer state.


55-64: LGTM! Constructor properly initializes all components.

The condition variable is correctly bound to the mutex, and all fields are appropriately initialized.


66-135: Excellent implementation of backpressure and buffering logic.

The Add method correctly:

  • Prevents infinite loops by rejecting batches exceeding total memory (lines 80-82) ✓
  • Implements backpressure with eviction and condition variable signaling ✓
  • Handles buffer initialization and JSON array formatting ✓
  • Provides fallback for pretty JSON failures ✓
  • Triggers flush when file size limit is reached ✓

137-157: LGTM! Effective eviction strategy with helpful logging.

The method correctly identifies and flushes the largest buffer under memory pressure, with appropriate debug information.


159-229: Excellent async upload implementation with proper resource management.

The method correctly:

  • Snapshots buffer data before launching goroutine ✓
  • Uses timeout context to prevent indefinite blocking (line 190) ✓
  • Removes buffer from map immediately to unblock new writes ✓
  • Properly manages WaitGroup for lifecycle tracking ✓
  • Reclaims memory and signals waiting threads upon completion ✓
  • Handles metadata updates with retry logic ✓

231-249: LGTM! Proper shutdown sequence with graceful flush.

The Close method correctly flushes all remaining buffers, unlocks before waiting (avoiding deadlock), and ensures all uploads complete via WaitGroup.


265-267: LGTM! Clean delegation to shared metadata key generation.

The wrapper appropriately delegates to the MetadataKey utility function.


269-291: LGTM! Proper metadata retrieval with ETag capture.

The method correctly:

  • Handles non-existent metadata by returning nil (line 279) ✓
  • Captures ETag for optimistic locking (line 290) ✓
  • Properly closes the response body ✓

293-315: LGTM! Proper optimistic locking implementation.

The method correctly:

  • Uses IfMatch with ETag for conditional writes (line 305) ✓
  • Detects and wraps optimistic lock failures for retry handling (lines 309-310) ✓
  • Sets appropriate content type ✓

317-346: Excellent retry logic with exponential backoff and jitter.

The method correctly:

  • Implements proper exponential backoff (line 341) ✓
  • Adds jitter to reduce thundering herd (line 342) ✓
  • Only retries optimistic lock failures (lines 328-331) ✓
  • Returns non-retryable errors immediately ✓

This addresses previous review concerns about backoff calculation.


348-368: LGTM! Proper metadata update with local and distributed synchronization.

The method correctly combines:

  • Local mutex to prevent concurrent updates in the same process (line 349) ✓
  • Optimistic locking via ETag to handle cross-process/cross-instance races ✓
  • Clean read-modify-write pattern with proper error propagation ✓

This addresses the previous review concern about metadata race conditions.

Comment thread connectors/s3/s3writer.go Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (2)
connectors/s3/s3writer.go (2)

10-10: Seed math/rand for non-deterministic jitter.

The math/rand package is used for jitter (line 343) but isn't seeded, producing the same sequence on every run. Add an init() function or switch to math/rand/v2 (Go 1.22+) which auto-seeds.

+func init() {
+	rand.Seed(time.Now().UnixNano())
+}
+
 // Config holds our limits
 type Config struct {

251-263: Potential filename collision under high concurrency.

time.Now().UnixNano() can return identical values for concurrent goroutines, risking S3 key collisions and data overwrites.

Add a random suffix or atomic counter:

+var objectCounter uint64
+
 func (bp *BatchProcessor) objectKey(namespace string) string {
 	nsPath := strings.ReplaceAll(strings.Trim(namespace, "/"), ".", "/")
 	if nsPath == "" {
 		nsPath = "default"
 	}
-	fileName := fmt.Sprintf("%d.json", time.Now().UnixNano())
+	counter := atomic.AddUint64(&objectCounter, 1)
+	fileName := fmt.Sprintf("%d_%d.json", time.Now().UnixNano(), counter)
 
 	prefix := strings.Trim(bp.config.Prefix, "/")

Add "sync/atomic" to imports.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3363219 and 0750602.

📒 Files selected for processing (1)
  • connectors/s3/s3writer.go (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
connectors/s3/s3writer.go (3)
connectors/s3/connector.go (2)
  • MAX_S3_WRITE_TIMEOUT_SEC (39-39)
  • S3_METADATA_UPDATES_RETRY (40-40)
connectors/s3/metadata.go (1)
  • MetadataKey (9-20)
connectors/s3/util.go (2)
  • IsS3OptimisticLockFailedError (5-7)
  • ErrS3OptimisticLockFailed (10-12)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (7)
connectors/s3/s3writer.go (7)

36-64: Well-structured concurrent processor design.

Good use of sync.Cond for backpressure signaling and separate metadataMutex for serializing metadata operations without blocking data ingestion.


76-97: Backpressure mechanism correctly prevents infinite loop.

The pre-check at lines 80-82 properly guards against the case where a single batch exceeds MaxTotalMemory, preventing the wait loop from becoming infinite.


231-249: Clean shutdown implementation.

Properly collects buffer keys before iteration to avoid concurrent modification, and uses WaitGroup to ensure all in-flight uploads complete.


269-291: Correct handling of missing metadata and ETag extraction.

Gracefully handles NoSuchKey for first-time writes and properly returns the ETag for subsequent optimistic locking.


293-315: Proper optimistic locking with ETag.

Uses IfMatch for conditional writes and correctly wraps precondition failures for retry handling.


317-347: Solid retry logic with exponential backoff.

Correctly identifies retryable precondition failures and implements proper exponential backoff (50ms, 100ms, 200ms) with jitter.


349-369: Serialized metadata updates with optimistic locking.

The metadataMutex prevents local race conditions, while ETag-based conditional writes with retry logic handle cross-process conflicts.

Comment thread connectors/s3/s3writer.go
Comment on lines +203 to +218
if err != nil {
slog.Error("Upload failed",
"namespace", ns,
"key", key,
"error", err,
)
// In a real system, you might implement a retry mechanism or Dead Letter Queue here.
} else {
// Update metadata on successful upload
if err := bp.updateMetadataWithRetries(ctx, ns, key, uint64(numDocs)); err != nil {
slog.Error("Failed to update metadata after retries",
"namespace", ns,
"key", key,
"error", err,
)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Upload failures result in silent data loss.

When PutObject fails, the data is logged and discarded. The comment mentions retry/DLQ but nothing is implemented. Consider adding a retry mechanism or at minimum persisting failed uploads to a local file for recovery.

Would you like me to generate a retry mechanism with exponential backoff for failed uploads, or a simple file-based dead letter queue?

@alex-thc
alex-thc requested a review from adiom-mark December 8, 2025 19:11

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (1)
connectors/s3/s3writer.go (1)

251-263: Potential filename collision under high concurrency.

time.Now().UnixNano() can return the same value for multiple goroutines executing simultaneously, leading to S3 key collisions and data loss.

This was flagged in a previous review but appears unaddressed. Consider adding a random suffix or atomic counter:

+var keyCounter uint64
+
 func (bp *BatchProcessor) objectKey(namespace string) string {
 	nsPath := strings.ReplaceAll(strings.Trim(namespace, "/"), ".", "/")
 	if nsPath == "" {
 		nsPath = "default"
 	}
-	fileName := fmt.Sprintf("%d.json", time.Now().UnixNano())
+	counter := atomic.AddUint64(&keyCounter, 1)
+	fileName := fmt.Sprintf("%d_%d.json", time.Now().UnixNano(), counter)

 	prefix := strings.Trim(bp.config.Prefix, "/")
🧹 Nitpick comments (2)
connectors/s3/s3writer.go (2)

203-219: Upload failures result in silent data loss.

When PutObject fails, the data is logged and discarded. The XXX comment acknowledges this, but for production reliability, consider tracking failed uploads for later retry or alerting.

One approach is to track failed uploads in a channel for later processing or persistence:

type failedUpload struct {
    namespace string
    key       string
    data      []byte
    err       error
}
// Add to BatchProcessor: failedUploads chan failedUpload

Alternatively, ensure upstream callers are aware that Close() returning does not guarantee all data was successfully persisted.


231-249: Close() may block indefinitely if S3 uploads are slow or unresponsive.

While individual uploads now have a 300-second timeout, Close() calls bp.wg.Wait() without any overall timeout. If there are many in-flight uploads or the network is degraded, Close() could block for an extended period.

Consider adding an optional deadline to Close():

func (bp *BatchProcessor) Close() error {
    // ... flush logic ...
    
    done := make(chan struct{})
    go func() {
        bp.wg.Wait()
        close(done)
    }()
    
    select {
    case <-done:
        slog.Debug("Finished final S3 flush")
        return nil
    case <-time.After(10 * time.Minute):
        return fmt.Errorf("timeout waiting for uploads to complete")
    }
}
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0750602 and 8d6885b.

📒 Files selected for processing (1)
  • connectors/s3/s3writer.go (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
connectors/s3/s3writer.go (3)
connectors/s3/connector.go (2)
  • MAX_S3_WRITE_TIMEOUT_SEC (39-39)
  • S3_METADATA_UPDATES_RETRY (40-40)
connectors/s3/metadata.go (1)
  • MetadataKey (9-20)
connectors/s3/util.go (2)
  • IsS3OptimisticLockFailedError (5-7)
  • ErrS3OptimisticLockFailed (10-12)
🔇 Additional comments (9)
connectors/s3/s3writer.go (9)

1-19: LGTM!

Good choice using math/rand/v2 which auto-seeds, addressing the previous concern about deterministic jitter.


21-28: LGTM!

Config struct is well-defined. Consider adding validation in the constructor to ensure MaxFileSize <= MaxTotalMemory and both are positive, but this is optional.


30-53: LGTM!

Good use of sync.Cond for backpressure signaling and metadataMutex to serialize metadata updates within the process. The optimistic locking with retries handles cross-process concurrency.


55-64: LGTM!

Constructor correctly initializes all state and the condition variable.


137-157: LGTM!

Eviction strategy correctly identifies and flushes the largest buffer to maximize freed memory.


265-267: LGTM!

Clean delegation to the shared MetadataKey utility function.


269-291: LGTM!

Proper handling of NoSuchKey for new namespaces and correct ETag extraction for optimistic locking.


317-346: LGTM!

Well-implemented retry logic with exponential backoff and jitter. Only retries on optimistic lock failures (precondition errors), correctly propagating other errors immediately.


348-368: LGTM!

Proper serialization with metadataMutex for intra-process concurrency combined with optimistic locking for inter-process safety. The read-modify-write pattern is correctly protected.

Comment thread connectors/s3/s3writer.go
Comment on lines +111 to +127
for i, doc := range data {
if bp.buffers[namespace].docCount > 0 || i > 0 {
bp.buffers[namespace].buffer.WriteByte(',')
}
if bp.config.PrettyJSON {
var prettyBuf bytes.Buffer
prettyBuf.Grow(len(doc) + len(doc)/10)
if err := json.Indent(&prettyBuf, doc, "", " "); err == nil {
bp.buffers[namespace].buffer.WriteByte('\n')
doc = prettyBuf.Bytes()
} else {
slog.Warn("Failed JSON indentation. Falling back to no-indent")
}
}
bp.buffers[namespace].buffer.Write(doc)
bp.buffers[namespace].docCount++
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Memory tracking may undercount when PrettyJSON is enabled.

When PrettyJSON is true, json.Indent can significantly expand the document size (typically 20-50% larger). However, dataLen was calculated from the original compact data, and currentUsage is incremented by this smaller value. The actual buffer may grow larger than tracked, potentially exceeding MaxTotalMemory in practice.

Consider recalculating the size after indentation or using a conservative multiplier:

 	for i, doc := range data {
 		if bp.buffers[namespace].docCount > 0 || i > 0 {
 			bp.buffers[namespace].buffer.WriteByte(',')
 		}
 		if bp.config.PrettyJSON {
 			var prettyBuf bytes.Buffer
 			prettyBuf.Grow(len(doc) + len(doc)/10)
 			if err := json.Indent(&prettyBuf, doc, "", "  "); err == nil {
 				bp.buffers[namespace].buffer.WriteByte('\n')
+				// Update memory tracking for expanded size
+				bp.currentUsage += int64(prettyBuf.Len() - len(doc))
 				doc = prettyBuf.Bytes()
 			} else {
 				slog.Warn("Failed JSON indentation. Falling back to no-indent")
 			}
 		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for i, doc := range data {
if bp.buffers[namespace].docCount > 0 || i > 0 {
bp.buffers[namespace].buffer.WriteByte(',')
}
if bp.config.PrettyJSON {
var prettyBuf bytes.Buffer
prettyBuf.Grow(len(doc) + len(doc)/10)
if err := json.Indent(&prettyBuf, doc, "", " "); err == nil {
bp.buffers[namespace].buffer.WriteByte('\n')
doc = prettyBuf.Bytes()
} else {
slog.Warn("Failed JSON indentation. Falling back to no-indent")
}
}
bp.buffers[namespace].buffer.Write(doc)
bp.buffers[namespace].docCount++
}
for i, doc := range data {
if bp.buffers[namespace].docCount > 0 || i > 0 {
bp.buffers[namespace].buffer.WriteByte(',')
}
if bp.config.PrettyJSON {
var prettyBuf bytes.Buffer
prettyBuf.Grow(len(doc) + len(doc)/10)
if err := json.Indent(&prettyBuf, doc, "", " "); err == nil {
bp.buffers[namespace].buffer.WriteByte('\n')
// Update memory tracking for expanded size
bp.currentUsage += int64(prettyBuf.Len() - len(doc))
doc = prettyBuf.Bytes()
} else {
slog.Warn("Failed JSON indentation. Falling back to no-indent")
}
}
bp.buffers[namespace].buffer.Write(doc)
bp.buffers[namespace].docCount++
}
🤖 Prompt for AI Agents
In connectors/s3/s3writer.go around lines 111 to 127, memory accounting uses the
original compact document length even when PrettyJSON=true and json.Indent
expands the bytes, causing currentUsage to undercount and potentially exceed
MaxTotalMemory; recalculate the actual byte size after indentation (e.g., use
len(prettyBytes) when indent succeeds) or apply a conservative multiplier before
reserving memory, then increment currentUsage by that actual/reserved size and
validate against MaxTotalMemory before writing to the buffer; ensure the
fallback path still uses the correct length and update any buffer.Grow logic to
match the recalculated size.

Comment thread connectors/s3/s3writer.go

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (2)
connectors/s3/s3writer.go (2)

112-128: Memory tracking undercount when PrettyJSON expands documents.

When PrettyJSON is enabled, json.Indent typically expands documents by 20-50%, but currentUsage (incremented at line 101) reflects only the original compact size. The actual buffer grows larger than tracked, potentially exceeding MaxTotalMemory and causing OOM despite backpressure.

Consider recalculating after indentation:

 	for i, doc := range data {
 		if bp.buffers[namespace].docCount > 0 || i > 0 {
 			bp.buffers[namespace].buffer.WriteByte(',')
 		}
 		if bp.config.PrettyJSON {
 			var prettyBuf bytes.Buffer
-			prettyBuf.Grow(len(doc) + len(doc)/10)
+			originalLen := len(doc)
+			prettyBuf.Grow(originalLen + originalLen/10)
 			if err := json.Indent(&prettyBuf, doc, "", "  "); err == nil {
 				bp.buffers[namespace].buffer.WriteByte('\n')
+				// Account for expansion
+				expansion := prettyBuf.Len() - originalLen
+				bp.currentUsage += int64(expansion)
 				doc = prettyBuf.Bytes()
 			} else {
 				slog.Warn("Failed JSON indentation. Falling back to no-indent")
 			}
 		}

252-264: Potential filename collision under high concurrency.

time.Now().UnixNano() can return identical values for concurrent goroutines, leading to S3 key collisions and data loss.

Add a random suffix using math/rand/v2:

 func (bp *BatchProcessor) objectKey(namespace string) string {
 	nsPath := strings.ReplaceAll(strings.Trim(namespace, "/"), ".", "/")
 	if nsPath == "" {
 		nsPath = "default"
 	}
-	fileName := fmt.Sprintf("%d.json", time.Now().UnixNano())
+	fileName := fmt.Sprintf("%d_%d.json", time.Now().UnixNano(), rand.Uint64())
 
 	prefix := strings.Trim(bp.config.Prefix, "/")
 	if prefix == "" {
 		return path.Join(nsPath, fileName)
 	}
 	return path.Join(prefix, nsPath, fileName)
 }
🧹 Nitpick comments (1)
connectors/s3/s3writer.go (1)

354-374: Consider per-namespace locking for better concurrency.

The current metadataMutex serializes all metadata updates across all namespaces. While this prevents races, updates to different namespaces could safely proceed in parallel.

Consider using a per-namespace lock map:

// Add to BatchProcessor struct:
metadataLocks sync.Map // map[string]*sync.Mutex per namespace

// In updateMetadata:
func (bp *BatchProcessor) updateMetadata(ctx context.Context, namespace string, fileKey string, recordCount uint64) error {
	// Get or create per-namespace mutex
	lockVal, _ := bp.metadataLocks.LoadOrStore(namespace, &sync.Mutex{})
	nsLock := lockVal.(*sync.Mutex)
	
	nsLock.Lock()
	defer nsLock.Unlock()
	
	// ... rest of function unchanged
}

This allows concurrent metadata updates for different namespaces while still preventing races within the same namespace.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8d6885b and 192882e.

📒 Files selected for processing (1)
  • connectors/s3/s3writer.go (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
connectors/s3/s3writer.go (3)
connectors/s3/connector.go (2)
  • MAX_S3_WRITE_TIMEOUT_SEC (39-39)
  • S3_METADATA_UPDATES_RETRY (40-40)
connectors/s3/metadata.go (1)
  • MetadataKey (9-20)
connectors/s3/util.go (2)
  • IsS3OptimisticLockFailedError (5-7)
  • ErrS3OptimisticLockFailed (10-12)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (4)
connectors/s3/s3writer.go (4)

1-65: LGTM! Clean structure and proper initialization.

The use of math/rand/v2 (which auto-seeds) addresses previous concerns about deterministic jitter. The BatchProcessor struct correctly initializes the condition variable with its mutex.


77-83: LGTM! Prevents infinite loop scenario.

The pre-check ensures that oversized batches are rejected immediately rather than entering an infinite wait loop.


294-321: LGTM! Optimistic locking correctly implemented.

The conditional write logic properly handles both new file creation (using IfNoneMatch="*" for put-if-not-exists) and updates (using IfMatch with ETag for compare-and-swap). This prevents concurrent metadata overwrites.


323-352: LGTM! Robust retry logic with proper backoff.

The exponential backoff (50*(1<<i) ms) ensures non-zero first retry, and rand.N from math/rand/v2 provides non-deterministic jitter. Correctly retries only on optimistic lock failures.

Comment thread connectors/s3/s3writer.go
Comment on lines +204 to +220
if err != nil {
slog.Error("Upload failed",
"namespace", ns,
"key", key,
"error", err,
)
//XXX: might want to implement a retry mechanism or Dead Letter Queue here.
} else {
// Update metadata on successful upload
if err := bp.updateMetadataWithRetries(ctx, ns, key, uint64(numDocs)); err != nil {
slog.Error("Failed to update metadata after retries",
"namespace", ns,
"key", key,
"error", err,
)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Upload failures result in data loss.

Failed uploads are logged but the data is discarded. While the XXX comment (line 210) acknowledges this, transient S3 errors will cause permanent data loss.

Would you like me to generate a retry mechanism with exponential backoff for failed uploads, or a simple file-based dead letter queue for recovery?

@alex-thc
alex-thc merged commit 7f3b205 into main Dec 8, 2025
2 checks passed
@alex-thc
alex-thc deleted the s3json2 branch December 8, 2025 20:12
This was referenced Mar 30, 2026
@coderabbitai coderabbitai Bot mentioned this pull request Apr 15, 2026
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.

1 participant