Skip to content

Commit b38e49a

Browse files
committed
feat(distributed_execution): add core query planning primitives and shard-count flag
Add the core building blocks for distributed query execution (fragment_metadata, deduplicate_node/ShardedRemoteExecutions, distributed_optimizer, codec) and the QFE-level -querier.distributed-exec-shard-count flag that drives how many shards the optimizer fans a shardable aggregation into (0 disables it). Signed-off-by: Essam Eldaly <eeldaly@amazon.com>
1 parent ded2c6b commit b38e49a

6 files changed

Lines changed: 324 additions & 19 deletions

File tree

pkg/distributed_execution/codec.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,16 @@ func unmarshalNode(data []byte) (logicalplan.Node, error) {
191191
return nil, err
192192
}
193193
return r, nil
194+
case ShardedRemoteExecutionNode:
195+
s := &ShardedRemoteExecutions{}
196+
for _, c := range t.Children {
197+
child, err := unmarshalNode(c)
198+
if err != nil {
199+
return nil, err
200+
}
201+
s.Expressions = append(s.Expressions, child)
202+
}
203+
return s, nil
194204
}
195205
return nil, nil
196206
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package distributed_execution
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/prometheus/prometheus/promql/parser"
8+
"github.com/prometheus/prometheus/storage"
9+
"github.com/thanos-io/promql-engine/execution"
10+
"github.com/thanos-io/promql-engine/execution/exchange"
11+
"github.com/thanos-io/promql-engine/execution/model"
12+
"github.com/thanos-io/promql-engine/logicalplan"
13+
"github.com/thanos-io/promql-engine/query"
14+
)
15+
16+
const (
17+
ShardedRemoteExecutionNode logicalplan.NodeType = "ShardedRemoteExecutionNode"
18+
)
19+
20+
var _ logicalplan.UserDefinedExpr = (*ShardedRemoteExecutions)(nil)
21+
22+
// ShardedRemoteExecutions is a custom logical-plan node that fans a single
23+
// aggregation out into one sub-expression per shard. At execution time each
24+
// sub-expression is built into its own operator and the partial results are
25+
// coalesced together, so a parent aggregation (e.g. sum) can combine them.
26+
//
27+
// NOTE: This is experimental. It is reconciled to compile against the current
28+
// Thanos engine API, but it is not yet wired end-to-end (see caveats in the
29+
// port notes): the node has no JSON (un)marshaling for codec round-tripping,
30+
// and the shard matcher injected by the optimizer does not match the merged
31+
// metric-name-hash storage sharding mechanism.
32+
type ShardedRemoteExecutions struct {
33+
Expressions []logicalplan.Node `json:"-"`
34+
}
35+
36+
// MakeExecutionOperator builds one operator per shard sub-expression and
37+
// coalesces them into a single operator stream for the parent aggregation.
38+
func (r *ShardedRemoteExecutions) MakeExecutionOperator(
39+
ctx context.Context,
40+
opts *query.Options,
41+
hints storage.SelectHints,
42+
) (model.VectorOperator, error) {
43+
operators := make([]model.VectorOperator, len(r.Expressions))
44+
var err error
45+
for i := range operators {
46+
operators[i], err = execution.New(ctx, r.Expressions[i], nil, opts)
47+
if err != nil {
48+
return nil, err
49+
}
50+
}
51+
coalesce := exchange.NewCoalesce(opts, 0, operators...)
52+
return exchange.NewConcurrent(coalesce, 2, opts), nil
53+
}
54+
55+
func (r *ShardedRemoteExecutions) Clone() logicalplan.Node {
56+
clone := &ShardedRemoteExecutions{Expressions: make([]logicalplan.Node, len(r.Expressions))}
57+
for i, e := range r.Expressions {
58+
clone.Expressions[i] = e.Clone()
59+
}
60+
return clone
61+
}
62+
63+
func (r *ShardedRemoteExecutions) Children() []*logicalplan.Node {
64+
children := make([]*logicalplan.Node, len(r.Expressions))
65+
for i := range r.Expressions {
66+
children[i] = &r.Expressions[i]
67+
}
68+
return children
69+
}
70+
71+
func (r *ShardedRemoteExecutions) String() string {
72+
return fmt.Sprintf("shard(%d)", len(r.Expressions))
73+
}
74+
75+
func (r *ShardedRemoteExecutions) ReturnType() parser.ValueType { return parser.ValueTypeVector }
76+
77+
func (r *ShardedRemoteExecutions) Type() logicalplan.NodeType { return ShardedRemoteExecutionNode }
Lines changed: 100 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,84 @@
11
package distributed_execution
22

33
import (
4+
"fmt"
5+
6+
"github.com/prometheus/prometheus/model/labels"
7+
"github.com/prometheus/prometheus/promql/parser"
48
"github.com/prometheus/prometheus/util/annotations"
59
"github.com/thanos-io/promql-engine/logicalplan"
610
"github.com/thanos-io/promql-engine/query"
711
)
812

9-
// This is a simplified implementation that only handles binary aggregation cases
10-
// Future versions of the distributed optimizer are expected to:
11-
// - Support more complex query patterns
12-
// - Incorporate diverse optimization strategies
13-
// - Extend support to node types beyond binary operations
14-
15-
type DistributedOptimizer struct{}
13+
// This optimizer inserts Remote nodes so portions of the plan can be executed
14+
// remotely. It supports two strategies:
15+
// - Binary-aggregation splitting: each operand of a binary expression that
16+
// contains an aggregation is offloaded to a Remote fragment.
17+
// - Sharded-aggregation splitting (experimental): a sum/count aggregation is
18+
// rewritten into sum(ShardedRemoteExecutions{per-shard sub-aggregations}),
19+
// so a single aggregation can be evaluated across shards and merged.
20+
//
21+
// NOTE: The sharded-aggregation path is experimental and not yet wired
22+
// end-to-end (no codec round-tripping for ShardedRemoteExecutions, and the
23+
// injected shard matcher does not match the merged metric-name-hash storage
24+
// sharding). It is reconciled here only to compile and to allow local
25+
// iteration.
26+
type DistributedOptimizer struct {
27+
// ShardCount is the number of shards the sharded-aggregation strategy fans
28+
// an aggregation out into. It should match the compactor's per-tenant
29+
// metric-name-shard-size so per-shard subqueries route to disjoint blocks.
30+
ShardCount int
31+
}
1632

1733
func (d *DistributedOptimizer) Optimize(root logicalplan.Node, opts *query.Options) (logicalplan.Node, annotations.Annotations) {
1834
warns := annotations.New()
1935

20-
// insert remote nodes
21-
logicalplan.TraverseBottomUp(nil, &root, func(parent, current *logicalplan.Node) bool {
22-
23-
if (*current).Type() == logicalplan.BinaryNode && d.hasAggregation(current) {
24-
ch := (*current).Children()
36+
if root == nil {
37+
return root, *warns
38+
}
2539

26-
for _, child := range ch {
27-
temp := (*child).Clone()
28-
*child = NewRemoteNode(temp)
29-
*(*child).Children()[0] = temp
40+
// Strategy 1 (experimental): shard aggregations (sum/count) across shards.
41+
// Only runs when a positive shard count is configured; otherwise it is left
42+
// to the pre-existing binary-splitting strategy below.
43+
sharded := false
44+
if shardCount := d.ShardCount; shardCount > 0 {
45+
logicalplan.TraverseBottomUp(nil, &root, func(parent, current *logicalplan.Node) bool {
46+
if aggr, ok := (*current).(*logicalplan.Aggregation); ok {
47+
switch aggr.Op {
48+
case parser.SUM, parser.COUNT:
49+
subqueries := newRemoteAggregation(aggr, shardCount)
50+
*current = &logicalplan.Aggregation{
51+
Op: parser.SUM,
52+
Expr: &ShardedRemoteExecutions{Expressions: subqueries},
53+
Param: aggr.Param,
54+
Grouping: aggr.Grouping,
55+
Without: aggr.Without,
56+
}
57+
sharded = true
58+
}
59+
return true
3060
}
31-
}
61+
return false
62+
})
63+
}
3264

33-
return false
34-
})
65+
// Strategy 2 (pre-existing): offload binary-expression operands that
66+
// contain an aggregation to Remote fragments. Skipped when the
67+
// aggregation-sharding pass already distributed the plan, to avoid
68+
// double-distributing the same subtrees.
69+
if !sharded {
70+
logicalplan.TraverseBottomUp(nil, &root, func(parent, current *logicalplan.Node) bool {
71+
if (*current).Type() == logicalplan.BinaryNode && d.hasAggregation(current) {
72+
ch := (*current).Children()
73+
for _, child := range ch {
74+
temp := (*child).Clone()
75+
*child = NewRemoteNode(temp)
76+
*(*child).Children()[0] = temp
77+
}
78+
}
79+
return false
80+
})
81+
}
3582

3683
return root, *warns
3784
}
@@ -47,3 +94,37 @@ func (d *DistributedOptimizer) hasAggregation(root *logicalplan.Node) bool {
4794
})
4895
return isAggr
4996
}
97+
98+
// newRemoteAggregation produces one Remote-wrapped per-shard copy of the given
99+
// aggregation, tagging each copy's selectors with its shard identity.
100+
func newRemoteAggregation(rootAggregation *logicalplan.Aggregation, shardNum int) []logicalplan.Node {
101+
nodes := make([]logicalplan.Node, 0, shardNum)
102+
for i := 0; i < shardNum; i++ {
103+
rc := rootAggregation.Expr.Clone()
104+
node := insertShardNum(&Remote{
105+
Expr: &logicalplan.Aggregation{
106+
Op: rootAggregation.Op,
107+
Expr: rc,
108+
Param: rootAggregation.Param,
109+
Grouping: rootAggregation.Grouping,
110+
Without: rootAggregation.Without,
111+
},
112+
}, shardNum, i)
113+
nodes = append(nodes, node)
114+
}
115+
return nodes
116+
}
117+
118+
// insertShardNum tags every vector selector in the subtree with the shard it
119+
// belongs to. NOTE: the label used here does not match the merged
120+
// metric-name-hash storage sharding; this is experimental.
121+
func insertShardNum(root logicalplan.Node, shardCount int, shardIdx int) logicalplan.Node {
122+
logicalplan.TraverseBottomUp(nil, &root, func(parent, current *logicalplan.Node) bool {
123+
if (*current).Type() == logicalplan.VectorSelectorNode {
124+
cur := (*current).(*logicalplan.VectorSelector)
125+
cur.LabelMatchers = append(cur.LabelMatchers, labels.MustNewMatcher(labels.MatchEqual, "__cortex_ingester_shard__", fmt.Sprintf("%d_%d", shardCount, shardIdx)))
126+
}
127+
return false
128+
})
129+
return root
130+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package distributed_execution
2+
3+
import (
4+
"context"
5+
)
6+
7+
type fragmentMetadataKey struct{}
8+
9+
type fragmentMetadata struct {
10+
queryID uint64
11+
fragmentID uint64
12+
childIDToAddr map[uint64]string
13+
isRoot bool
14+
}
15+
16+
// InjectFragmentMetaData stores the distributed execution metadata for the current
17+
// fragment into the context. This metadata is propagated from the query-scheduler to
18+
// the querier so that the querier knows which fragment it is executing, where to pull
19+
// its child fragments' results from, and whether it is the root (coordinator) fragment.
20+
func InjectFragmentMetaData(ctx context.Context, fragmentID uint64, queryID uint64, isRoot bool, childIDToAddr map[uint64]string) context.Context {
21+
return context.WithValue(ctx, fragmentMetadataKey{}, fragmentMetadata{
22+
queryID: queryID,
23+
fragmentID: fragmentID,
24+
childIDToAddr: childIDToAddr,
25+
isRoot: isRoot,
26+
})
27+
}
28+
29+
// ExtractFragmentMetaData retrieves the distributed execution metadata for the current
30+
// fragment from the context. The final return value reports whether metadata was present.
31+
func ExtractFragmentMetaData(ctx context.Context) (isRoot bool, queryID uint64, fragmentID uint64, childAddrs map[uint64]string, ok bool) {
32+
metadata, ok := ctx.Value(fragmentMetadataKey{}).(fragmentMetadata)
33+
if !ok {
34+
return false, 0, 0, nil, false
35+
}
36+
return metadata.isRoot, metadata.queryID, metadata.fragmentID, metadata.childIDToAddr, true
37+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package distributed_execution
2+
3+
import (
4+
"context"
5+
"reflect"
6+
"testing"
7+
)
8+
9+
func TestFragmentMetadata(t *testing.T) {
10+
tests := []struct {
11+
name string
12+
queryID uint64
13+
fragID uint64
14+
isRoot bool
15+
childIDs []uint64
16+
childAddr []string
17+
}{
18+
{
19+
name: "basic test",
20+
queryID: 123,
21+
fragID: 456,
22+
isRoot: true,
23+
childIDs: []uint64{1, 2, 3},
24+
childAddr: []string{"addr1", "addr2", "addr3"},
25+
},
26+
{
27+
name: "empty children",
28+
queryID: 789,
29+
fragID: 101,
30+
isRoot: false,
31+
childIDs: []uint64{},
32+
childAddr: []string{},
33+
},
34+
{
35+
name: "single child",
36+
queryID: 999,
37+
fragID: 888,
38+
isRoot: true,
39+
childIDs: []uint64{42},
40+
childAddr: []string{"10.0.0.1:8080"},
41+
},
42+
}
43+
44+
for _, tt := range tests {
45+
t.Run(tt.name, func(t *testing.T) {
46+
// injection
47+
ctx := context.Background()
48+
49+
childIDToAddr := make(map[uint64]string)
50+
for i, childID := range tt.childIDs {
51+
childIDToAddr[childID] = tt.childAddr[i]
52+
}
53+
newCtx := InjectFragmentMetaData(ctx, tt.fragID, tt.queryID, tt.isRoot, childIDToAddr)
54+
55+
// extraction
56+
isRoot, queryID, fragmentID, childAddrs, ok := ExtractFragmentMetaData(newCtx)
57+
58+
// verify results
59+
if !ok {
60+
t.Error("ExtractFragmentMetaData failed, ok = false")
61+
}
62+
63+
if isRoot != tt.isRoot {
64+
t.Errorf("isRoot = %v, want %v", isRoot, tt.isRoot)
65+
}
66+
67+
if queryID != tt.queryID {
68+
t.Errorf("queryID = %v, want %v", queryID, tt.queryID)
69+
}
70+
71+
if fragmentID != tt.fragID {
72+
t.Errorf("fragmentID = %v, want %v", fragmentID, tt.fragID)
73+
}
74+
75+
// create expected childIDToAddr map
76+
expectedChildAddrs := make(map[uint64]string)
77+
for i, childID := range tt.childIDs {
78+
expectedChildAddrs[childID] = tt.childAddr[i]
79+
}
80+
81+
if !reflect.DeepEqual(childAddrs, expectedChildAddrs) {
82+
t.Errorf("childAddrs = %v, want %v", childAddrs, expectedChildAddrs)
83+
}
84+
})
85+
}
86+
}
87+
88+
func TestExtractFragmentMetaDataWithEmptyContext(t *testing.T) {
89+
ctx := context.Background()
90+
isRoot, queryID, fragmentID, childAddrs, ok := ExtractFragmentMetaData(ctx)
91+
if ok {
92+
t.Error("ExtractFragmentMetaData should return ok=false for empty context")
93+
}
94+
if isRoot || queryID != 0 || fragmentID != 0 || childAddrs != nil {
95+
t.Error("ExtractFragmentMetaData should return zero values for empty context")
96+
}
97+
}

pkg/querier/querier.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,8 @@ type Config struct {
101101

102102
DistributedExecEnabled bool `yaml:"distributed_exec_enabled" doc:"hidden"`
103103

104+
DistributedExecShardCount int `yaml:"distributed_exec_shard_count" doc:"hidden"`
105+
104106
HonorProjectionHints bool `yaml:"honor_projection_hints"`
105107

106108
// Timeout classification flags for converting 5XX to 4XX on expensive queries.
@@ -164,6 +166,7 @@ func (cfg *Config) RegisterFlags(f *flag.FlagSet) {
164166
f.StringVar(&cfg.ParquetQueryableDefaultBlockStore, "querier.parquet-queryable-default-block-store", string(parquetBlockStore), "[Experimental] Parquet queryable's default block store to query. Valid options are tsdb and parquet. If it is set to tsdb, parquet queryable always fallback to store gateway.")
165167
f.BoolVar(&cfg.HonorProjectionHints, "querier.honor-projection-hints", false, "[Experimental] If true, querier will honor projection hints and only materialize requested labels. Today, projection is only effective when Parquet Queryable is enabled. Projection is only applied when not querying mixed block types (parquet and non-parquet) and not querying ingesters.")
166168
f.BoolVar(&cfg.DistributedExecEnabled, "querier.distributed-exec-enabled", false, "Experimental: Enables distributed execution of queries by passing logical query plan fragments to downstream components.")
169+
f.IntVar(&cfg.DistributedExecShardCount, "querier.distributed-exec-shard-count", 0, "Experimental: Number of shards the distributed-execution optimizer fans a shardable aggregation into. Only effective when -querier.distributed-exec-enabled is true. 0 (default) disables aggregation sharding.")
167170
f.BoolVar(&cfg.ParquetQueryableFallbackDisabled, "querier.parquet-queryable-fallback-disabled", false, "[Experimental] Disable Parquet queryable to fallback queries to Store Gateway if the block is not available as Parquet files but available in TSDB. Setting this to true will disable the fallback and users can remove Store Gateway. But need to make sure Parquet files are created before it is queryable.")
168171
f.BoolVar(&cfg.TimeoutClassificationEnabled, "querier.timeout-classification-enabled", false, "If true, classify query timeouts as 4XX (user error) or 5XX (system error) based on phase timing.")
169172
f.DurationVar(&cfg.TimeoutClassificationDeadline, "querier.timeout-classification-deadline", time.Minute+59*time.Second, "The total time before the querier proactively cancels a query for timeout classification. Set this a few seconds less than the querier timeout.")

0 commit comments

Comments
 (0)