Skip to content

feat(spanner): add native read-only transactional support#3616

Open
anubhav756 wants to merge 3 commits into
anubhav-readonly-corefrom
anubhav-readonly-spanner
Open

feat(spanner): add native read-only transactional support#3616
anubhav756 wants to merge 3 commits into
anubhav-readonly-corefrom
anubhav-readonly-spanner

Conversation

@anubhav756

@anubhav756 anubhav756 commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Overview

This PR implements read-only transactional support for Cloud Spanner.

Changes

  • Updates the core execution logic to route queries through spanner.Single().Query() when connected in read-only mode
    • This guarantees the execution is within absolute read-only transaction boundaries.
  • Includes a vulnerability integration test to prove that write queries are fundamentally rejected at the client/API layer when operating under this configuration, preventing agent bypass.

@anubhav756 anubhav756 self-assigned this Jul 14, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

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.

Code Review

This pull request introduces a read-only mode for the Spanner data source and its tools, adding configuration options, documentation, and validation. The review feedback highlights two key issues: first, a bug in the conflict validation logic where omitting the legacy readOnly field triggers a false conflict with readOnlyHint: true; second, the need to fail fast with a clear error message when write operations are attempted on a read-only source to prevent Spanner API errors.

Comment thread internal/tools/spanner/spannerexecutesql/spannerexecutesql.go
Comment thread internal/sources/spanner/spanner.go
@anubhav756 anubhav756 force-pushed the anubhav-readonly-core branch 2 times, most recently from a975dc7 to efeaaa2 Compare July 14, 2026 07:31
@anubhav756 anubhav756 force-pushed the anubhav-readonly-spanner branch from 4085da4 to cacd2bd Compare July 14, 2026 07:32
@anubhav756 anubhav756 force-pushed the anubhav-readonly-core branch from efeaaa2 to c638838 Compare July 14, 2026 07:36
@anubhav756 anubhav756 force-pushed the anubhav-readonly-spanner branch from cacd2bd to 3de0265 Compare July 14, 2026 08:13
@anubhav756

Copy link
Copy Markdown
Contributor Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

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.

Code Review

This pull request introduces a read-only mode configuration (SPANNER_READONLY) for the Spanner data source and its tools, ensuring write operations are blocked and queries are executed on read-only endpoints when enabled. Feedback on the changes includes simplifying a redundant boolean condition in the query execution path and correcting the newly added unit tests, which currently lack assertions on the overridden read-only configuration and contain dead code.

return nil, fmt.Errorf("cannot execute write operations when the source is configured in read-only mode")
}

if readOnly || s.ReadOnly {

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.

medium

The condition readOnly || s.ReadOnly is redundant and can be simplified to just readOnly. Since any case where !readOnly && s.ReadOnly is true is already handled and rejected with an error on line 182, we are guaranteed that if s.ReadOnly is true, readOnly must also be true. Simplifying this condition improves readability and avoids potential confusion for future maintainers.

Suggested change
if readOnly || s.ReadOnly {
if readOnly {

Comment on lines +99 to +183
func TestInitialize_ReadOnlyValidation(t *testing.T) {
ctx := context.Background()

ptr := func(b bool) *bool { return &b }

tcs := []struct {
desc string
cfg spannerexecutesql.Config
wantErr bool
errContains string
}{
{
desc: "no conflict - both true",
cfg: spannerexecutesql.Config{
ConfigBase: tools.ConfigBase{Name: "test-tool", Description: "desc"},
ReadOnly: true,
Annotations: &tools.ToolAnnotations{ReadOnlyHint: ptr(true)},
},
wantErr: false,
},
{
desc: "no conflict - both false",
cfg: spannerexecutesql.Config{
ConfigBase: tools.ConfigBase{Name: "test-tool", Description: "desc"},
ReadOnly: false,
Annotations: &tools.ToolAnnotations{ReadOnlyHint: ptr(false)},
},
wantErr: false,
},
{
desc: "no conflict - readOnlyHint nil",
cfg: spannerexecutesql.Config{
ConfigBase: tools.ConfigBase{Name: "test-tool", Description: "desc"},
ReadOnly: true,
Annotations: &tools.ToolAnnotations{ReadOnlyHint: nil},
},
wantErr: false,
},
{
desc: "precedence - readOnly false, readOnlyHint true overrides to true",
cfg: spannerexecutesql.Config{
ConfigBase: tools.ConfigBase{Name: "test-tool", Description: "desc"},
ReadOnly: false,
Annotations: &tools.ToolAnnotations{ReadOnlyHint: ptr(true)},
},
wantErr: false,
},
{
desc: "precedence - readOnly true, readOnlyHint false overrides to false",
cfg: spannerexecutesql.Config{
ConfigBase: tools.ConfigBase{Name: "test-tool", Description: "desc"},
ReadOnly: true,
Annotations: &tools.ToolAnnotations{ReadOnlyHint: ptr(false)},
},
wantErr: false,
},
}

for _, tc := range tcs {
t.Run(tc.desc, func(t *testing.T) {
_, err := tc.cfg.Initialize(ctx)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error, got nil")
}
// Use manual substring check since strings import might not be present
errStr := err.Error()
found := false
for i := 0; i <= len(errStr)-len(tc.errContains); i++ {
if errStr[i:i+len(tc.errContains)] == tc.errContains {
found = true
break
}
}
if !found {
t.Errorf("expected error to contain %q, got: %v", tc.errContains, err)
}
} else {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
})
}
}

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.

medium

The test TestInitialize_ReadOnlyValidation does not actually assert that the ReadOnly configuration is correctly overridden by the annotations. Since all test cases have wantErr: false, the test would pass even if the override logic was completely missing. Additionally, the test contains dead code (wantErr and errContains fields) and an unidiomatic manual substring search loop.

We should rewrite the test to assert the expected ReadOnly value on the initialized tool using ToConfig() and clean up the unused fields.

func TestInitialize_ReadOnlyValidation(t *testing.T) {
	ctx := context.Background()

	ptr := func(b bool) *bool { return &b }

	tcs := []struct {
		desc         string
		cfg          spannerexecutesql.Config
		wantReadOnly bool
	}{
		{
			desc: "no conflict - both true",
			cfg: spannerexecutesql.Config{
				ConfigBase:  tools.ConfigBase{Name: "test-tool", Description: "desc"},
				ReadOnly:    true,
				Annotations: &tools.ToolAnnotations{ReadOnlyHint: ptr(true)},
			},
			wantReadOnly: true,
		},
		{
			desc: "no conflict - both false",
			cfg: spannerexecutesql.Config{
				ConfigBase:  tools.ConfigBase{Name: "test-tool", Description: "desc"},
				ReadOnly:    false,
				Annotations: &tools.ToolAnnotations{ReadOnlyHint: ptr(false)},
			},
			wantReadOnly: false,
		},
		{
			desc: "no conflict - readOnlyHint nil",
			cfg: spannerexecutesql.Config{
				ConfigBase:  tools.ConfigBase{Name: "test-tool", Description: "desc"},
				ReadOnly:    true,
				Annotations: &tools.ToolAnnotations{ReadOnlyHint: nil},
			},
			wantReadOnly: true,
		},
		{
			desc: "precedence - readOnly false, readOnlyHint true overrides to true",
			cfg: spannerexecutesql.Config{
				ConfigBase:  tools.ConfigBase{Name: "test-tool", Description: "desc"},
				ReadOnly:    false,
				Annotations: &tools.ToolAnnotations{ReadOnlyHint: ptr(true)},
			},
			wantReadOnly: true,
		},
		{
			desc: "precedence - readOnly true, readOnlyHint false overrides to false",
			cfg: spannerexecutesql.Config{
				ConfigBase:  tools.ConfigBase{Name: "test-tool", Description: "desc"},
				ReadOnly:    true,
				Annotations: &tools.ToolAnnotations{ReadOnlyHint: ptr(false)},
			},
			wantReadOnly: false,
		},
	}

	for _, tc := range tcs {
		t.Run(tc.desc, func(t *testing.T) {
			tool, err := tc.cfg.Initialize(ctx)
			if err != nil {
				t.Fatalf("unexpected error: %v", err)
			}
			gotCfg, ok := tool.ToConfig().(spannerexecutesql.Config)
			if !ok {
				t.Fatalf("expected Config type, got %T", tool.ToConfig())
			}
			if gotCfg.ReadOnly != tc.wantReadOnly {
				t.Errorf("expected ReadOnly to be %t, got %t", tc.wantReadOnly, gotCfg.ReadOnly)
			}
		})
	}
}
References
  1. For mock test tools, kebab-case naming is acceptable to maintain consistency with existing legacy integration tests, even if the general style guide recommends snake_case.

@anubhav756 anubhav756 marked this pull request as ready for review July 14, 2026 13:29
@anubhav756 anubhav756 requested review from a team as code owners July 14, 2026 13:29
@anubhav756 anubhav756 assigned Yuan325 and unassigned anubhav756 Jul 14, 2026
@anubhav756 anubhav756 force-pushed the anubhav-readonly-spanner branch from edea0e0 to 3308f65 Compare July 15, 2026 07:56
@anubhav756 anubhav756 force-pushed the anubhav-readonly-spanner branch from 3308f65 to a016562 Compare July 15, 2026 08:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants