Skip to content

feat(sync): add ETag and content hash support to blob sync#1991

Open
leakonvalinka wants to merge 6 commits into
open-feature:mainfrom
open-feature-forking:feat/dedupe-unchanged-payloads
Open

feat(sync): add ETag and content hash support to blob sync#1991
leakonvalinka wants to merge 6 commits into
open-feature:mainfrom
open-feature-forking:feat/dedupe-unchanged-payloads

Conversation

@leakonvalinka

@leakonvalinka leakonvalinka commented Jul 7, 2026

Copy link
Copy Markdown
Member

This PR

  • introduces ETag & hash of body's content to the blob sync to avoid publishing "fake" changes

Related Issues

#1988

Signed-off-by: Lea Konvalinka <lea.konvalinka@dynatrace.com>
@netlify

netlify Bot commented Jul 7, 2026

Copy link
Copy Markdown

Deploy Preview for polite-licorice-3db33c canceled.

Name Link
🔨 Latest commit c158cae
🔍 Latest deploy log https://app.netlify.com/projects/polite-licorice-3db33c/deploys/6a579bccdcb91a00085dbe3a

@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e280f7d8-9b5e-45e5-a878-a8533f28f4d4

📥 Commits

Reviewing files that changed from the base of the PR and between 676cd4b and c158cae.

📒 Files selected for processing (2)
  • core/pkg/utils/hash.go
  • core/pkg/utils/hash_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • core/pkg/utils/hash.go
  • core/pkg/utils/hash_test.go

📝 Walkthrough

Walkthrough

Blob synchronization now compares object attributes and canonicalized JSON body hashes before publishing, persists ETag and body-hash state, synchronizes readiness access, centralizes hashing for blob and HTTP sync, replaces the test mock driver, and expands change-detection and resync tests.

Changes

Blob Sync Change Detection

Layer / File(s) Summary
Sync state and object hashing
core/pkg/sync/blob/blob_sync.go, core/pkg/sync/http/http_sync.go, core/pkg/utils/hash.go
Adds cached ETag/body-hash state, synchronized readiness access, object-fetch helpers, and canonicalized SHA3-256 hashing for converted JSON.
Attribute and body-hash change detection
core/pkg/sync/blob/blob_sync.go
Skips unchanged attributes, compares converted-body hashes, publishes only changed data, and updates cached state.
Fake blob driver for controlled sync tests
core/pkg/sync/blob/mock_blob.go
Adds explicit object metadata/content control and full-read tracking through a custom fake blob driver.
Sync and resync change-detection coverage
core/pkg/sync/blob/blob_sync_test.go, core/pkg/utils/hash_test.go
Tests publishing, skipped reads, metadata-only changes, resync behavior, ETag/ModTime/content combinations, and hash normalization.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

  • open-feature/flagd issue 1988 — The change implements blob deduplication using ETag/mod-time checks and body hashing.

Suggested reviewers: guidobrei

Sequence Diagram(s)

sequenceDiagram
  participant Poller
  participant BlobSync
  participant BlobBucket
  participant DataChannel
  Poller->>BlobSync: sync()
  BlobSync->>BlobBucket: fetchObjectAttributes()
  BlobBucket-->>BlobSync: ETag and ModTime
  alt attributes changed
    BlobSync->>BlobBucket: fetchObject()
    BlobBucket-->>BlobSync: converted JSON and body hash
    alt body hash changed
      BlobSync->>DataChannel: publish sync.DataSync
    else body hash unchanged
      BlobSync-->>Poller: skip publication
    end
  else attributes unchanged
    BlobSync-->>Poller: skip full-object fetch
  end
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: adding ETag and content-hash support to blob sync.
Description check ✅ Passed The description is directly related to the changeset and explains the new ETag and body-hash change detection.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 golangci-lint (2.12.2)

level=error msg="[linters_context] typechecking error: pattern ./...: directory prefix . does not contain main module or its selected dependencies"


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.

Signed-off-by: Lea Konvalinka <lea.konvalinka@dynatrace.com>
Signed-off-by: Lea Konvalinka <lea.konvalinka@dynatrace.com>
@leakonvalinka leakonvalinka marked this pull request as ready for review July 9, 2026 13:45
@leakonvalinka leakonvalinka requested review from a team as code owners July 9, 2026 13:45
@dosubot dosubot Bot added the size:L This PR changes 100-499 lines, ignoring generated files. label Jul 9, 2026

@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

🧹 Nitpick comments (1)
core/pkg/sync/blob/blob_sync.go (1)

183-186: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Consider hashing the converted JSON rather than raw bytes.

generateSha hashes the raw data (pre-conversion bytes), but fetchObject returns the converted json string for publishing. If a YAML file is reformatted without changing the semantic flag configuration, the raw bytes change (different hash) while the JSON output stays the same — triggering an unnecessary fetch+publish of identical content.

Hashing the converted JSON instead would catch this case and align the change-detection hash with what is actually published.

♻️ Proposed refactor: hash the converted JSON
 func (hs *Sync) fetchObject(ctx context.Context, bucket *blob.Bucket) (string, string, error) {
 	r, err := bucket.NewReader(ctx, hs.Object, nil)
 	if err != nil {
 		return "", "", fmt.Errorf("error opening reader for object %s/%s: %w", hs.Bucket, hs.Object, err)
 	}
 	defer r.Close()

 	data, err := io.ReadAll(r)
 	if err != nil {
 		return "", "", fmt.Errorf("error downloading object %s/%s: %w", hs.Bucket, hs.Object, err)
 	}

 	json, err := utils.ConvertToJSON(data, filepath.Ext(hs.Object), r.ContentType())
 	if err != nil {
 		return "", "", fmt.Errorf("error converting blob data to json: %w", err)
 	}
-	return json, hs.generateSha(data), nil
+	return json, hs.generateSha([]byte(json)), nil
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@core/pkg/sync/blob/blob_sync.go` around lines 183 - 186, The change-detection
hash in generateSha is based on the raw input bytes, but fetchObject publishes
the converted JSON, so equivalent YAML reformatting can trigger unnecessary
updates. Update the hashing flow around generateSha/fetchObject so the hash is
computed from the converted JSON string (the same payload that gets published),
and ensure the comparison uses that canonical representation for Sync.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@core/pkg/sync/blob/blob_sync.go`:
- Around line 104-114: The ReSync path in blob synchronization is leaving state
stale because updateState is only called when skipChangeDetection is false,
which causes duplicate publishes on the next normal sync. Update the
sync/publish flow in blob_sync.go so updateState is called unconditionally after
processing a blob, using attrs as nil-safe input when skipChangeDetection is
true; this lets hs.updateState refresh lastBodySHA while preserving
lastETag/lastUpdated. Keep the existing skip-publish branch in sync with
hs.lastBodySHA comparison, and reference sync() plus updateState to ensure the
state is always refreshed after both publish and no-change paths.
- Around line 80-117: The Sync.sync flow reads and updates shared state fields
like lastETag, lastUpdated, and lastBodySHA without any synchronization, which
can race when Poller.Start and ReSync invoke it concurrently. Add mutex or
atomic protection around all accesses to these fields, and update the relevant
methods in Sync such as sync, attributesUnchanged, fetchObject, and updateState
so reads and writes are guarded consistently.

---

Nitpick comments:
In `@core/pkg/sync/blob/blob_sync.go`:
- Around line 183-186: The change-detection hash in generateSha is based on the
raw input bytes, but fetchObject publishes the converted JSON, so equivalent
YAML reformatting can trigger unnecessary updates. Update the hashing flow
around generateSha/fetchObject so the hash is computed from the converted JSON
string (the same payload that gets published), and ensure the comparison uses
that canonical representation for Sync.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b7cd5131-4649-4f53-a5c0-9a076de6f98e

📥 Commits

Reviewing files that changed from the base of the PR and between d99a3e3 and 7fc35a9.

📒 Files selected for processing (6)
  • core/pkg/sync/blob/blob_sync.go
  • core/pkg/sync/blob/blob_sync_test.go
  • core/pkg/sync/blob/mock_blob.go
  • schemas
  • spec
  • test-harness

Comment thread core/pkg/sync/blob/blob_sync.go
Comment thread core/pkg/sync/blob/blob_sync.go Outdated
@toddbaert

Copy link
Copy Markdown
Member

Initially, I misread the code and thought that we weren't properly updating the bodySha and using it on the next request; we are (I misread) but I think we are missing a test that specifically asserts this. The existing TestBlobSync_ChangeDetection cases prove state is set after the baseline sync (since the "ETag unchanged -> no fetch" case would fail otherwise), but nothing exercises the full path involving an update to the blob while syncing. A small three-sync case would give confidence here: initial publish -> change + publish -> identical tick asserts no fetch and no publish. That way if someone later moves updateState back into only the skip branch, we'd catch it.

Comment thread spec
Comment thread schemas
Comment thread test-harness
Comment thread core/pkg/sync/blob/blob_sync.go Outdated
Comment on lines +183 to +186
func (hs *Sync) generateSha(body []byte) string {
hasher := sha3.New256()
hasher.Write(body)
return base64.URLEncoding.EncodeToString(hasher.Sum(nil))

@toddbaert toddbaert Jul 10, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Instead of hashing the body directly, hash a canonical representation of the payload; i.e. JSON-parse the data first, then hash the re-serialized output. Go's encoding/json sorts map keys alphabetically and has strong stability guarantees on output. Hashing that means upstream key-ordering differences (not stable per the JSON spec) and YAML comments/whitespace won't cause spurious "changes". Ideally we'd apply this across all sync sources (file, blob, http), not just blob. It's a bit of scope creep, but IMO a good thing to add here.

@toddbaert toddbaert requested a review from guidobrei July 10, 2026 15:14
Signed-off-by: Lea Konvalinka <lea.konvalinka@dynatrace.com>

@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.

🧹 Nitpick comments (1)
core/pkg/sync/blob/blob_sync_test.go (1)

237-237: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Rename the test to avoid confusion with the ReSync method.

The test name implies that it exercises the ReSync method, but it actually verifies the complete standard polling update flow (initial publish, changed publish, and an identical tick). Renaming it to align with the PR objective's description will improve clarity for future maintainers.

♻️ Proposed refactor
-func TestBlobSync_SyncAndResync(t *testing.T) {
+func TestBlobSync_CompleteUpdateFlow(t *testing.T) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@core/pkg/sync/blob/blob_sync_test.go` at line 237, Rename the
TestBlobSync_SyncAndResync test to reflect that it covers the standard polling
update flow rather than the ReSync method, while leaving its test behavior
unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@core/pkg/sync/blob/blob_sync_test.go`:
- Line 237: Rename the TestBlobSync_SyncAndResync test to reflect that it covers
the standard polling update flow rather than the ReSync method, while leaving
its test behavior unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c932618c-fda6-428e-9c32-74f66fe07c6c

📥 Commits

Reviewing files that changed from the base of the PR and between 7fc35a9 and 2ccb641.

📒 Files selected for processing (2)
  • core/pkg/sync/blob/blob_sync.go
  • core/pkg/sync/blob/blob_sync_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • core/pkg/sync/blob/blob_sync.go

@leakonvalinka leakonvalinka changed the title feat: add ETag and content hash support to blob sync feat(sync): add ETag and content hash support to blob sync Jul 15, 2026
// based on its attributes alone, allowing us to skip fetching the full object.
// prefers the ETag and falls back to the mod time for stores that don't expose one.
func (hs *Sync) attributesUnchanged(attrs *blob.Attributes) bool {
hs.mu.Lock()

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.

since we are adding a locking mechanism, would it make sense to add some kind of small/lightweight concurrency/race-safety test?
e.g. concurrent IsReady(), ReSync(), and regular sync calls on the same Sync, mainly intended to run under go test -race (which is already set up for our tests. see makefile). Just to make sure that there are no race conditions or deadlocks are happening around that data thats protected via the mutex

@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

🧹 Nitpick comments (2)
core/pkg/utils/hash_test.go (2)

5-11: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Fix tautological comparison to avoid linter warnings.

Static analysis tools flag GenerateSha(in) != GenerateSha(in) as an identical sub-expression on both sides. To properly test determinism and appease linters, hash two distinct byte slices containing the same data.

♻️ Proposed fix
 	t.Run("is deterministic for identical input", func(t *testing.T) {
-		in := []byte(`{"flags":{"a":{"state":"ENABLED"}}}`)
-		if GenerateSha(in) != GenerateSha(in) {
+		in1 := []byte(`{"flags":{"a":{"state":"ENABLED"}}}`)
+		in2 := []byte(`{"flags":{"a":{"state":"ENABLED"}}}`)
+		if GenerateSha(in1) != GenerateSha(in2) {
 			t.Error("expected identical input to produce identical hashes")
 		}
 	})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@core/pkg/utils/hash_test.go` around lines 5 - 11, Update the “is
deterministic for identical input” subtest in TestGenerateSha to create two
distinct byte slices with identical contents, then compare GenerateSha results
for those separate inputs instead of passing the same slice expression twice.

Source: Linters/SAST tools


37-45: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Fix tautological comparison to avoid linter warnings.

Similar to the previous test, comparing GenerateSha([]byte("not json")) != GenerateSha([]byte("not json")) triggers static analysis warnings.

♻️ Proposed fix
 	t.Run("falls back to raw bytes for non-json", func(t *testing.T) {
 		if GenerateSha([]byte("not json")) == GenerateSha([]byte("also not json")) {
 			t.Error("expected distinct non-json payloads to hash differently")
 		}
 		// stable for identical non-json input
-		if GenerateSha([]byte("not json")) != GenerateSha([]byte("not json")) {
+		in1 := []byte("not json")
+		in2 := []byte("not json")
+		if GenerateSha(in1) != GenerateSha(in2) {
 			t.Error("expected identical non-json input to hash equally")
 		}
 	})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@core/pkg/utils/hash_test.go` around lines 37 - 45, Update the “falls back to
raw bytes for non-json” test to avoid calling GenerateSha with the identical
literal expression on both sides of the comparison. Compute the hash for the
repeated non-JSON payload once, then compare that stored result against a
separately computed hash to preserve the stability assertion without triggering
tautological-comparison warnings.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@core/pkg/utils/hash.go`:
- Around line 3-26: Update canonicalize to parse JSON with json.Decoder
configured with UseNumber(), preserving numeric token text during the subsequent
json.Marshal; retain the existing body fallback when decoding or
canonicalization fails, and keep GenerateSha unchanged.

---

Nitpick comments:
In `@core/pkg/utils/hash_test.go`:
- Around line 5-11: Update the “is deterministic for identical input” subtest in
TestGenerateSha to create two distinct byte slices with identical contents, then
compare GenerateSha results for those separate inputs instead of passing the
same slice expression twice.
- Around line 37-45: Update the “falls back to raw bytes for non-json” test to
avoid calling GenerateSha with the identical literal expression on both sides of
the comparison. Compute the hash for the repeated non-JSON payload once, then
compare that stored result against a separately computed hash to preserve the
stability assertion without triggering tautological-comparison warnings.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: faac19bd-e999-4786-8525-73ff0919758e

📥 Commits

Reviewing files that changed from the base of the PR and between 2ccb641 and 124c27e.

📒 Files selected for processing (4)
  • core/pkg/sync/blob/blob_sync.go
  • core/pkg/sync/http/http_sync.go
  • core/pkg/utils/hash.go
  • core/pkg/utils/hash_test.go

Comment thread core/pkg/utils/hash.go
Signed-off-by: Lea Konvalinka <lea.konvalinka@dynatrace.com>
@leakonvalinka leakonvalinka force-pushed the feat/dedupe-unchanged-payloads branch from 124c27e to 676cd4b Compare July 15, 2026 14:04
Signed-off-by: Lea Konvalinka <lea.konvalinka@dynatrace.com>
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
4.9% Duplication on New Code (required ≤ 3%)

See analysis details on SonarQube Cloud

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

Labels

size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants