Skip to content

fix(destination-postgres): strip NUL characters nested in JSON values - #84321

Open
devin-ai-integration[bot] wants to merge 3 commits into
masterfrom
devin/1786553022-postgres-nul-jsonb
Open

fix(destination-postgres): strip NUL characters nested in JSON values#84321
devin-ai-integration[bot] wants to merge 3 commits into
masterfrom
devin/1786553022-postgres-nul-jsonb

Conversation

@devin-ai-integration

Copy link
Copy Markdown
Contributor

What

Resolves https://github.com/airbytehq/oncall/issues/13292:

A sync on destination-postgres 3.0.16 failed during COPY with:

org.postgresql.util.PSQLException: ERROR: unsupported Unicode escape sequence
  Detail: <NULL> cannot be converted to text.
COPY ..., line 35, column entries: "[{"data":{"values":[{"type":"App\\Domain\\...

The failing column is a semistructured column mapped to jsonb, and Postgres' jsonb input function rejects \u0000 because it cannot be represented in Postgres text. The source record contained a NUL nested deep inside a JSON structure.

NUL sanitization only covered top-level StringValue (in PostgresValueCoercer.validate). Anything nested inside ObjectValue/ArrayValue was serialized untouched by AirbyteValueToCsvRow.toCsvValue(), so Jackson emitted the textual \u0000 escape straight into the CSV stream feeding a jsonb column.

The raw-path sanitizer added in #75902 was also ineffective: Jsons.writeValueAsString(record).replace("\u0000", "") replaces the actual NUL code point after Jackson has already escaped it as the six-character text \u0000, so nothing matched.

How

Sanitization now happens on the AirbyteValue tree, before any JSON serialization:

  • New connector-local sanitizePostgresValue walks ObjectValue/ArrayValue recursively and strips \u0000 from every nested StringValue, returning the original instance when nothing changed (this is a per-record hot path). Values only, not object keys — consistent with the existing removeNullCharacters expectation mappers in src/test-integration.
  • PostgresValueCoercer.map() sanitizes before the UnionType/UnknownType serializeToString() step, so serialization can no longer produce a \u0000 escape. validate() is now range/length validation only.
  • PostgresRawRecordFormatter sanitizes values before Jsons.writeValueAsString and drops the ineffective post-serialization replace. This path is kept sanitizing independently of the coercer because PostgresInsertBuffer can also be populated without going through the normal coercion pipeline.

The fix is intentionally connector-local: the bulk-load CDK ValueCoercer interface is destination-specific, so no Postgres-specific behavior was added to the shared CDK.

Stripping stays silent (no meta change / nullification), matching the connector's pre-existing behavior for top-level strings and the test-integration expected-record mappers.

Review guide

  1. write/transform/PostgresValueSanitizer.kt — new recursive sanitizer
  2. write/transform/PostgresValueCoercer.kt — sanitize in map(), remove strip from validate()
  3. write/load/PostgresRecordFormatter.kt — sanitize before serialization on the raw path
  4. src/test/.../PostgresRecordFormatterTest.kt, PostgresValueCoercerTest.kt

Test Coverage

New tests exercise the full serialize path and assert the absence of the textual \u0000 escape (asserting only the absence of an actual NUL passes vacuously, which is why the previous formatter test did not catch this):

  • PostgresValueCoercerTest.testMapRemovesNestedNullCharacters — NUL nested in an ObjectValue and inside an ArrayValue is gone after map(), asserted on the value itself.
  • PostgresRecordFormatterTest — a nested NUL routed through PostgresValueCoercer.map + PostgresSchemaRecordFormatter (jsonb column) and + PostgresRawRecordFormatter (_airbyte_data) produces output containing neither an actual NUL nor the \u0000 escape.

Verified each new test fails against the unmodified sources (3 failures) and passes with the fix; full connector unit suite ./gradlew :airbyte-integrations:connectors:destination-postgres:test is green.

Breaking change evaluation

Not breaking: no schema, spec, stream, PK/cursor or state change. Records that previously failed the whole COPY now land with NUL characters removed, which is the behavior already applied to top-level string fields. enableProgressiveRollout: true, so the version is 3.0.17-rc.1.

User Impact

Syncs no longer fail with unsupported Unicode escape sequence when source JSON contains NUL characters nested inside objects or arrays; those characters are removed from the written value.

Can this PR be safely reverted and rolled back?

  • YES 💚
  • NO ❌

Link to Devin session: https://app.devin.ai/sessions/41a50ff04f8f40978fff6d97f17cd4cb

Co-Authored-By: bot_apk <apk@cognition.ai>
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

Co-Authored-By: bot_apk <apk@cognition.ai>
@github-actions

Copy link
Copy Markdown
Contributor

👋 Greetings, Airbyte Team Member!

Here are some helpful tips and reminders for your convenience.

💡 Show Tips and Tricks

PR Slash Commands

Airbyte Maintainers (that's you!) can execute the following slash commands on your PR:

  • 🛠️ Quick Fixes
    • /format-fix - Fixes most formatting issues.
    • /bump-version - Bumps connector versions, scraping changelog description from the PR title.
      • Bump types: patch (default), minor, major, major_rc, rc, promote.
      • The rc type is a smart default: applies minor_rc if stable, or bumps the RC number if already RC.
      • The promote type strips the RC suffix to finalize a release.
      • Example: /bump-version type=rc or /bump-version type=minor
    • /bump-progressive-rollout-version - Alias for /bump-version type=rc. Bumps with an RC suffix and enables progressive rollout.
  • ❇️ AI Testing and Review (internal link: AI-SDLC Docs):
    • /ai-prove-fix - Runs prerelease readiness checks, including testing against customer connections.
    • /ai-canary-prerelease - Rolls out prerelease to 5-10 connections for canary testing.
    • /ai-review - AI-powered PR review for connector safety and quality gates.
  • 📝 AI Documentation:
    • /ai-docs-review - AI-powered documentation review for PRs with connector changes.
    • /ai-create-docs-pr - Creates a documentation PR for connector changes, stacked on the current PR.
  • 🚀 Connector Releases:
    • /publish-connectors-prerelease - Publishes pre-release connector builds (tagged as {version}-preview.{git-sha}) for all modified connectors in the PR.
    • /enable-autopilot-rollouts - Enables autopilot progressive rollouts for the modified connector(s) in the PR, remediating "autopilot rollouts not enabled for {connector-name}" auto-merge blockers. Sets defaultRolloutMode: autopilot and enableProgressiveRollout: true, preserving any existing autopilotConfig.
      • Optional args: connector=<CONNECTOR_NAME> (defaults to the modified connectors in the PR), strategy=fast|slow|default (defaults to fast).
      • Example: /enable-autopilot-rollouts or /enable-autopilot-rollouts connector=source-faker strategy=slow
  • ☕️ JVM connectors:
    • /update-connector-cdk-version connector=<CONNECTOR_NAME> - Updates the specified connector to the latest CDK version.
      Example: /update-connector-cdk-version connector=destination-bigquery
  • 🐍 Python connectors:
    • /poe connector source-example lock - Run the Poe lock task on the source-example connector, committing the results back to the branch.
    • /poe source example lock - Alias for /poe connector source-example lock.
    • /poe source example use-cdk-branch my/branch - Pin the source-example CDK reference to the branch name specified.
    • /poe source example use-cdk-latest - Update the source-example CDK dependency to the latest available version.
  • ⚙️ Admin commands:
    • /force-merge reason="<REASON>" - Force merges the PR using admin privileges, bypassing CI checks. Requires a reason.
      Example: /force-merge reason="CI is flaky, tests pass locally"
📚 Show Repo Guidance

Helpful Resources

📝 Edit this welcome message.

@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

destination-postgres Connector Test Results

202 tests   186 ✅  3m 27s ⏱️
 13 suites   16 💤
 13 files      0 ❌

Results for commit dbf87a0.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Deploy preview for airbyte-docs ready!

Project:airbyte-docs
Status: ✅  Deploy successful!
Preview URL:https://airbyte-docs-6in3urp04-airbyte-growth.vercel.app
Latest Commit:dbf87a0

Deployed with vercel-action

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

↪️ Triggering /ai-prove-fix per Hands-Free AI Triage Project triage next step.

Reason: Draft fix with all CI checks green; prove-fix validation is the next pipeline step for:
https://github.com/airbytehq/oncall/issues/13292

Devin session

@octavia-bot

octavia-bot Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

🔍 AI Prove Fix session starting... Running readiness checks and testing against customer connections. View playbook

Devin AI session created successfully!

@airbyte-support-bot

Airbyte Support Bot (airbyte-support-bot) commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

🟢 Fix Proven

Pre-release tested: 3.0.17-preview.1febc26 (publish run)
Baseline (known-bad): 3.0.16

An isolated A/B reproduction confirms the reported failure on 3.0.16 and its resolution on this PR's pre-release, across both code paths the PR changes. Data is sanitized surgically — only the NUL is removed.

Root cause confirmed

3.0.16 strips U+0000 only from top-level StringValues. A NUL nested inside an object/array value survives to serialization, where Jackson encodes it as the six-character escape \u0000; Postgres' jsonb input function then rejects it during COPY, aborting the whole batch. The pre-existing .replace("\u0000", "") in the raw formatter ran after serialization, so it was looking for a literal NUL byte that no longer existed by that point — which is why it never helped. This PR's recursive walk of the AirbyteValue tree runs before serialization, which is the part that matters.

Evidence

Local Postgres 15.18 in Docker, driven at the Airbyte protocol level. Byte-identical input to both images, differing only in target schema name. Three records: one with a NUL nested four levels deep in a jsonb-typed field, one clean, and one with a top-level NUL (already handled in 3.0.16 — negative control).

Path 1 — PostgresValueCoercer.map (typed final tables, default mode)

3.0.16 3.0.17-preview.1febc26
Exit code 1 0
Rows landed 0 3
Outcome PSQLException: unsupported Unicode escape sequence at PostgresAirbyteClient.copyFromCsv Completed successfully

Path 2 — PostgresRecordFormatter (raw _airbyte_data, via disable_type_dedupe: true)

3.0.16 3.0.17-preview.1febc26
Exit code 1 0
Rows landed 0 3
Outcome unsupported Unicode escape sequence on COPY ... column _airbyte_data Completed successfully
Data integrity on the target
-- final table, jsonb column
entries #>> '{0,data,values,0,value}'  →  prefix-NUL-ab-suffix

-- raw table, _airbyte_data
{"id": 1, "note": "clean", "entries": [{"data": {"values": [{"type": "App\\Domain\\Thing", "value": "prefix-NUL-ab-suffix"}]}}]}

prefix-NUL-a<NUL>b-suffixprefix-NUL-ab-suffix. The surrounding characters, the sibling type field with its escaped backslashes, the clean record, and the top-level-NUL record all survive unchanged. All 3 rows present in both modes — no truncation, no dropped fields, no dropped records.

Regression check

PyAirbyte destination smoke test against the pre-release: success=true, records_delivered=59, no scenario failures across the fast suite (which includes nested_json_objects, unicode_and_special_strings, null_handling, and union_types).

Pre-flight checks
Check Result
Viability ✅ Sanitizer runs before Jsons.writeValueAsString, which is exactly where the previous approach failed
Design intent ✅ Top-level-only stripping was an incomplete implementation of existing intent, not a deliberate limitation
Safety ✅ No external calls, credential access, obfuscation, or data exfiltration; confined to in-memory value transformation
Breaking changes ✅ None — no spec, schema, stream, primary-key, cursor, or state-format changes; object keys are deliberately left untouched
Reversibility ✅ Patch bump, progressive rollout enabled, changelog entry present; rollback to 3.0.16 is clean

What was not tested

No live customer sync was run. Approval to pin the pre-release on the reporting connection was requested but has not come back, so nothing was pinned. The isolated A/B stands on its own — it reproduces the exact failure on the baseline and shows it resolved on the pre-release — but it uses a synthetic record shaped like the reported payload rather than the original data.

One detail worth flagging for review: the recursive sanitizer returns the original instance when no descendant changed, so unaffected records avoid a full tree copy. That keeps the hot path allocation-free for the common case, but it does mean the returned value is sometimes the same object and sometimes a copy — fine given AirbyteValue is immutable, just worth a reviewer's eye.

Recommendation

Merge and roll out. Progressive rollout is already enabled. The reporting connection should recover on its next sync with no manual intervention — no state reset or backfill needed, since the failing COPY aborted as a unit and no partial data was written.

Customer-specific details are recorded privately in the linked oncall issue.

Session: https://app.devin.ai/sessions/472e51ec087646e090f7b230d0aacf9e

@airbyte-support-bot Airbyte Support Bot (airbyte-support-bot) added the hyd-prove Hydra: ai-prove-fix stage has run label Aug 13, 2026
@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Pre-release Connector Publish Started

Publishing pre-release build for connector destination-postgres.
PR: #84321

Pre-release versions will be tagged as {version}-preview.1febc26
and are available for version pinning via the scoped_configuration API.

View workflow run
⚠️ Pre-release Publish CANCELLED for destination-postgres.

@devin-ai-integration
devin-ai-integration Bot marked this pull request as ready for review August 13, 2026 12:22
@devin-ai-integration
devin-ai-integration Bot requested a review from a team as a code owner August 13, 2026 12:22
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

↪️ Triggering /ai-review per Hands-Free AI Triage Project triage next step.

Reason: /ai-prove-fix returned Fix Proven on current HEAD 1febc260 with an A/B reproduction across both changed code paths, and CI is green. AI review is the next pipeline step. Marked ready for review.
https://github.com/airbytehq/oncall/issues/13292

Devin session

@octavia-bot

octavia-bot Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

AI PR Review starting...

Reviewing PR for connector safety and quality.
View playbook

Devin AI session created successfully!

@airbyte-support-bot

Airbyte Support Bot (airbyte-support-bot) commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

🛡️ AI PR Review Report

🟢 Review Action: APPROVED

Gate Status
Per-Record Performance WARNING

All other gates passed.

🔶 Risk Level: 3/5

Logic change in the per-record write path of an existing connector (PostgresValueCoercer.map, PostgresRawRecordFormatter), with new unit tests and an A/B pre-release reproduction backing it.


📋 PR Details

Connector(s): destination-postgres
PR: #84321
HEAD SHA: 1febc260f78e68a1b66f93065a5962819099cc2f
Session: https://app.devin.ai/sessions/701c5f3ff8ec43efa7a15682609b52a6

🔍 Gate Evaluation Details

Gate-by-Gate Analysis

Gate Status Enforced? Details
PR Hygiene PASS Yes Description is substantive (What / How / Review guide / Test coverage / breaking-change evaluation); changelog row added in docs/integrations/destinations/postgres.md; no unresolved human review comments.
Code Hygiene PASS WARNING Source changes accompanied by test changes in src/test/.../PostgresValueCoercerTest.kt and new PostgresRecordFormatterTest.kt.
Test Coverage PASS Yes Bug fix with new test content: testMapRemovesNestedNullCharacters plus two new formatter tests asserting absence of both the NUL byte and the textual \u0000 escape.
Code Security PASS Yes No changed file matches auth/credential/secret/encryption path patterns; metadata.yaml diff hunk only changes dockerImageTag (no allowedHosts, connectorBuildOptions, or dockerRepository in added/removed lines).
Per-Record Performance WARNING WARNING sanitizePostgresValue adds a recursive walk of the AirbyteValue tree on every record. Mitigated by returning the original instance when nothing changed, but PostgresRawRecordFormatter wraps it in filteredRecord.mapValues { ... }, which allocates a fresh map per record even when no value changed. Non-blocking; worth a follow-up if raw-path throughput matters.
Breaking Dependencies PASS WARNING No build.gradle, gradle.properties, or CDK version changes in the diff.
Backwards Compatibility PASS Yes No spec, schema, stream, primary-key, cursor, or state-format change. metadata.yaml changes only dockerImageTag (3.0.163.0.17-rc.1); no releases.breakingChanges entry needed. Records that previously aborted the entire COPY now land with nested NULs removed — the same treatment already applied to top-level strings.
Forwards Compatibility PASS Warning (elevates Risk Level) No state/cursor/checkpoint/partition-router/paginator keywords in the diff hunks. This is a destination-side value transformation; rollback to 3.0.16 restores the prior behavior with no state or format migration.
Behavioral Changes PASS Warning (elevates Risk Level) No rate-limit, retry, backoff, timeout, or error-handler changes. The only match for limit is the pre-existing TEXT_LIMIT_BYTES constant in relocated length-validation code — the threshold value and its semantics are unchanged.
Out-of-Scope Changes PASS Skip All changes are under airbyte-integrations/connectors/destination-postgres/** and docs/integrations/destinations/postgres.md.
CI Checks PASS Yes Core checks green on this SHA: Test destination-postgres Connector, Lint destination-postgres Connector, Build and Verify Artifacts (destination-postgres), Format Check, Connector CI Checks Summary. Pre-release and rollout check-runs are excluded from this gate by design.
Live / E2E Tests PASS Yes Validation is required (bug fix touching sync-write behavior). /ai-prove-fix posted 🟢 Fix Proven on this HEAD: isolated A/B against pre-release 3.0.17-preview.1febc26 vs. baseline 3.0.16 reproduced the unsupported Unicode escape sequence failure and showed it resolved across both changed code paths (typed jsonb column and raw _airbyte_data), with 0 → 3 rows landed in each.

Notes on evidence scoping

  • Keyword matching for Code Security, Behavioral Changes, and Forwards Compatibility was applied to added/removed diff lines only, excluding comment lines.
  • /ai-prove-fix flagged one review-worthy detail: the sanitizer returns the original instance when no descendant changed, so the returned value is sometimes the same object and sometimes a copy. This is safe given AirbyteValue immutability, but it is the kind of thing a human reviewer should eyeball.
  • /ai-prove-fix also noted that no live customer sync was pinned — the proof rests on an isolated A/B with a synthetic record shaped like the reported payload, not the original data.
📚 Evidence Consulted

Evidence

  • Changed files: 7 (3 main sources, 2 test files, metadata.yaml, connector docs)
  • CI checks: Test destination-postgres Connector (success), Lint destination-postgres Connector (success), Build and Verify Artifacts (destination-postgres) (success), destination-postgres Pre-Release Checks (success), destination-postgres Progressive Rollout Gate (success), Format Check (success), Check Changelog Updated (success), Connector CI Checks Summary (success), CodeQL (neutral)
  • PR labels: connectors/destination/postgres, hyd-fix, hyd-prove, hyd-review
  • PR description: present (substantive)
  • Existing bot reviews: none for this HEAD SHA
  • /ai-prove-fix conclusion: 🟢 Fix Proven (https://app.devin.ai/sessions/472e51ec087646e090f7b230d0aacf9e)

Devin session

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🚫 Not fixing the per-record performance warning about filteredRecord.mapValues { ... } in PostgresRawRecordFormatter, but flagging it for a human reviewer.

The extra allocation is real but marginal in context: the immediately preceding record.filter { ... } already allocates a fresh map per record on this path (pre-existing behavior), so mapValues adds one more map of the same small size, whose entries are the original AirbyteValue instances when nothing changed. The dominant per-record cost here is the Jsons.writeValueAsString call right after it. Fusing filter+sanitize into a single pass would save one small map per raw record; I'd rather not churn this hot path in a fix PR that's meant to be minimal — happy to do it as a follow-up if raw-path throughput is a concern.

On the other flagged detail: sanitizePostgresValue returning the original instance when no descendant changed is intentional (avoids copying the whole tree for the common case) and safe because AirbyteValue implementations are immutable data classes.


Devin session

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

↪️ Triggering /ai-ready per Hands-Free AI Triage Project triage next step.

Reason: AI review returned APPROVE for the exact current HEAD 1febc260, /ai-prove-fix is Fix Proven with an A/B reproduction, the branch is not conflicted, there are no Changes Requested reviews, and no /ai-ready has ever run for this PR.
https://github.com/airbytehq/oncall/issues/13292

Devin session

@octavia-bot octavia-bot Bot added the hyd-ready Hydra: ready for human review or merge label Aug 13, 2026
@octavia-bot-hoard

octavia-bot-hoard Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Auto-merge evaluation: FAIL

Eligibility (all must pass)

Pass Check Reasoning
Airbyte Support Bot Assigned Airbyte Support Bot assignment confirms automated merge eligibility.
No Destination Changes Destination-related changes require human approval and merge.

Scope analysis was skipped because deterministic auto-merge eligibility did not pass.

Workflow run

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🙋 Escalated to #human-in-the-loop per Hands-Free AI Triage Project triage next step.

Reason: Every automated gate passed — /ai-prove-fix Fix Proven with an A/B reproduction, AI review APPROVE on current HEAD 1febc260, Airbyte Support Bot assigned — but the /ai-ready auto-merge evaluation returned FAIL on policy: destination changes require human approval and merge. A maintainer needs to review and merge this one; there is no further automated step. Linked issue: https://github.com/airbytehq/oncall/issues/13292

Devin session

Comment on lines +20 to +21
if ('\u0000' in value.value) {
SanitizedValue(StringValue(value.value.replace("\u0000", "")), true)

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.

why the if statement? is in cheaper than a no-op replace?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🙋 Your call — happy to simplify. The if isn't there for the cost of replace; it's there because I need the changed boolean, which is what lets parent ObjectValue/ArrayValue nodes skip copying their container when no descendant was modified. So the check does double duty as "did anything change here".

On the cost question specifically: you're right that the scan isn't buying much. '\u0000' in s is indexOf(Char), and I checked against the stdlib we build with (2.1.20) that a no-op String.replace returns the receiver itself rather than a copy:

String s = "hello world";
String r = kotlin.text.StringsKt.replace(s, "\u0000", "", false);  // identity == true

So the alternative would be to always call replace and derive changed from sanitized !== value.value. I avoided that because returning this is an implementation detail of the stdlib rather than a documented contract, and the explicit check reads more obviously. If you'd prefer the shorter version (or an indexOf result reused for the replace), say so and I'll push it.


Devin session

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.

what if we compare the length and not the whole value?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

👍 On it. Length comparison works and is cheaper than the pre-scan — removing NULs is the only edit, so the length differs exactly when something was stripped. Switching to a single replace plus sanitized.length != value.value.length for the changed flag, and I'll re-run the connector unit tests before pushing.


Devin session

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

☑️ Resolved in dbf87a0. Single replace now, with changed derived from sanitizedValue.length != value.value.length; the pre-scan is gone. Connector unit tests still pass locally (:airbyte-integrations:connectors:destination-postgres:test — BUILD SUCCESSFUL) and spotless is clean.


Devin session

Co-Authored-By: bot_apk <apk@cognition.ai>
Comment on lines -49 to -50
// Sanitize null bytes from JSON data — PostgreSQL TEXT columns do not support \u0000
val jsonData = Jsons.writeValueAsString(filteredRecord).replace("\u0000", "")

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.

why didn't this work before?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🚫 No change needed — answering. It ran too late. By the time Jsons.writeValueAsString(...) returns, Jackson has already encoded the NUL as the six-character escape sequence \u0000 (backslash, u, 0000), so there is no literal NUL code point left in the string for .replace("\u0000", "") to match. The replace silently matched nothing on every record.

Quick demonstration with the Jackson version we build against:

input:  {"a": "x<NUL>y"}          // actual U+0000 in the value
output: {"a":"x\u0000y"}          // six literal characters, zero NUL bytes

And \u0000 in that form is precisely what Postgres' jsonb input function rejects (unsupported Unicode escape sequence / <NULL> cannot be converted to text), which is the failure in the linked issue. Sanitizing the AirbyteValue tree before serialization is what makes it effective, and the new PostgresRecordFormatterTest asserts the absence of both the NUL byte and the textual escape so this can't silently regress to a no-op again.


Devin session

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.

shouldn't there be a way to use replace with the string value instead of the byte value?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🙋 There is, but I'd rather not — your call. You're right that .replace("\\u0000", "") on the serialized text would actually match, unlike the old code. Two reasons I went the other way:

  1. It can corrupt valid JSON. If a source string legitimately contains the six characters \u0000 as text (backslash included), Jackson escapes the backslash and emits seven characters: \\u0000. A textual replace of \u0000 matches the tail of that and leaves a dangling \, producing invalid JSON. Operating on the AirbyteValue tree has no such ambiguity — we edit the actual string content, and Jackson escapes whatever remains correctly.

  2. It only covers the raw path. The typed path doesn't serialize the whole record; each jsonb column is serialized independently (PostgresValueCoercer.maptoCsvValue) and the result is written into a CSV row. A post-serialization fixup would have to be repeated there, on strings already mixed with CSV quoting. Sanitizing before serialization is one place that covers both paths and keeps the typed and raw tables consistent.

If you'd still prefer the textual replace on the raw path, I'll switch it — but I'd want to keep the pre-serialization sanitizer for the typed path regardless, so it would be two mechanisms instead of one.


Devin session

@airbyte-support-bot

Copy link
Copy Markdown
Contributor

↪️ Triggering /ai-review per Hands-Free AI Triage Project triage next step.

Reason: A new commit (dbf87a0e, 2026-08-13 18:19 UTC) landed after the last APPROVE review marker (1febc260), so the approval is stale and review must be refreshed before readiness.

Devin session

@octavia-bot

octavia-bot Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

AI PR Review starting...

Reviewing PR for connector safety and quality.
View playbook

Devin AI session created successfully!

@airbyte-support-bot

Airbyte Support Bot (airbyte-support-bot) commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

🛡️ AI PR Review Report

🟢 Review Action: APPROVED

Gate Status
Per-Record Performance WARNING

All other gates passed.

🔶 Risk Level: 3/5

Logic change in the per-record write path of an existing connector (PostgresValueCoercer.map, PostgresRawRecordFormatter), backed by new unit tests and an A/B pre-release reproduction; no spec, schema, stream, PK/cursor or state change.


📋 PR Details

Connector(s): destination-postgres
PR: #84321
HEAD SHA: dbf87a0ee1ce6540118b72d501e6b8b4eef6231f
Session: https://app.devin.ai/sessions/727edadb82ea4e2aa1569a62254a3072
Re-run: yes — the prior APPROVE marker was for 1febc260; this review re-evaluates all gates against dbf87a0e.

🔍 Gate Evaluation Details

Gate-by-Gate Analysis

Gate Status Enforced? Details
PR Hygiene PASS Yes Description ~3.5k chars with What/How/Review guide/Test coverage/breaking-change sections; changelog row added in docs/integrations/destinations/postgres.md; both human review threads from mwbayley have author replies.
Code Hygiene PASS WARNING Source changes accompanied by test changes in src/test/.../PostgresRecordFormatterTest.kt and PostgresValueCoercerTest.kt.
Test Coverage PASS Yes Bug-fix PR with new test content: testMapRemovesNestedNullCharacters plus two new formatter tests asserting absence of the textual \u0000 escape.
Code Security PASS Yes No changed path matches auth/token/credential/secret patterns; metadata.yaml diff hunks contain only dockerImageTag (no allowedHosts, dockerRepository, connectorBuildOptions).
Per-Record Performance WARNING WARNING PostgresRawRecordFormatter adds a mapValues { sanitizePostgresValue(...) } pass per record on top of the pre-existing record.filter { ... }. See note below.
Breaking Dependencies PASS WARNING No build.gradle/gradle.properties/dependency version changes in the diff.
Backwards Compatibility PASS Yes (enforced) metadata.yaml change is dockerImageTag: 3.0.16 → 3.0.17-rc.1 only — no spec, schemas/*.json, stream, PK/cursor or format/type change, and no code path that shapes a catalog schema. Records that previously aborted the whole COPY now land with NULs stripped, which is the behavior already applied to top-level strings — no data-scope reduction.
Forwards Compatibility PASS Warning (elevates Risk Level) No state/cursor/partition/pagination keywords in diff hunks; destination-side value sanitization does not persist state. write/transform appears only as a package/import path, not a transformations config change.
Behavioral Changes PASS Warning (elevates Risk Level) No rate-limit, retry, backoff or timeout changes. The only limit occurrence is the pre-existing TEXT_LIMIT_BYTES length check in PostgresValueCoercer.validate(), whose threshold and semantics are unchanged (the diff only reads abValue.value instead of the re-cast value.abValue).
Out-of-Scope Changes PASS Skip All 7 changed files are under airbyte-integrations/connectors/destination-postgres/** or docs/**.
CI Checks PASS Yes All core check-runs on dbf87a0e are success (incl. Test destination-postgres Connector, Lint destination-postgres Connector, Build and Verify Artifacts, Format Check, Check Changelog Updated); connector test results 202 tests / 0 failures. No core check failed, cancelled or pending.
Live / E2E Tests PASS Yes Validation required (bug fix + sync-behavior write path). /ai-prove-fix posted 🟢 Fix Proven for pre-release 3.0.17-preview.1febc26 with an isolated A/B reproduction against baseline 3.0.16 across both changed code paths. See the SHA-delta note below.

Detail notes

Live / E2E — prove-fix SHA vs. current HEAD. The prove-fix conclusion was published for 1febc260. The only change between 1febc260 and dbf87a0e is inside PostgresValueSanitizer.kt, replacing the '\u0000' in value.value pre-scan with a single replace plus a sanitizedValue.length != value.value.length comparison to derive the changed flag (a mwbayley review request). The stripping operation and its output are identical; only how "did anything change" is detected differs, and that is covered by the new unit tests. The proven behavior therefore still applies to this HEAD.

Per-Record Performance (WARNING, non-blocking). On the raw path, filteredRecord.mapValues { sanitizePostgresValue(it) } allocates one additional small map per record. The author declined to fuse filter+sanitize into a single pass in this fix PR (comment) and flagged it for a human; Jsons.writeValueAsString dominates this path, and the sanitizer returns the original instance when no descendant changed, so the common case copies nothing. The dbf87a0e refactor also removed the extra string pre-scan, reducing per-string work versus the previously reviewed revision. This does not block APPROVE.

📚 Evidence Consulted

Evidence

  • Changed files: 7 (metadata.yaml, PostgresRecordFormatter.kt, PostgresValueCoercer.kt, new PostgresValueSanitizer.kt, new PostgresRecordFormatterTest.kt, PostgresValueCoercerTest.kt, docs/integrations/destinations/postgres.md)
  • CI checks on dbf87a0e: all core checks success; destination-postgres Pre-Release Checks success, destination-postgres Progressive Rollout Gate success, CodeQL neutral (excluded), pre-release/rollout/PR-title checks excluded from this gate
  • PR labels: connectors/destination/postgres, hyd-review, hyd-fix, hyd-prove, hyd-ready — no validation-override labels used
  • PR description: present (~3.5k chars, all template sections filled)
  • Human review threads: 2 from mwbayley, both with author replies (one ☑️ Resolved in dbf87a0, one 🙋 awaiting maintainer preference on sanitizing the serialized text instead) — unresolved-but-answered, so PR Hygiene passes; the open 🙋 question is a maintainer judgement call, not a gate failure
  • Existing bot reviews: prior APPROVE marker for 1febc260 (now stale); no REQUEST CHANGES review for dbf87a0e
  • /ai-prove-fix: 🟢 Fix Proven (comment)

@airbyte-support-bot

Copy link
Copy Markdown
Contributor

↪️ Triggering /ai-ready per Hands-Free AI Triage Project triage next step.

Reason: the refreshed /ai-review returned APPROVE at the current head dbf87a0e (the previous marker 1febc260 was stale), no checks are failing or pending, the PR is not DIRTY, and no /ai-ready has run for this head.


Devin session

@octavia-bot-hoard

octavia-bot-hoard Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Auto-merge evaluation: FAIL

Eligibility (all must pass)

Pass Check Reasoning
Airbyte Support Bot Assigned Airbyte Support Bot assignment confirms automated merge eligibility.
No Destination Changes Destination-related changes require human approval and merge.

Scope analysis was skipped because deterministic auto-merge eligibility did not pass.

Workflow run

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

Labels

connectors/destination/postgres hyd-fix Hydra: ai-fix stage has run hyd-prove Hydra: ai-prove-fix stage has run hyd-ready Hydra: ready for human review or merge hyd-review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants