This is an automated email from the ASF dual-hosted git repository.
joao-r-reis pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/cassandra-gocql-driver.git
The following commit(s) were added to refs/heads/trunk by this push:
new 60ef3281 Add request attempt interceptors for queries and batches
60ef3281 is described below
commit 60ef32811be61529676d2ebe64ba11a856443e83
Author: Alex Benn <[email protected]>
AuthorDate: Tue Oct 20 16:37:06 2026 -0400
Add request attempt interceptors for queries and batches
Applications need to apply rate limiting, propagate tracing context, and
inspect results for each request attempt, including retries and speculative
executions. Observers report results after an attempt and cannot reject
execution or adjust its context.
Introduce pre- and post-attempt hooks with cluster defaults and per-query
or per-batch overrides. Interceptor chains preserve each interceptor's
context and invoke post-attempt hooks in reverse order.
Track attempt indices separately from completed attempts so observers
receive stable indices during concurrent execution while retry decisions
and average latency retain their existing semantics.
Patch by Ben Eddy, Alex Benn, João Reis; reviewed by João Reis, Bohdan
Siryk, toptobes for CASSGO-39
Assisted-by: Claude Code:claude-opus-5-5
---
CHANGELOG.md | 6 ++
cluster.go | 4 +
control.go | 6 +-
doc.go | 12 +++
example_interceptor_test.go | 154 ++++++++++++++++++++++++++++
policies_test.go | 4 +-
query_executor.go | 237 ++++++++++++++++++++++++++++++++++++++------
query_executor_unit_test.go | 178 +++++++++++++++++++++++++++++++++
session.go | 75 ++++++++++++--
9 files changed, 632 insertions(+), 44 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 071f2e93..8493442d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this
file.
The format is based on [Keep a
Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic
Versioning](https://semver.org/spec/v2.0.0.html).
+## [Unreleased]
+
+### Added
+
+- Add request attempt interceptors for queries and batches (CASSGO-39)
+
## [2.2.0]
### Added
diff --git a/cluster.go b/cluster.go
index eeb12b92..f2f8dd1b 100644
--- a/cluster.go
+++ b/cluster.go
@@ -258,6 +258,10 @@ type ClusterConfig struct {
// See https://issues.apache.org/jira/browse/CASSANDRA-10786
DisableSkipMetadata bool
+ // RequestInterceptor will set the provided interceptor on all
queries/batches created from this session.
+ // Use it to intercept requests by providing an implementation of
RequestInterceptor.
+ RequestInterceptor RequestInterceptor
+
// QueryObserver will set the provided query observer on all queries
created from this session.
// Use it to collect metrics / stats from queries by providing an
implementation of QueryObserver.
QueryObserver QueryObserver
diff --git a/control.go b/control.go
index 9f56958a..5acca9ba 100644
--- a/control.go
+++ b/control.go
@@ -591,7 +591,11 @@ func (c *controlConn) query(statement string, values
...interface{}) (iter *Iter
NewLogFieldString("statement", statement),
NewLogFieldError("err", iter.err))
}
- qry.metrics.attempt(0)
+ // Record each attempt so the retry policy sees an increasing
attempt count and
+ // eventually stops retrying. getNextAttempt() is called to
keep attemptsStarted
+ // in sync with attemptsCompleted, as queryMetrics assumes
started >= completed.
+ qry.metrics.getNextAttempt()
+ qry.metrics.recordAttempt(0)
qry.hostMetricsManager.attempt(0, c.getConn().host)
if iter.err == nil || !c.retry.Attempt(qry) {
break
diff --git a/doc.go b/doc.go
index c0b9a729..a05a5e33 100644
--- a/doc.go
+++ b/doc.go
@@ -813,6 +813,18 @@
//
// See Example_userDefinedTypesMap, Example_userDefinedTypesStruct,
ExampleUDTMarshaler, ExampleUDTUnmarshaler.
//
+// # Interceptors
+//
+// A RequestInterceptor wraps query/batch execution and can be used to inject
logic that should apply to all request
+// execution attempts. For example, interceptors can be used for rate
limiting, logging, attaching distributed tracing
+// metadata to the context, and inspecting request results. However, the
query/batch itself cannot be modified.
+//
+// A RequestInterceptor will be invoked once prior to (via
InterceptPreAttempt()) and once after (via
+// InterceptPostAttempt()) each request execution attempt, including retry
attempts and speculative execution attempts.
+// It may optionally return an error to prevent that request from being
executed.
+//
+// See Example_interceptorFault, Example_interceptorContext, and
Example_interceptorChain for examples.
+//
// # Metrics and tracing
//
// It is possible to provide observer implementations that could be used to
gather metrics:
diff --git a/example_interceptor_test.go b/example_interceptor_test.go
new file mode 100644
index 00000000..370d7bfc
--- /dev/null
+++ b/example_interceptor_test.go
@@ -0,0 +1,154 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+/*
+ * Content before git sha 34fdeebefcbf183ed7f916f931aa0586fdaa1b40
+ * Copyright (c) 2016, The Gocql authors,
+ * provided under the BSD-3-Clause License.
+ * See the NOTICE file distributed with this work for additional information.
+ */
+
+package gocql_test
+
+import (
+ "context"
+ "errors"
+ "log"
+
+ gocql "github.com/apache/cassandra-gocql-driver/v2"
+)
+
+type MyRequestInterceptor struct {
+ injectFault bool
+ contextStr string
+}
+
+type traceIDKey struct{}
+
+var errInjectedTimeout = errors.New("timeout")
+
+var _ gocql.RequestInterceptor = (*MyRequestInterceptor)(nil)
+
+func (q MyRequestInterceptor) InterceptPreAttempt(
+ ctx context.Context,
+ attempt gocql.RequestAttempt,
+) (gocql.InterceptResult, error) {
+ switch attempt.Type {
+ case gocql.StatementQuery:
+ // Inspect query
+ log.Println(attempt.Query.Statement())
+ case gocql.StatementBatch:
+ // Inspect batch
+ log.Println(attempt.Batch.Entries[0].Stmt)
+ }
+
+ // Inspect or modify context
+ ctx = context.WithValue(ctx, traceIDKey{}, q.contextStr)
+
+ // Optionally bypass request issuance by returning an error to prevent
query execution.
+ // For example, to simulate query timeouts, or to perform client-side
load shedding.
+ if q.injectFault {
+ return gocql.InterceptResult{}, errInjectedTimeout
+ }
+
+ return gocql.InterceptResult{Ctx: ctx}, nil
+}
+
+func (q MyRequestInterceptor) InterceptPostAttempt(res gocql.InterceptResult,
_ error) {
+ if res.Ctx == nil {
+ return
+ }
+ if q.contextStr != res.Ctx.Value(traceIDKey{}) {
+ log.Printf("context string changed: %s",
res.Ctx.Value(traceIDKey{}))
+ }
+}
+
+// Demonstrates how a RequestInterceptor might be used to inject faults.
+func Example_interceptorFault() {
+ cluster := gocql.NewCluster("localhost:9042")
+ cluster.RequestInterceptor = MyRequestInterceptor{injectFault: true}
+
+ session, err := cluster.CreateSession()
+ if err != nil {
+ log.Fatal(err)
+ }
+ defer session.Close()
+
+ ctx := context.Background()
+
+ var stringValue string
+ // Here we specify a retry policy, but the GoCQL library with
SimpleRetryPolicy will only retry multiple
+ // times if you have more than one host. This may not be the case if
you are running this example against
+ // a local test Cassandra instance, so NumRetries: 2 is here only for
explanatory purposes.
+ err = session.Query("select now() from system.local").
+ RetryPolicy(&gocql.SimpleRetryPolicy{NumRetries: 2}).
+ Idempotent(true).
+ ScanContext(ctx, &stringValue)
+ if !errors.Is(err, errInjectedTimeout) {
+ log.Fatal(err)
+ }
+}
+
+// Demonstrates how a RequestInterceptor might be used to propagate ancillary
data to post-processing via the context.
+func Example_interceptorContext() {
+ cluster := gocql.NewCluster("localhost:9042")
+ cluster.RequestInterceptor = MyRequestInterceptor{injectFault: false}
+
+ session, err := cluster.CreateSession()
+ if err != nil {
+ log.Fatal(err)
+ }
+ defer session.Close()
+
+ ctx := context.Background()
+
+ var stringValue string
+ err = session.Query("select now() from system.local").
+ RetryPolicy(&gocql.SimpleRetryPolicy{NumRetries: 2}).
+ ScanContext(ctx, &stringValue)
+ if err != nil {
+ log.Fatal(err)
+ }
+}
+
+// Demonstrates how to use a request interceptor chain.
+func Example_interceptorChain() {
+ cluster := gocql.NewCluster("localhost:9042")
+ cluster.RequestInterceptor = gocql.RequestInterceptorChain{
+ []gocql.RequestInterceptor{
+ MyRequestInterceptor{contextStr: "123"},
+ MyRequestInterceptor{contextStr: "234"},
+ MyRequestInterceptor{contextStr: "345"},
+ },
+ }
+
+ session, err := cluster.CreateSession()
+ if err != nil {
+ log.Fatal(err)
+ }
+ defer session.Close()
+
+ ctx := context.Background()
+
+ var stringValue string
+ err = session.Query("select now() from system.local").
+ RetryPolicy(&gocql.SimpleRetryPolicy{NumRetries: 2}).
+ ScanContext(ctx, &stringValue)
+ if err != nil {
+ log.Fatal(err)
+ }
+}
diff --git a/policies_test.go b/policies_test.go
index 547a123b..aaae1925 100644
--- a/policies_test.go
+++ b/policies_test.go
@@ -254,7 +254,7 @@ func TestSimpleRetryPolicy(t *testing.T) {
}
for _, c := range cases {
- q.metrics = &queryMetrics{totalAttempts: int64(c.attempts)}
+ q.metrics = &queryMetrics{attemptsCompleted: c.attempts}
q.hostMetricsManager =
preFilledHostMetricsMetricsManager(map[string]*hostMetrics{"127.0.0.1":
{Attempts: c.attempts}})
if c.allow && !rt.Attempt(q) {
t.Fatalf("should allow retry after %d attempts",
c.attempts)
@@ -339,7 +339,7 @@ func TestDowngradingConsistencyRetryPolicy(t *testing.T) {
}
for _, c := range cases {
- q.metrics = &queryMetrics{totalAttempts: int64(c.attempts)}
+ q.metrics = &queryMetrics{attemptsCompleted: c.attempts}
q.hostMetricsManager =
preFilledHostMetricsMetricsManager(map[string]*hostMetrics{"127.0.0.1":
{Attempts: c.attempts}})
if c.retryType != rt.GetRetryType(c.err) {
t.Fatalf("retry type should be %v", c.retryType)
diff --git a/query_executor.go b/query_executor.go
index 5d848c2e..1ab75a4c 100644
--- a/query_executor.go
+++ b/query_executor.go
@@ -58,7 +58,8 @@ type Statement interface {
type internalRequest interface {
execute(ctx context.Context, conn *Conn) *Iter
- attempt(keyspace string, end, start time.Time, iter *Iter, host
*HostInfo)
+ getNextAttempt() int
+ recordAttempt(attemptNum int, keyspace string, end, start time.Time,
iter *Iter, host *HostInfo)
retryPolicy() RetryPolicy
speculativeExecutionPolicy() SpeculativeExecutionPolicy
getQueryMetrics() *queryMetrics
@@ -73,12 +74,164 @@ type queryExecutor struct {
policy HostSelectionPolicy
}
-func (q *queryExecutor) attemptQuery(ctx context.Context, qry internalRequest,
conn *Conn) *Iter {
+type StatementType int
+
+const (
+ StatementQuery StatementType = iota
+ StatementBatch
+)
+
+type RequestAttempt struct {
+ // The statement type, whether query or batch.
+ Type StatementType
+ // Only one of Query or Batch will be set, depending on the op type.
Both should be considered
+ // immutable.
+ Query *Query
+ Batch *Batch
+ // The host that will receive the query.
+ Host *HostInfo
+}
+
+type InterceptResult struct {
+ Ctx context.Context
+}
+
+// RequestInterceptor is the interface implemented by interceptors /
middleware.
+//
+// Interceptors are well-suited to logic that is not specific to a single
query or batch, such as flow control.
+type RequestInterceptor interface {
+ // InterceptPreAttempt is invoked once immediately before a query or
batch execution attempt, including retry
+ // attempts and speculative execution attempts.
+ //
+ // The method may inspect the RequestAttempt and its contents (e.g.
Query/Batch, connection information, etc.), along
+ // with the context ctx. If a ctx is specified in the returned
InterceptResult, it is used when performing the
+ // execution, offering a way to adjust deadline or other metadata. To
prevent request execution, return a non-nil
+ // error.
+ InterceptPreAttempt(ctx context.Context, attempt RequestAttempt)
(InterceptResult, error)
+
+ // InterceptPostAttempt is called with the intercept result created at
pre-attempt time, along with any
+ // request execution error. Note that if this attempt failed, another
attempt in flight could still succeed.
+ // Must not panic.
+ // N.B.: InterceptPostAttempt is not called if InterceptPreAttempt
returns an error.
+ InterceptPostAttempt(res InterceptResult, execResult error)
+}
+
+// RequestInterceptorChain can be used to apply multiple Interceptors at
Intercept time.
+// Each interceptor is passed the previous one's context if that previous one
returned a non-nil
+// context, and the final request attempt is performed with the last
interceptor's context.
+//
+// InterceptPreAttempt is called on the Interceptors in order, and
InterceptPostAttempt is called
+// in reverse order. At post-attempt time, Interceptors are each handed back
the context they synthesized at
+// pre-attempt time, rather than the final context. This is to avoid confusion
if e.g. a pre-attempt grabbed some
+// resource that must be released, and multiple interceptors might use the
same context key to attach that
+// resource to the attempt's request. However, this also means that if an
Interceptor returns an InterceptResult
+// with a nil context, they will be handed back a nil.
+//
+// Finally, if Interceptor i's InterceptPreAttempt returns an error,
indicating that execution should not occur,
+// InterceptPostAttempt is still called on all the interceptors that were
chained before i. They are given the error
+// that the failing InterceptPreAttempt returned, in order to signal that
execution was not successful.
+type RequestInterceptorChain struct {
+ Interceptors []RequestInterceptor
+}
+
+type interceptorChainContextList []context.Context
+
+type interceptorChainContextListKey struct{}
+
+var _ RequestInterceptor = (*RequestInterceptorChain)(nil)
+
+func (c RequestInterceptorChain) InterceptPreAttempt(
+ ctx context.Context,
+ attempt RequestAttempt,
+) (InterceptResult, error) {
+ capturedContexts := make([]context.Context, 0, len(c.Interceptors))
+ lastCtx := ctx
+ for i, interceptor := range c.Interceptors {
+ result, err := interceptor.InterceptPreAttempt(lastCtx, attempt)
+ if err != nil {
+ // We must unwind all previously-successful
interceptors before returning err, since
+ // those interceptors may be counting on
InterceptPostAttempt being invoked.
+ for j := i - 1; j >= 0; j-- {
+ // Pass in the final err to all PostAttempt
intercepts, to signal that execution was not successful
+ // (because it never actually happened).
+
c.Interceptors[j].InterceptPostAttempt(InterceptResult{Ctx:
capturedContexts[j]}, err)
+ }
+ return InterceptResult{}, err
+ }
+ capturedContexts = append(capturedContexts, result.Ctx)
+ if result.Ctx != nil {
+ lastCtx = result.Ctx
+ }
+ }
+
+ finalContext := context.WithValue(lastCtx,
interceptorChainContextListKey{}, interceptorChainContextList(capturedContexts))
+ // Returns a result whose context wraps all the captured contexts for
all the chained interceptors.
+ return InterceptResult{Ctx: finalContext}, nil
+}
+
+func (c RequestInterceptorChain) InterceptPostAttempt(
+ res InterceptResult, execResult error,
+) {
+ // res must be the result returned by this chain's InterceptPreAttempt,
which carries each interceptor's context.
+ // If it isn't, those contexts are lost, so skip the post-attempt calls
rather than panic.
+ if res.Ctx == nil {
+ return
+ }
+ ctxList, ok :=
res.Ctx.Value(interceptorChainContextListKey{}).(interceptorChainContextList)
+ if !ok {
+ return
+ }
+ for i := len(ctxList) - 1; i >= 0; i-- {
+ interceptor := c.Interceptors[i]
+ ctx := ctxList[i]
+ interceptor.InterceptPostAttempt(InterceptResult{Ctx: ctx},
execResult)
+ }
+}
+
+func (q *queryExecutor) attemptQuery(ctx context.Context, iRequest
internalRequest, conn *Conn) *Iter {
+ var res InterceptResult
+ var interceptor RequestInterceptor
+ iq := RequestAttempt{
+ Host: conn.host,
+ }
+ if iQuery, ok := iRequest.(*internalQuery); ok {
+ iq.Type = StatementQuery
+ iq.Query = iQuery.originalQuery
+ interceptor = iQuery.qryOpts.interceptor
+ }
+ if iBatch, ok := iRequest.(*internalBatch); ok {
+ iq.Type = StatementBatch
+ iq.Batch = iBatch.originalBatch
+ interceptor = iBatch.batchOpts.interceptor
+ }
+ attempt := iRequest.getNextAttempt()
+ if interceptor != nil {
+ var err error
+ res, err = interceptor.InterceptPreAttempt(ctx, iq)
+ if err != nil {
+ iter := newErrIter(err, iRequest.getQueryMetrics(),
iRequest.Keyspace(), iRequest.getRoutingInfo(), iRequest.getKeyspaceFunc())
+ iter.rejectedByInterceptor = true
+ // Always record the attempt, even if the interceptor
aborts it before it is actually executed. This way, a
+ // custom retry policy will see the retry count
increase and respond accordingly if the interceptor decides
+ // to abort the attempt, rather than getting into an
infinite retry loop with zero perceived attempts.
+ // Nothing was sent, so the attempt has zero latency.
+ now := time.Now()
+ iRequest.recordAttempt(attempt, q.pool.keyspace, now,
now, iter, conn.host)
+ return iter
+ }
+ if res.Ctx != nil {
+ ctx = res.Ctx
+ }
+ }
+
+ // Time only the execution, so that time spent in interceptors isn't
reported as query latency.
start := time.Now()
- iter := qry.execute(ctx, conn)
+ iter := iRequest.execute(ctx, conn)
end := time.Now()
-
- qry.attempt(q.pool.keyspace, end, start, iter, conn.host)
+ if interceptor != nil {
+ interceptor.InterceptPostAttempt(res, iter.err)
+ }
+ iRequest.recordAttempt(attempt, q.pool.keyspace, end, start, iter,
conn.host)
return iter
}
@@ -199,10 +352,16 @@ func (q *queryExecutor) do(ctx context.Context, qry
internalRequest, hostIter Ne
case context.Canceled, context.DeadlineExceeded, ErrNotFound:
// those errors represents logical errors, they should
not count
// toward removing a node from the pool
- selectedHost.Mark(nil)
+ if !iter.rejectedByInterceptor {
+ // However, if the error comes from an
interceptor, it's not
+ // actually a success, so we don't Mark(nil) in
that case.
+ selectedHost.Mark(nil)
+ }
return iter
default:
- selectedHost.Mark(iter.err)
+ if !iter.rejectedByInterceptor {
+ selectedHost.Mark(iter.err)
+ }
}
// Exit if the query was successful
@@ -262,9 +421,10 @@ type queryOptions struct {
pageSize int
disableAutoPage bool
- // Monitoring
- trace Tracer
- observer QueryObserver
+ // Monitoring and intercepting
+ trace Tracer
+ observer QueryObserver
+ interceptor RequestInterceptor
// Parameters
values []interface{}
@@ -313,6 +473,7 @@ func newQueryOptions(q *Query, ctx context.Context)
*queryOptions {
prefetch: q.prefetch,
trace: q.trace,
observer: q.observer,
+ interceptor: q.interceptor,
rt: q.rt,
spec: q.spec,
binding: q.binding,
@@ -371,14 +532,18 @@ func newInternalQuery(q *Query, ctx context.Context)
*internalQuery {
}
}
-// Attempts returns the number of times the query was executed.
+// getNextAttempt returns the index of the next attempt. Calling this
increments the attempt counter by one.
+func (q *internalQuery) getNextAttempt() int {
+ return q.metrics.getNextAttempt()
+}
+
func (q *internalQuery) Attempts() int {
return q.metrics.attempts()
}
-func (q *internalQuery) attempt(keyspace string, end, start time.Time, iter
*Iter, host *HostInfo) {
+func (q *internalQuery) recordAttempt(attemptNum int, keyspace string, end,
start time.Time, iter *Iter, host *HostInfo) {
latency := end.Sub(start)
- attempt := q.metrics.attempt(latency)
+ q.metrics.recordAttempt(latency)
if q.qryOpts.observer != nil {
metricsForHost := q.hostMetricsManager.attempt(latency, host)
@@ -389,16 +554,17 @@ func (q *internalQuery) attempt(keyspace string, end,
start time.Time, iter *Ite
Keyspace: q.routingInfo.getKeyspace(),
Table: q.routingInfo.getTable(),
},
- IsPrepared: q.routingInfo.isPrepared(),
- Values: q.qryOpts.values,
- Start: start,
- End: end,
- Rows: iter.numRows,
- Host: host,
- Metrics: metricsForHost,
- Err: iter.err,
- Attempt: attempt,
- Query: q.originalQuery,
+ IsPrepared: q.routingInfo.isPrepared(),
+ Values: q.qryOpts.values,
+ Start: start,
+ End: end,
+ Rows: iter.numRows,
+ Host: host,
+ Metrics: metricsForHost,
+ Err: iter.err,
+ Attempt: attemptNum,
+ Query: q.originalQuery,
+ RejectedByInterceptor: iter.rejectedByInterceptor,
})
}
}
@@ -505,8 +671,9 @@ func (q *internalQuery) getKeyspaceFunc() func() string {
}
type batchOptions struct {
- trace Tracer
- observer BatchObserver
+ trace Tracer
+ observer BatchObserver
+ interceptor RequestInterceptor
bType BatchType
entries []BatchEntry
@@ -549,6 +716,7 @@ func newBatchOptions(b *Batch, ctx context.Context)
*batchOptions {
spec: b.spec,
trace: b.trace,
observer: b.observer,
+ interceptor: b.interceptor,
serialCons: b.serialCons,
defaultTimestamp: b.defaultTimestamp,
defaultTimestampValue: b.defaultTimestampValue,
@@ -601,14 +769,18 @@ func newInternalBatch(batch *Batch, ctx context.Context)
*internalBatch {
}
}
+func (b *internalBatch) getNextAttempt() int {
+ return b.metrics.getNextAttempt()
+}
+
// Attempts returns the number of attempts made to execute the batch.
func (b *internalBatch) Attempts() int {
return b.metrics.attempts()
}
-func (b *internalBatch) attempt(keyspace string, end, start time.Time, iter
*Iter, host *HostInfo) {
+func (b *internalBatch) recordAttempt(attemptNum int, keyspace string, end,
start time.Time, iter *Iter, host *HostInfo) {
latency := end.Sub(start)
- attempt := b.metrics.attempt(latency)
+ b.metrics.recordAttempt(latency)
if b.batchOpts.observer == nil {
return
@@ -641,11 +813,12 @@ func (b *internalBatch) attempt(keyspace string, end,
start time.Time, iter *Ite
Start: start,
End: end,
// Rows not used in batch observations // TODO - might be able
to support it when using BatchCAS
- Host: host,
- Metrics: metricsForHost,
- Err: iter.err,
- Attempt: attempt,
- Batch: b.originalBatch,
+ Host: host,
+ Metrics: metricsForHost,
+ Err: iter.err,
+ Attempt: attemptNum,
+ Batch: b.originalBatch,
+ RejectedByInterceptor: iter.rejectedByInterceptor,
})
}
diff --git a/query_executor_unit_test.go b/query_executor_unit_test.go
new file mode 100644
index 00000000..b6693b65
--- /dev/null
+++ b/query_executor_unit_test.go
@@ -0,0 +1,178 @@
+//go:build all || unit
+// +build all unit
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package gocql
+
+import (
+ "context"
+ "errors"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+// markCountingPolicy wraps a HostSelectionPolicy and counts Mark calls on the
hosts it picks.
+type markCountingPolicy struct {
+ HostSelectionPolicy
+ marks int32
+}
+
+func (p *markCountingPolicy) Pick(qry ExecutableStatement) NextHost {
+ next := p.HostSelectionPolicy.Pick(qry)
+ return func() SelectedHost {
+ host := next()
+ if host == nil {
+ return nil
+ }
+ return markCountingHost{SelectedHost: host, marks: &p.marks}
+ }
+}
+
+type markCountingHost struct {
+ SelectedHost
+ marks *int32
+}
+
+func (h markCountingHost) Mark(err error) {
+ atomic.AddInt32(h.marks, 1)
+ h.SelectedHost.Mark(err)
+}
+
+type rejectingInterceptor struct {
+ err error
+}
+
+func (r rejectingInterceptor) InterceptPreAttempt(context.Context,
RequestAttempt) (InterceptResult, error) {
+ return InterceptResult{}, r.err
+}
+
+func (rejectingInterceptor) InterceptPostAttempt(InterceptResult, error) {}
+
+// timingInterceptor records when its pre-attempt hook returns and its
post-attempt hook is called.
+type timingInterceptor struct {
+ preDone time.Time
+ postStart time.Time
+}
+
+func (ti *timingInterceptor) InterceptPreAttempt(ctx context.Context, _
RequestAttempt) (InterceptResult, error) {
+ ti.preDone = time.Now()
+ return InterceptResult{Ctx: ctx}, nil
+}
+
+func (ti *timingInterceptor) InterceptPostAttempt(InterceptResult, error) {
+ ti.postStart = time.Now()
+}
+
+type countingInterceptor struct {
+ posts int
+}
+
+func (c *countingInterceptor) InterceptPreAttempt(ctx context.Context, _
RequestAttempt) (InterceptResult, error) {
+ return InterceptResult{Ctx: ctx}, nil
+}
+
+func (c *countingInterceptor) InterceptPostAttempt(InterceptResult, error) {
+ c.posts++
+}
+
+type lastQueryObserver struct {
+ query ObservedQuery
+}
+
+func (o *lastQueryObserver) ObserveQuery(_ context.Context, q ObservedQuery) {
+ o.query = q
+}
+
+func TestInterceptorRejectionDoesNotMarkHost(t *testing.T) {
+ srv := NewTestServer(t, defaultProto, context.Background())
+ defer srv.Stop()
+
+ policy := &markCountingPolicy{HostSelectionPolicy:
RoundRobinHostPolicy()}
+ cluster := testCluster(defaultProto, srv.Address)
+ cluster.PoolConfig.HostSelectionPolicy = policy
+ db, err := cluster.CreateSession()
+ if err != nil {
+ t.Fatalf("NewCluster: %v", err)
+ }
+ defer db.Close()
+
+ // A sent attempt marks the host, which shows the policy is in use.
+ if err := db.Query("void").Exec(); err != nil {
+ t.Fatal(err)
+ }
+ if marks := atomic.LoadInt32(&policy.marks); marks != 1 {
+ t.Fatalf("expected 1 mark after a sent attempt, got %d", marks)
+ }
+
+ errRejected := errors.New("rejected")
+ iter := db.Query("void").Interceptor(rejectingInterceptor{err:
errRejected}).Iter()
+ if err := iter.Close(); !errors.Is(err, errRejected) {
+ t.Fatalf("expected %v, got %v", errRejected, err)
+ }
+ if marks := atomic.LoadInt32(&policy.marks); marks != 1 {
+ t.Fatalf("expected a rejected attempt not to mark the host, got
%d marks", marks)
+ }
+}
+
+func TestInterceptorTimeExcludedFromLatency(t *testing.T) {
+ srv := NewTestServer(t, defaultProto, context.Background())
+ defer srv.Stop()
+
+ db, err := newTestSession(defaultProto, srv.Address)
+ if err != nil {
+ t.Fatalf("NewCluster: %v", err)
+ }
+ defer db.Close()
+
+ interceptor := &timingInterceptor{}
+ observer := &lastQueryObserver{}
+ if err :=
db.Query("void").Interceptor(interceptor).Observer(observer).Exec(); err != nil
{
+ t.Fatal(err)
+ }
+
+ if observer.query.Start.Before(interceptor.preDone) {
+ t.Errorf("attempt timing started before the pre-attempt
interceptor returned")
+ }
+ if observer.query.End.After(interceptor.postStart) {
+ t.Errorf("attempt timing ended after the post-attempt
interceptor was called")
+ }
+}
+
+func TestInterceptorChainPostAttemptWithoutChainContext(t *testing.T) {
+ inner := &countingInterceptor{}
+ chain := RequestInterceptorChain{Interceptors:
[]RequestInterceptor{inner}}
+
+ // Neither result came from chain.InterceptPreAttempt, so there are no
contexts to hand back.
+ chain.InterceptPostAttempt(InterceptResult{}, nil)
+ chain.InterceptPostAttempt(InterceptResult{Ctx: context.Background()},
nil)
+ if inner.posts != 0 {
+ t.Fatalf("expected no post-attempt calls without the chain's
context, got %d", inner.posts)
+ }
+
+ res, err := chain.InterceptPreAttempt(context.Background(),
RequestAttempt{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ chain.InterceptPostAttempt(res, nil)
+ if inner.posts != 1 {
+ t.Fatalf("expected 1 post-attempt call, got %d", inner.posts)
+ }
+}
diff --git a/session.go b/session.go
index bfe42573..feb0752a 100644
--- a/session.go
+++ b/session.go
@@ -62,6 +62,7 @@ type Session struct {
connectObserver ConnectObserver
frameObserver FrameHeaderObserver
streamObserver StreamObserver
+ interceptor RequestInterceptor
hostSource *ringDescriber
ringRefresher *refreshDebouncer
stmtsLRU *preparedLRU
@@ -194,6 +195,7 @@ func NewSession(cfg ClusterConfig) (*Session, error) {
s.connectObserver = cfg.ConnectObserver
s.frameObserver = cfg.FrameHeaderObserver
s.streamObserver = cfg.StreamObserver
+ s.interceptor = cfg.RequestInterceptor
// Propagate node status, topology and schema change listeners
s.hostListeners = newInternalHostStateListeners(
@@ -973,26 +975,50 @@ type hostMetrics struct {
TotalLatency int64
}
+// queryMetrics tracks attempt counts and latency for a query or batch.
+//
+// When speculative execution is active, attempts can start and finish out of
order
+// across concurrent goroutines. Attempt indices are issued at start time via
getNextAttempt()
+// so observers receive stable attempt numbers, while attemptsCompleted and
totalLatency
+// are recorded at completion time under lock so latency() (average latency)
and attempts()
+// remain consistent mid-flight.
type queryMetrics struct {
- totalAttempts int64
- totalLatency int64
+ l sync.RWMutex
+ attemptsStarted int
+ attemptsCompleted int
+ totalLatency int64
}
-func (qm *queryMetrics) attempt(addLatency time.Duration) int {
- atomic.AddInt64(&qm.totalLatency, addLatency.Nanoseconds())
- return int(atomic.AddInt64(&qm.totalAttempts, 1) - 1)
+func (qm *queryMetrics) getNextAttempt() int {
+ qm.l.Lock()
+ defer qm.l.Unlock()
+ attempt := qm.attemptsStarted
+ qm.attemptsStarted++
+ return attempt
}
+func (qm *queryMetrics) recordAttempt(addLatency time.Duration) {
+ qm.l.Lock()
+ defer qm.l.Unlock()
+ qm.totalLatency += addLatency.Nanoseconds()
+ qm.attemptsCompleted++
+}
+
+// Returns total number of attempts completed.
func (qm *queryMetrics) attempts() int {
- return int(atomic.LoadInt64(&qm.totalAttempts))
+ qm.l.RLock()
+ defer qm.l.RUnlock()
+ return qm.attemptsCompleted
}
func (qm *queryMetrics) latency() int64 {
- attempts := atomic.LoadInt64(&qm.totalAttempts)
+ qm.l.RLock()
+ defer qm.l.RUnlock()
+ attempts := qm.attemptsCompleted
if attempts == 0 {
- return atomic.LoadInt64(&qm.totalLatency)
+ return qm.totalLatency
}
- return atomic.LoadInt64(&qm.totalLatency) / attempts
+ return qm.totalLatency / int64(attempts)
}
type hostMetricsManager interface {
@@ -1055,6 +1081,7 @@ type Query struct {
prefetch float64
trace Tracer
observer QueryObserver
+ interceptor RequestInterceptor
session *Session
rt RetryPolicy
spec SpeculativeExecutionPolicy
@@ -1120,6 +1147,7 @@ func (q *Query) defaultsFromSession() {
q.pageSize = s.pageSize
q.trace = s.trace
q.observer = s.queryObserver
+ q.interceptor = s.interceptor
q.prefetch = s.prefetch
q.rt = s.cfg.RetryPolicy
q.serialCons = s.cfg.SerialConsistency
@@ -1194,6 +1222,14 @@ func (q *Query) Observer(observer QueryObserver) *Query {
return q
}
+// Interceptor specifies a query-level interceptor on this query.
+// The provided interceptor will be called every time this query is executed.
+// This replaces any session-level inherited interceptor for this query.
+func (q *Query) Interceptor(interceptor RequestInterceptor) *Query {
+ q.interceptor = interceptor
+ return q
+}
+
// PageSize will tell the iterator to fetch the result in pages of size n.
// This is useful for iterating over large result sets, but setting the
// page size too low might decrease the performance. This feature is only
@@ -1602,6 +1638,9 @@ type Iter struct {
host *HostInfo
metrics *queryMetrics
+ // rejectedByInterceptor is set when a RequestInterceptor aborted the
attempt before it was sent.
+ rejectedByInterceptor bool
+
getKeyspace func() string
keyspace string
routingInfo *queryRoutingInfo
@@ -2013,6 +2052,7 @@ type Batch struct {
spec SpeculativeExecutionPolicy
trace Tracer
observer BatchObserver
+ interceptor RequestInterceptor
session *Session
serialCons Consistency
defaultTimestamp bool
@@ -2038,6 +2078,7 @@ func (s *Session) Batch(typ BatchType) *Batch {
serialCons: s.cfg.SerialConsistency,
trace: s.trace,
observer: s.batchObserver,
+ interceptor: s.interceptor,
session: s,
Cons: s.cons,
defaultTimestamp: s.cfg.DefaultTimestamp,
@@ -2062,6 +2103,14 @@ func (b *Batch) Observer(observer BatchObserver) *Batch {
return b
}
+// Interceptor specifies a batch-level interceptor on this batch.
+// The provided interceptor will be called every time this batched query is
executed.
+// This replaces any session-level interceptor for this batch.
+func (b *Batch) Interceptor(interceptor RequestInterceptor) *Batch {
+ b.interceptor = interceptor
+ return b
+}
+
func (b *Batch) Keyspace() string {
return b.keyspace
}
@@ -2441,6 +2490,10 @@ type ObservedQuery struct {
// Query object associated with this request. Should be used as read
only.
Query *Query
+
+ // Whether the attempt was aborted by the interceptor before it was
issued. In this case, the request was never
+ // actually sent to the above host.
+ RejectedByInterceptor bool
}
// QueryObserver is the interface implemented by query observers / stat
collectors.
@@ -2490,6 +2543,10 @@ type ObservedBatch struct {
// Batch object associated with this request. Should be used as read
only.
Batch *Batch
+
+ // Whether the attempt was aborted by the interceptor before it was
issued. In this case, the request was never
+ // actually sent to the above host.
+ RejectedByInterceptor bool
}
// BatchObserver is the interface implemented by batch observers / stat
collectors.
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]