From 4e16e385abc857c26e08a763897e3d4d807df617 Mon Sep 17 00:00:00 2001 From: hongming Date: Sun, 24 May 2026 03:31:42 -0700 Subject: [PATCH] fix(memory-plugin): emit JSON literal "null" for nil metadata/propagation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit URGENT — fixes a production regression introduced by #1794 hitting a latent bug in the plugin's marshalMetadata helper. Symptom: every POST /workspaces/:id/memories on tenants running the post-#1794 image returns HTTP 500. Tenant logs show: Commit memory error (plugin): memory-plugin: internal: commit memory: pq: invalid input syntax for type json Same error fires for UpsertNamespace called from the Phase A2 backfill (cmd/memory-backfill), causing the client's circuit breaker to open after 3 failures and rejecting EVERY subsequent plugin call for that process. Root cause: marshalMetadata returned Go nil for nil-map input. lib/pq sends Go nil as a bytea-typed NULL placeholder. PostgreSQL refused to implicitly cast that placeholder into the target jsonb column (memory_namespaces.metadata + memory_records.propagation), so the INSERT failed at parse time. Direct `INSERT ... metadata=NULL` works fine from psql, so the bug is purely on the driver-binding side — sending the JSON literal `null` keeps the parameter typed as a valid jsonb value regardless of pq's auto-conversion heuristics. Existing sqlmock tests use sqlmock.AnyArg() for the metadata column, so they didn't catch the real-postgres type mismatch. Verified: - All four production tenants on staging-39c8618 currently FAIL every POST /memories with the JSON syntax error - Direct `INSERT INTO memory_plugin.memory_namespaces ... NULL` from psql succeeds — confirms PostgreSQL is fine with NULL; only the pq-binding path is broken - pgplugin unit test updated to pin the new contract: nil input → `[]byte("null")` Once this lands + image rebuilds + tenants recycle: - POST /memories writes will succeed (closes the #1794 regression) - Phase A2 backfill via /memory-backfill will succeed (closes #1791 step 2) Closes the production regression. Phase A2 backfill is now unblocked. --- .../internal/memory/pgplugin/store.go | 17 ++++++++++++++++- .../internal/memory/pgplugin/store_test.go | 11 +++++++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/workspace-server/internal/memory/pgplugin/store.go b/workspace-server/internal/memory/pgplugin/store.go index 3bb6ad2a1..61afaf42f 100644 --- a/workspace-server/internal/memory/pgplugin/store.go +++ b/workspace-server/internal/memory/pgplugin/store.go @@ -349,8 +349,23 @@ func scanMemory(row interface { } func marshalMetadata(m map[string]interface{}) ([]byte, error) { + // Returning Go nil here previously made lib/pq send the parameter + // as a bytea-typed NULL placeholder that PostgreSQL refused to + // implicitly cast into the jsonb column. The resulting INSERT + // failed with `pq: invalid input syntax for type json`, which + // surfaced first as "scan namespace" errors from the QueryRow + // path (the row never gets created so RETURNING produces no rows; + // the QueryRow error propagates through Scan). Caught 2026-05-24 + // during the Phase A2 backfill — UpsertNamespace failed on every + // call, then the client's circuit breaker opened and ALL plugin + // writes started failing. The same hazard exists on the + // memory_records.propagation column (also marshalled via this + // helper). Sending the JSON literal `null` keeps the parameter + // typed as a valid jsonb value regardless of pq's auto-conversion + // heuristics — semantically identical to NULL for our consumers + // (they treat empty propagation/metadata as absent). if m == nil { - return nil, nil + return []byte(`null`), nil } b, err := json.Marshal(m) if err != nil { diff --git a/workspace-server/internal/memory/pgplugin/store_test.go b/workspace-server/internal/memory/pgplugin/store_test.go index ac025f3f4..e05796cf5 100644 --- a/workspace-server/internal/memory/pgplugin/store_test.go +++ b/workspace-server/internal/memory/pgplugin/store_test.go @@ -17,12 +17,19 @@ import ( // --- marshalMetadata corner cases --- func TestMarshalMetadata_Nil(t *testing.T) { + // Post-fix (2026-05-24): nil input must return the JSON literal + // `null` (4 bytes), NOT Go nil. Returning Go nil made lib/pq send + // a bytea NULL parameter that PostgreSQL refused to implicitly + // cast into the target jsonb column, breaking every UpsertNamespace + // and CommitMemory call with `pq: invalid input syntax for type + // json`. The Phase A2 backfill exposed it (production saw every + // POST /memories return 500 immediately post-tenant-recycle). got, err := marshalMetadata(nil) if err != nil { t.Errorf("err = %v", err) } - if got != nil { - t.Errorf("got = %v, want nil", got) + if string(got) != "null" { + t.Errorf("got = %q (%v), want %q", string(got), got, "null") } } -- 2.52.0