Skip to content

fix: capture ParameterDescription to resolve timestamp OIDs for EXPLAIN#25

Merged
mickamy merged 12 commits intomainfrom
fix/timestamp
Mar 7, 2026
Merged

fix: capture ParameterDescription to resolve timestamp OIDs for EXPLAIN#25
mickamy merged 12 commits intomainfrom
fix/timestamp

Conversation

@mickamy
Copy link
Copy Markdown
Owner

@mickamy mickamy commented Mar 7, 2026

No description provided.

Copilot AI review requested due to automatic review settings March 7, 2026 01:02
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR improves PostgreSQL extended-query parameter decoding by capturing server-resolved parameter OIDs (via ParameterDescription) so binary-encoded timestamps can be decoded into RFC3339 strings (avoiding EXPLAIN failures caused by out-of-range date/time values).

Changes:

  • Capture Describe + ParameterDescription messages and store authoritative parameter OIDs for statements.
  • Add test-only exports for decodeBinaryParam and timestamp OID constants.
  • Add unit tests for DecodeBinaryParam timestamp vs non-timestamp decoding behavior.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.

File Description
proxy/postgres/conn.go Tracks Describe and consumes ParameterDescription to update parameter OIDs; introduces a mutex for cross-goroutine access to OID state.
proxy/postgres/export_test.go Exposes decodeBinaryParam and timestamp OID constants to external tests.
proxy/postgres/conn_test.go Adds unit tests covering DecodeBinaryParam behavior for timestamp OIDs and common integer lengths.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread proxy/postgres/conn.go Outdated
Comment on lines +348 to +351
c.lastParamOIDs = m.ParameterOIDs
if c.lastDescribeStmt != "" {
c.preparedStmtOIDs[c.lastDescribeStmt] = m.ParameterOIDs
}
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

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

handleParameterDescription always overwrites c.lastParamOIDs, even when the ParameterDescription is for a named prepared statement. Since handleBind uses lastParamOIDs as the fallback for PreparedStatement == "", a ParameterDescription for some other statement can race in and cause unnamed binds to use the wrong OIDs (and thus decode parameters incorrectly). One fix is to only update lastParamOIDs when the described statement name is empty (unnamed statement), and otherwise update only preparedStmtOIDs for the corresponding statement (ideally using an ordered pending-describe queue).

Suggested change
c.lastParamOIDs = m.ParameterOIDs
if c.lastDescribeStmt != "" {
c.preparedStmtOIDs[c.lastDescribeStmt] = m.ParameterOIDs
}
// If lastDescribeStmt is empty, this ParameterDescription applies to the
// unnamed prepared statement; update lastParamOIDs, which is used as the
// fallback for unnamed binds.
if c.lastDescribeStmt == "" {
c.lastParamOIDs = m.ParameterOIDs
return
}
// Otherwise, this ParameterDescription applies to a named prepared
// statement; record its OIDs in preparedStmtOIDs without touching
// lastParamOIDs (which should remain associated with the unnamed stmt).
c.preparedStmtOIDs[c.lastDescribeStmt] = m.ParameterOIDs

Copilot uses AI. Check for mistakes.
Comment thread proxy/postgres/export_test.go Outdated
Comment on lines +12 to +13
OidTimestamp = oidTimestamp
OidTimestampTZ = oidTimestampTZ
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

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

Exported identifiers for initialisms are typically all-caps in this codebase (e.g., TxID, preparedStmtOIDs). For consistency, consider renaming OidTimestamp / OidTimestampTZ to OIDTimestamp / OIDTimestampTZ (and updating the tests accordingly).

Suggested change
OidTimestamp = oidTimestamp
OidTimestampTZ = oidTimestampTZ
OIDTimestamp = oidTimestamp
OIDTimestampTZ = oidTimestampTZ

Copilot uses AI. Check for mistakes.
Comment thread proxy/postgres/conn.go
Comment on lines +333 to +337
func (c *conn) handleDescribe(m *pgproto.Describe) {
if m.ObjectType == 'S' {
c.stmtMu.Lock()
c.lastDescribeStmt = m.Name
c.stmtMu.Unlock()
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

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

lastDescribeStmt stores only a single statement name, but ParameterDescription responses arrive asynchronously from the upstream goroutine and PostgreSQL allows pipelining multiple Describe('S') messages. If multiple Describe(Statement) messages are in-flight, lastDescribeStmt can be overwritten before the first ParameterDescription arrives, causing OIDs to be associated with the wrong prepared statement. Consider tracking a FIFO queue of pending described statement names (push in handleDescribe, pop in handleParameterDescription) so responses are matched to the correct statement.

Copilot uses AI. Check for mistakes.
Copilot AI review requested due to automatic review settings March 7, 2026 01:12
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 5 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread proxy/postgres/conn.go
Comment on lines +336 to +366
func (c *conn) handleDescribe(m *pgproto.Describe) {
if m.ObjectType == 'S' {
c.stmtMu.Lock()
c.pendingDescribes = append(c.pendingDescribes, m.Name)
c.stmtMu.Unlock()
}
}

// handleParameterDescription captures the server-resolved parameter OIDs
// returned by the upstream in response to a Describe(Statement) message.
// These OIDs are authoritative — they override the OIDs from Parse, which
// are often all zeros (meaning "let the server decide").
// Responses arrive in the same order as the corresponding Describe requests,
// so we pop from the front of pendingDescribes to match them.
func (c *conn) handleParameterDescription(m *pgproto.ParameterDescription) {
c.stmtMu.Lock()
defer c.stmtMu.Unlock()

if len(c.pendingDescribes) == 0 {
return
}
name := c.pendingDescribes[0]
c.pendingDescribes = c.pendingDescribes[1:]

if name == "" {
// Unnamed statement: update the fallback OIDs used by unnamed binds.
c.lastParamOIDs = m.ParameterOIDs
} else {
// Named statement: only update its entry without touching lastParamOIDs.
c.preparedStmtOIDs[name] = m.ParameterOIDs
}
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

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

pendingDescribes is only advanced when a ParameterDescription arrives. If the upstream returns an ErrorResponse for a Describe('S') (e.g., unknown statement), there will be no ParameterDescription, leaving a stale entry in the FIFO and causing the next ParameterDescription to be associated with the wrong statement. Consider clearing (or popping) pending describe entries on relevant ErrorResponse/sync boundaries (e.g., in handleErrorResponse under stmtMu) to keep the queue in sync and avoid mis-decoding subsequent bind params.

Copilot uses AI. Check for mistakes.
Comment thread proxy/postgres/conn.go
Comment on lines +344 to +366
// handleParameterDescription captures the server-resolved parameter OIDs
// returned by the upstream in response to a Describe(Statement) message.
// These OIDs are authoritative — they override the OIDs from Parse, which
// are often all zeros (meaning "let the server decide").
// Responses arrive in the same order as the corresponding Describe requests,
// so we pop from the front of pendingDescribes to match them.
func (c *conn) handleParameterDescription(m *pgproto.ParameterDescription) {
c.stmtMu.Lock()
defer c.stmtMu.Unlock()

if len(c.pendingDescribes) == 0 {
return
}
name := c.pendingDescribes[0]
c.pendingDescribes = c.pendingDescribes[1:]

if name == "" {
// Unnamed statement: update the fallback OIDs used by unnamed binds.
c.lastParamOIDs = m.ParameterOIDs
} else {
// Named statement: only update its entry without touching lastParamOIDs.
c.preparedStmtOIDs[name] = m.ParameterOIDs
}
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

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

New behavior captures server-resolved parameter OIDs via Describe/ParameterDescription, but there are no tests asserting that (a) unnamed binds pick up ParameterDescription OIDs and (b) named prepared statements override per-statement OIDs. Adding a small protocol-level/unit test around handleDescribe + handleParameterDescription + handleBind would prevent regressions where timestamps fall back to OID=0 and decode incorrectly.

Copilot uses AI. Check for mistakes.
Comment thread .github/workflows/ci.yml
branches:
- "**"
tags:
- "v*"
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

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

CI no longer runs for pull requests because the pull_request trigger was removed and there is no other workflow in .github/workflows/ that triggers on PRs. This will prevent required checks from running before merge. Re-add a pull_request trigger (or document/implement an alternative PR validation workflow) to keep PR validation in place.

Suggested change
- "v*"
- "v*"
pull_request:
branches:
- "**"

Copilot uses AI. Check for mistakes.
Comment thread .github/workflows/ci.yml Outdated
Comment on lines +19 to +33
- uses: actions/setup-go@v5
with:
go-version: "1.25"
go-version: 1.26

- name: golangci-lint
uses: golangci/golangci-lint-action@v9

test:
runs-on: ubuntu-latest
strategy:
matrix:
go-version: [ "1.25", "1.26" ]
steps:
- uses: actions/checkout@v6

- uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go-version }}
go-version: 1.26
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

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

In GitHub Actions YAML, unquoted values like go-version: 1.26 are parsed as numbers. Quoting the version (e.g., "1.26") avoids edge cases where YAML numeric parsing can change the value (notably for versions like 1.10) and matches the convention used in release.yml.

Copilot uses AI. Check for mistakes.
Comment thread go.mod
module github.com/mickamy/sql-tap

go 1.25.0
go 1.26.1
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

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

The PR title is specific to PostgreSQL ParameterDescription handling, but this change also bumps the module Go version (and separately modifies CI/docs). This makes the PR harder to review and can complicate rollback. Consider splitting the Go/tooling/docs changes into separate PR(s) unless they are required for the protocol fix.

Copilot uses AI. Check for mistakes.
Copilot AI review requested due to automatic review settings March 7, 2026 01:36
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 7 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tui/editor.go
Comment on lines 39 to 41
_ = f.Close()
_ = os.Remove(path) //nolint:gosec // path is a controlled temp file created by this function
_ = os.Remove(path)
return func() tea.Msg {
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

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

Removing the previous //nolint:gosec here will likely cause gosec G304-style findings for using a variable file path. Since path comes from os.CreateTemp, it’s safe, but with gosec enabled this may fail CI; consider restoring the suppression with a brief rationale.

Copilot uses AI. Check for mistakes.

p := pproxy.New(addr, upstream)
ctx, cancel := context.WithCancel(t.Context())
ctx, cancel := context.WithCancel(t.Context()) //nolint:gosec // cancel is deferred below via t.Cleanup
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

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

The inline suppression uses //nolint:gosec, but the warning for context.WithCancel without an immediate cancel() is typically from govet's lostcancel analyzer, not gosec. As written, this comment likely won’t suppress the CI finding; use the correct nolint tag (e.g. lostcancel/govet) or adjust the code in a way the analyzer recognizes.

Suggested change
ctx, cancel := context.WithCancel(t.Context()) //nolint:gosec // cancel is deferred below via t.Cleanup
ctx, cancel := context.WithCancel(t.Context()) //nolint:govet,lostcancel // cancel is deferred below via t.Cleanup

Copilot uses AI. Check for mistakes.
Comment thread proxy/mysql/proxy_test.go

p := mproxy.New(addr, upstream)
ctx, cancel := context.WithCancel(t.Context())
ctx, cancel := context.WithCancel(t.Context()) //nolint:gosec // cancel is deferred below via t.Cleanup
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

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

The inline suppression uses //nolint:gosec, but the warning for context.WithCancel without an immediate cancel() is typically from govet's lostcancel analyzer, not gosec. As written, this comment likely won’t suppress the CI finding; use the correct nolint tag (e.g. lostcancel/govet) or adjust the code in a way the analyzer recognizes.

Suggested change
ctx, cancel := context.WithCancel(t.Context()) //nolint:gosec // cancel is deferred below via t.Cleanup
ctx, cancel := context.WithCancel(t.Context()) //nolint:govet // cancel is deferred below via t.Cleanup

Copilot uses AI. Check for mistakes.
Comment thread web/web_test.go
Comment on lines 26 to 28
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL+"/", nil)
resp, err := http.DefaultClient.Do(req) //nolint:gosec // test URL
resp, err := http.DefaultClient.Do(req)
if err != nil {
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

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

Removing the //nolint:gosec suppression here will likely reintroduce gosec G107 (variable URL in HTTP request). Since gosec is enabled in .golangci.yaml, this may fail CI; consider restoring the suppression (or using an alternative suppression form like #nosec G107) with an appropriate rationale for this test-only request.

Copilot uses AI. Check for mistakes.
Comment thread web/web_test.go
Comment on lines 53 to 55
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL+"/api/events", nil)
resp, err := http.DefaultClient.Do(req) //nolint:gosec // test URL
resp, err := http.DefaultClient.Do(req)
if err != nil {
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

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

Removing the //nolint:gosec suppression here will likely reintroduce gosec G107 (variable URL in HTTP request). Since gosec is enabled in .golangci.yaml, this may fail CI; consider restoring the suppression (or using #nosec G107) with a short test-only justification.

Copilot uses AI. Check for mistakes.
Comment thread web/web_test.go
Comment on lines 118 to 120
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL+"/api/events", nil)
resp, err := http.DefaultClient.Do(req) //nolint:gosec // test URL
resp, err := http.DefaultClient.Do(req)
if err != nil {
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

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

Removing the //nolint:gosec suppression here will likely reintroduce gosec G107 (variable URL in HTTP request). Since gosec is enabled in .golangci.yaml, this may fail CI; consider restoring the suppression (or using #nosec G107) with a short test-only justification.

Copilot uses AI. Check for mistakes.
Comment thread web/web_test.go
Comment on lines 148 to 151
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, ts.URL+"/api/explain", body)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req) //nolint:gosec // test URL
resp, err := http.DefaultClient.Do(req)
if err != nil {
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

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

Removing the //nolint:gosec suppression here will likely reintroduce gosec G107 (variable URL in HTTP request). Since gosec is enabled in .golangci.yaml, this may fail CI; consider restoring the suppression (or using #nosec G107) with a short test-only justification.

Copilot uses AI. Check for mistakes.
@mickamy mickamy merged commit 49ccb34 into main Mar 7, 2026
9 checks passed
@mickamy mickamy deleted the fix/timestamp branch March 7, 2026 01:45
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