Files
go/conn_http.go
zeekay 0c90ebe02e debrand: finish the rename across source, docs and dev infra
Source: the unexported clickhouse struct becomes client, the ch receiver and
chproto import alias follow, error message prefixes read datastore, and the
JSON serializer interface drops the vendor infix. resources' metadata key and
its accessor are renamed together, with a test proving the embedded YAML still
unmarshals — a silent drift there would have made every connection take the
unsupported-server-version branch.

Codegen: all three templates still emitted the upstream module paths, so
regenerating would have quietly undone the rename. Note the templates have
drifted from the files they produce (regenerating today drops QBit and
scanTypeDuration), so the generated files are left as tracked and only the
templates are corrected.

Dead links: an earlier mechanical pass invented URLs that do not resolve —
github.com/ClickHouse/datastore-go, hanzo-ds/driver issue numbers that belong to
upstream, and mangled names of independent projects (mailru, uptrace, nikepan,
EpicStep). Third-party names are not ours to rewrite, so those directories are
removed rather than repaired, and provenance comments now point at real files.

Dev infra: compose runs our own server image at the tag production runs, since
the vendor's alpine tag does not exist in our registry. Cluster hostnames,
nginx upstreams and server configs are renamed as one set.

CHANGELOG.md is upstream's release history for versions this module never
released; rewriting it would fabricate a lineage, so it is dropped and the
upstream base recorded in README instead.

The two long-standing vet findings are fixed by keying the struct literals
rather than suppressed. LICENSE and contributors/list keep the vendor name on
purpose: one is the copyright notice Apache-2.0 requires, the other is real
people's names. CI now enforces build, vet, test, gofmt and a grep gate.
2026-07-26 11:46:39 -07:00

751 lines
20 KiB
Go

package datastore
import (
"bytes"
"compress/flate"
"compress/gzip"
"compress/zlib"
"context"
sqldriver "database/sql/driver"
"errors"
"fmt"
"io"
"log/slog"
"mime/multipart"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"github.com/andybalholm/brotli"
"github.com/hanzo-ds/go/lib/proto"
"github.com/hanzo-ds/native/compress"
nativeproto "github.com/hanzo-ds/native/proto"
)
const (
quotaKeyParamName = "quota_key"
queryIDParamName = "query_id"
)
type Pool[T any] struct {
pool *sync.Pool
}
func NewPool[T any](fn func() T) Pool[T] {
return Pool[T]{
pool: &sync.Pool{New: func() any { return fn() }},
}
}
func (p *Pool[T]) Get() T {
return p.pool.Get().(T)
}
func (p *Pool[T]) Put(x T) {
p.pool.Put(x)
}
type HTTPReaderWriter struct {
reader io.Reader
writer io.WriteCloser
err error
method CompressionMethod
}
// NewReader will return a reader that will decompress data if needed.
func (rw *HTTPReaderWriter) NewReader(res *http.Response) (io.Reader, error) {
enc := res.Header.Get("Content-Encoding")
if !res.Uncompressed && rw.method.String() == enc {
switch rw.method {
case CompressionGZIP:
reader := rw.reader.(*gzip.Reader)
if err := reader.Reset(res.Body); err != nil {
return nil, err
}
return reader, nil
case CompressionDeflate:
reader := rw.reader
if err := reader.(flate.Resetter).Reset(res.Body, nil); err != nil {
return nil, err
}
return reader, nil
case CompressionBrotli:
reader := rw.reader.(*brotli.Reader)
if err := reader.Reset(res.Body); err != nil {
return nil, err
}
return reader, nil
}
}
return res.Body, nil
}
func (rw *HTTPReaderWriter) reset(pw *io.PipeWriter) io.WriteCloser {
switch rw.method {
case CompressionGZIP:
rw.writer.(*gzip.Writer).Reset(pw)
return rw.writer
case CompressionDeflate:
rw.writer.(*zlib.Writer).Reset(pw)
return rw.writer
case CompressionBrotli:
rw.writer.(*brotli.Writer).Reset(pw)
return rw.writer
default:
return pw
}
}
// applyOptionsToRequest applies the client Options (such as auth, headers, client info) to the given http.Request
func applyOptionsToRequest(ctx context.Context, req *http.Request, opt *Options) error {
queryOpt := queryOptions(ctx)
jwt := queryOpt.jwt
useJWT := jwt != "" || useJWTAuth(opt)
if opt.TLS != nil && useJWT {
if jwt == "" {
var err error
jwt, err = opt.GetJWT(ctx)
if err != nil {
return fmt.Errorf("failed to get JWT: %w", err)
}
}
req.Header.Set("Authorization", "Bearer "+jwt)
} else if opt.TLS != nil && len(opt.Auth.Username) > 0 {
req.Header.Set(HeaderUser, opt.Auth.Username)
if len(opt.Auth.Password) > 0 {
req.Header.Set(HeaderKey, opt.Auth.Password)
req.Header.Set(HeaderSSLCertificateAuth, "off")
} else {
req.Header.Set(HeaderSSLCertificateAuth, "on")
}
} else if opt.TLS == nil && len(opt.Auth.Username) > 0 {
if len(opt.Auth.Password) > 0 {
req.URL.User = url.UserPassword(opt.Auth.Username, opt.Auth.Password)
} else {
req.URL.User = url.User(opt.Auth.Username)
}
}
req.Header.Set("User-Agent", opt.ClientInfo.Append(queryOpt.clientInfo).String())
for k, v := range opt.HttpHeaders {
req.Header.Set(k, v)
}
return nil
}
func dialHttp(ctx context.Context, addr string, num int, opt *Options) (*httpConnect, error) {
// Get base logger and enrich with connection-specific context
baseLogger := opt.logger()
logger := prepareConnLogger(baseLogger, num, addr, "http")
scheme := opt.scheme
compression := opt.Compression
if scheme == "" {
switch opt.Protocol {
case HTTP:
scheme = opt.Protocol.String()
if opt.TLS != nil {
scheme = fmt.Sprintf("%ss", scheme)
}
default:
return nil, errors.New("invalid interface type for http")
}
}
u := &url.URL{
Scheme: scheme,
Host: addr,
Path: opt.HttpUrlPath,
}
query := u.Query()
if len(opt.Auth.Database) > 0 {
query.Set("database", opt.Auth.Database)
}
if compression == nil {
compression = &Compression{
Method: CompressionNone,
}
}
compressionPool, err := createCompressionPool(compression)
if err != nil {
return nil, err
}
for k, v := range opt.Settings {
if cv, ok := v.(CustomSetting); ok {
v = cv.Value
}
query.Set(k, fmt.Sprint(v))
}
query.Set("default_format", "Native")
query.Set("client_protocol_version", strconv.Itoa(ClientTCPProtocolVersion))
u.RawQuery = query.Encode()
rt, err := createHTTPRoundTripper(opt)
if err != nil {
return nil, err
}
conn := httpConnect{
id: num,
connectedAt: time.Now(),
released: false,
logger: logger,
opt: opt,
client: &http.Client{
Transport: rt,
},
url: u,
revision: ClientTCPProtocolVersion, // Preflight uses hardcoded revision, may break older versions.
encodeRevision: 0, // Encoding data over HTTP must use 0. client_protocol_version does not apply to inserts.
buffer: new(nativeproto.Buffer),
compression: compression.Method,
blockCompressor: compress.NewWriter(compress.Level(compression.Level), compress.Method(compression.Method)),
compressionPool: compressionPool,
blockBufferSize: opt.BlockBufferSize,
}
handshake, err := conn.queryHello(ctx, func(nativeTransport, error) {})
if err != nil {
return nil, fmt.Errorf("failed to query server hello: %w", err)
}
conn.handshake = handshake
conn.revision = conn.handshake.Revision
return &conn, nil
}
func createHTTPRoundTripper(opt *Options) (http.RoundTripper, error) {
httpProxy := http.ProxyFromEnvironment
if opt.HTTPProxyURL != nil {
httpProxy = http.ProxyURL(opt.HTTPProxyURL)
}
rt := &http.Transport{
Proxy: httpProxy,
DialContext: (&net.Dialer{
Timeout: opt.DialTimeout,
}).DialContext,
MaxIdleConns: 1,
MaxConnsPerHost: opt.HttpMaxConnsPerHost,
IdleConnTimeout: opt.ConnMaxLifetime,
ResponseHeaderTimeout: opt.ReadTimeout,
TLSClientConfig: opt.TLS,
DisableCompression: true,
}
if opt.DialContext != nil {
rt.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
return opt.DialContext(ctx, addr)
}
}
if opt.TransportFunc == nil {
return rt, nil
}
return opt.TransportFunc(rt)
}
type httpConnect struct {
id int
connectedAt time.Time
released bool
logger *slog.Logger
opt *Options
revision uint64
encodeRevision uint64
url *url.URL
client *http.Client
buffer *nativeproto.Buffer
compression CompressionMethod
blockCompressor *compress.Writer
compressionPool Pool[HTTPReaderWriter]
blockBufferSize uint8
handshake proto.ServerHandshake
}
func (h *httpConnect) serverVersion() (*ServerVersion, error) {
return &h.handshake, nil
}
func (h *httpConnect) connID() int {
return h.id
}
func (h *httpConnect) connectedAtTime() time.Time {
return h.connectedAt
}
func (h *httpConnect) getLogger() *slog.Logger {
return h.logger
}
func (h *httpConnect) isReleased() bool {
return h.released
}
func (h *httpConnect) setReleased(released bool) {
h.released = released
}
func (h *httpConnect) freeBuffer() {
}
func (h *httpConnect) isBad() bool {
return h.client == nil
}
func (h *httpConnect) queryHello(ctx context.Context, release nativeTransportRelease) (proto.ServerHandshake, error) {
h.logger.Debug("querying server info via HTTP")
ctx = Context(ctx, ignoreExternalTables())
query := "SELECT displayName(), version(), revision(), timezone()"
rows, err := h.query(ctx, release, query)
if err != nil {
return proto.ServerHandshake{}, fmt.Errorf("failed to query server hello info: %w", err)
}
defer rows.Close()
if !rows.Next() {
return proto.ServerHandshake{}, errors.New("no rows returned for server hello query")
}
var (
displayName string
versionStr string
revision uint32
timezone string
)
if err := rows.Scan(&displayName, &versionStr, &revision, &timezone); err != nil {
return proto.ServerHandshake{}, err
}
location, err := time.LoadLocation(timezone)
if err != nil {
return proto.ServerHandshake{}, fmt.Errorf("failed to load timezone from server hello query: %w", err)
}
return proto.ServerHandshake{
Name: displayName,
DisplayName: displayName,
Revision: uint64(revision),
Version: proto.ParseVersion(versionStr),
Timezone: location,
}, nil
}
func createCompressionPool(compression *Compression) (Pool[HTTPReaderWriter], error) {
pool := NewPool(func() HTTPReaderWriter {
switch compression.Method {
case CompressionGZIP:
// trick so we can init the reader to something to Reset when we reuse
writer, err := gzip.NewWriterLevel(io.Discard, compression.Level)
if err != nil {
return HTTPReaderWriter{err: err}
}
b := new(bytes.Buffer)
writer.Reset(b)
writer.Flush()
writer.Close()
reader, err := gzip.NewReader(bytes.NewReader(b.Bytes()))
return HTTPReaderWriter{writer: writer, reader: reader, err: err, method: compression.Method}
case CompressionDeflate:
writer, err := zlib.NewWriterLevel(io.Discard, compression.Level)
if err != nil {
return HTTPReaderWriter{err: err}
}
b := new(bytes.Buffer)
writer.Reset(b)
writer.Flush()
writer.Close()
reader, err := zlib.NewReader(bytes.NewReader(b.Bytes()))
if err != nil {
return HTTPReaderWriter{err: err}
}
return HTTPReaderWriter{writer: writer, reader: reader, method: compression.Method}
case CompressionBrotli:
writer := brotli.NewWriterLevel(io.Discard, compression.Level)
b := new(bytes.Buffer)
writer.Reset(b)
writer.Flush()
writer.Close()
reader := brotli.NewReader(bytes.NewReader(b.Bytes()))
return HTTPReaderWriter{writer: writer, reader: reader, method: compression.Method}
default:
return HTTPReaderWriter{method: CompressionNone}
}
})
err := pool.Get().err
if err != nil {
return pool, err
}
return pool, nil
}
func (h *httpConnect) writeData(block *proto.Block) error {
// Saving offset of compressible data
start := len(h.buffer.Buf)
if err := block.Encode(h.buffer, h.encodeRevision); err != nil {
return fmt.Errorf("block encode: %w", err)
}
if h.compression == CompressionLZ4 || h.compression == CompressionZSTD {
// Performing compression. Supported and requires
data := h.buffer.Buf[start:]
if err := h.blockCompressor.Compress(data); err != nil {
return fmt.Errorf("compress: %w", err)
}
h.buffer.Buf = append(h.buffer.Buf[:start], h.blockCompressor.Data...)
}
return nil
}
func (h *httpConnect) readData(reader *nativeproto.Reader, timezone *time.Location, captureBuffer *bytes.Buffer) (*proto.Block, error) {
location := h.handshake.Timezone
if timezone != nil {
location = timezone
}
serverContext := serverVersionToContext(h.handshake)
serverContext.Timezone = location
block := proto.Block{ServerContext: &serverContext}
if h.compression == CompressionLZ4 || h.compression == CompressionZSTD {
reader.EnableCompression()
defer reader.DisableCompression()
}
// Try to decode the block
if err := block.Decode(reader, h.revision); err != nil {
// Decode failed - check if captured data contains exception marker
// The decode error typically happens because it tries to read the
// "__exception__" marker as binary data
// Exception block size can be up to 16KiB max.
// https://docs.hanzo.ai/datastore/interfaces/http#http_response_codes_caveats
// NOTE: When exception happens, a dedicated block is allocated for exception and only
// that exception will be present in the block. So safe to assume whole block size is same
// exception block max size 16KiB.
maxSize := int64(16 * 1024)
// Try to read any remaining data
lr := &limitedReader{reader: reader, limit: 2 * maxSize} // allocating 2 * maxSize for just in case
buf := make([]byte, maxSize)
n, readErr := lr.Read(buf)
if n > 0 && captureBuffer != nil {
captureBuffer.Write(buf[:n])
}
if readErr != nil {
h.logger.Error("HTTP read data: decode error while parsing exception block", slog.Any("error", err))
}
// Check if the captured data contains the exception marker
if captureBuffer != nil && bytes.Contains(captureBuffer.Bytes(), []byte("__exception__")) {
// This is an exception block, parse it
return nil, parseExceptionFromBytes(captureBuffer.Bytes())
}
// Not an exception, return the original decode error
return nil, fmt.Errorf("block decode for exception: %w", err)
}
return &block, nil
}
// limitedReader is a helper to read from nativeproto.Reader up to a limit
type limitedReader struct {
reader *nativeproto.Reader
limit int64
read int64
}
func (lr *limitedReader) Read(p []byte) (n int, err error) {
if lr.read >= lr.limit {
return 0, io.EOF
}
if int64(len(p)) > lr.limit-lr.read {
p = p[0 : lr.limit-lr.read]
}
n, err = lr.reader.Read(p)
lr.read += int64(n)
return
}
// parseExceptionFromBytes parses Datastore exception block
// Format from Datastore server (WriteBufferFromHTTPServerResponse.cpp):
//
// \r\n
// __exception__
// \r\n
// <TAG> (16 bytes)
// \r\n
// <error message>
// \n
// <message_length> <TAG>
// \r\n
// __exception__
// \r\n
func parseExceptionFromBytes(data []byte) error {
const (
exceptionMarker = "__exception__"
exceptionMarkerLen = len(exceptionMarker)
exceptionTagLen = 16 // bytes
)
dataStr := string(data)
// Find the first __exception__ marker
firstMarker := strings.Index(dataStr, exceptionMarker)
if firstMarker < 0 {
return fmt.Errorf("exception marker not found")
}
// Skip past first __exception__\r\n
pos := firstMarker + exceptionMarkerLen
// Skip \r\n after first marker
if pos+2 < len(dataStr) && dataStr[pos:pos+2] == "\r\n" {
pos += 2
}
// Skip the exception tag (16 bytes) + \r\n
if pos+exceptionTagLen+2 < len(dataStr) {
pos += exceptionTagLen + 2 // tag + \r\n
}
// Now we're at the start of the error message
// Find the second __exception__ marker
secondMarker := strings.Index(dataStr[pos:], exceptionMarker)
if secondMarker < 0 {
// If we can't find second marker, just extract what we can
errorMsg := strings.TrimSpace(dataStr[pos:])
if len(errorMsg) > 0 {
return fmt.Errorf("Datastore exception: %s", errorMsg)
}
return fmt.Errorf("Datastore exception occurred but could not parse details")
}
// Extract error message between tag and second marker
// The error message ends with: \n<message_length> <TAG>\r\n__exception__
errorContent := dataStr[pos : pos+secondMarker]
// Find the last line which contains "<message_length> <TAG>"
lines := strings.Split(strings.TrimRight(errorContent, "\r\n"), "\n")
if len(lines) == 0 {
return fmt.Errorf("Datastore exception occurred but could not parse details")
}
// The last line is "<message_length> <TAG>", error message is everything before it
errorMsg := strings.Join(lines[:len(lines)-1], "\n")
errorMsg = strings.TrimSpace(errorMsg)
if len(errorMsg) == 0 {
return fmt.Errorf("Datastore exception occurred but message is empty")
}
return fmt.Errorf("Datastore exception: %s", errorMsg)
}
func (h *httpConnect) sendStreamQuery(ctx context.Context, r io.Reader, options *QueryOptions, headers map[string]string) (*http.Response, error) {
req, err := h.createRequest(ctx, h.url.String(), r, options, headers)
if err != nil {
return nil, err
}
res, err := h.executeRequest(req)
if err != nil {
return nil, err
}
return res, nil
}
func (h *httpConnect) sendQuery(ctx context.Context, query string, options *QueryOptions, headers map[string]string) (*http.Response, error) {
req, err := h.prepareRequest(ctx, query, options, headers)
if err != nil {
return nil, err
}
res, err := h.executeRequest(req)
if err != nil {
return nil, err
}
return res, nil
}
func (h *httpConnect) readRawResponse(response *http.Response) (body []byte, err error) {
rw := h.compressionPool.Get()
defer h.compressionPool.Put(rw)
reader, err := rw.NewReader(response)
if err != nil {
return nil, err
}
if h.compression == CompressionLZ4 || h.compression == CompressionZSTD {
chReader := nativeproto.NewReader(reader)
chReader.EnableCompression()
reader = chReader
}
body, err = io.ReadAll(reader)
if err != nil && !errors.Is(err, io.EOF) {
return nil, err
}
return body, nil
}
func (h *httpConnect) createRequest(ctx context.Context, requestUrl string, reader io.Reader, options *QueryOptions, headers map[string]string) (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, requestUrl, reader)
if err != nil {
return nil, err
}
err = applyOptionsToRequest(ctx, req, h.opt)
if err != nil {
return nil, err
}
for k, v := range headers {
req.Header.Add(k, v)
}
var query url.Values
if options != nil {
query = req.URL.Query()
if options.queryID != "" {
query.Set(queryIDParamName, options.queryID)
}
if options.quotaKey != "" {
query.Set(quotaKeyParamName, options.quotaKey)
}
for key, value := range options.settings {
// check that query doesn't change format
if key == "default_format" {
continue
}
if cv, ok := value.(CustomSetting); ok {
value = cv.Value
}
query.Set(key, fmt.Sprint(value))
}
for key, value := range options.parameters {
query.Set(fmt.Sprintf("param_%s", key), value)
}
req.URL.RawQuery = query.Encode()
}
return req, nil
}
func (h *httpConnect) prepareRequest(ctx context.Context, query string, options *QueryOptions, headers map[string]string) (*http.Request, error) {
if options == nil || len(options.external) == 0 {
return h.createRequest(ctx, h.url.String(), strings.NewReader(query), options, headers)
}
return h.createRequestWithExternalTables(ctx, query, options, headers)
}
func (h *httpConnect) createRequestWithExternalTables(ctx context.Context, query string, options *QueryOptions, headers map[string]string) (*http.Request, error) {
payload := &bytes.Buffer{}
w := multipart.NewWriter(payload)
currentUrl := new(url.URL)
*currentUrl = *h.url
queryValues := currentUrl.Query()
buf := &nativeproto.Buffer{}
for _, table := range options.external {
tableName := table.Name()
queryValues.Set(fmt.Sprintf("%v_format", tableName), "Native")
queryValues.Set(fmt.Sprintf("%v_structure", tableName), table.Structure())
partWriter, err := w.CreateFormFile(tableName, "")
if err != nil {
return nil, err
}
buf.Reset()
err = table.Block().Encode(buf, h.encodeRevision)
if err != nil {
return nil, err
}
_, err = partWriter.Write(buf.Buf)
if err != nil {
return nil, err
}
}
currentUrl.RawQuery = queryValues.Encode()
err := w.WriteField("query", query)
if err != nil {
return nil, err
}
err = w.Close()
if err != nil {
return nil, err
}
if headers == nil {
headers = make(map[string]string)
}
headers["Content-Type"] = w.FormDataContentType()
return h.createRequest(ctx, currentUrl.String(), bytes.NewReader(payload.Bytes()), options, headers)
}
func (h *httpConnect) executeRequest(req *http.Request) (*http.Response, error) {
if h.client == nil {
return nil, sqldriver.ErrBadConn
}
resp, err := h.client.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
defer discardAndClose(resp.Body)
msgBytes, err := h.readRawResponse(resp)
if err != nil {
return nil, fmt.Errorf("[HTTP %d] failed to read response: %w", resp.StatusCode, err)
}
return nil, fmt.Errorf("[HTTP %d] response body: \"%s\"", resp.StatusCode, string(msgBytes))
}
return resp, nil
}
func (h *httpConnect) ping(ctx context.Context) error {
ctx = Context(ctx, ignoreExternalTables())
// release func is called by connection pool
rows, err := h.query(ctx, func(nativeTransport, error) {}, "SELECT 1")
if err != nil {
return err
}
defer rows.Close()
column := rows.Columns()
// check that we got column 1
if len(column) == 1 && column[0] == "1" {
return nil
}
return errors.New("datastore [ping]:: cannot ping datastore")
}
func (h *httpConnect) close() error {
if h.client == nil {
return nil
}
h.client.CloseIdleConnections()
h.client = nil
return nil
}
// discardAndClose discards remaining data and closes the reader.
// Intended for freeing HTTP connections for re-use.
func discardAndClose(rc io.ReadCloser) {
_, _ = io.Copy(io.Discard, rc)
_ = rc.Close()
}