mirror of https://github.com/grafana/loki
Implement hierarchical queues for query scheduler (#8691)
### What this PR does / why we need it: This PR is the first step towards hierarchical queues within the query scheduler. Hierarchical queues can be used to ensure query fairness across multiple actors within a single tenant, such as individual (human) users using the same tenant. The change is described in the LID ["Query fairness across users within tenants"](https://grafana.com/docs/loki/next/lids/0003-queryfairnessinscheduler/). * Extract metrics for `RequestQueue` into their own `metrics` field, so that they can extended and passed more easily in the future. * Move mapping for tenant queues into its own data structure (ordered map) so its functionality can be reused. It also simplifies the code inside the `tenantQueues` struct. * Add the `LeafQueue` struct that implements hierarchical queues. Sub-queues are dequeued in a round-robin manner using the same implementation of `Mapping` that is also used by the tenant queues. ### Special notes for your reviewer Part of https://github.com/grafana/loki/pull/8585 Contains the changes from PR https://github.com/grafana/loki/pull/8722 **This PR does not yet make use of the new hierarchical queues, it only adds the relevant data structures. Wiring up will be done in a follow up PR.** While the new `Mapping` data structure adds an unsignificant overhead to the time/op I consider the simplification an overall better tradeoff. ```console $ benchstat scheduler-bench-old.txt scheduler-bench-new.txt name old time/op new time/op delta GetNextRequest-8 18.0µs ± 2% 20.5µs ± 3% +13.89% (p=0.008 n=5+5) QueueRequest-8 56.6µs ± 0% 53.5µs ± 3% -5.48% (p=0.008 n=5+5) name old alloc/op new alloc/op delta GetNextRequest-8 1.60kB ± 0% 1.60kB ± 0% ~ (all equal) QueueRequest-8 35.6kB ± 0% 29.8kB ± 0% -16.37% (p=0.008 n=5+5) name old allocs/op new allocs/op delta GetNextRequest-8 100 ± 0% 100 ± 0% ~ (all equal) QueueRequest-8 819 ± 0% 806 ± 0% -1.59% (p=0.008 n=5+5) ``` #### Complexity of operations | Operation | Function | Complexity | | --------- | -------- | ---------- | | Insert | `Put(key string, value *tenantQueue) bool` | `O(1)`| | Select | `GetNext(idx QueueIndex) *tenantQueue` | `O(1)` (best case) `O(n)` (worst case) | | Remove | `Remove(key string) bool` | `O(1)` | --- Signed-off-by: Christian Haudum <christian.haudum@gmail.com>pull/8734/head^2
parent
162a2d0057
commit
4b74a5b815
@ -0,0 +1,137 @@ |
||||
package queue |
||||
|
||||
import ( |
||||
"fmt" |
||||
"strings" |
||||
) |
||||
|
||||
type QueuePath []string //nolint:revive
|
||||
|
||||
// LeafQueue is an hierarchical queue implementation where each sub-queue
|
||||
// has the same guarantees to be chosen from.
|
||||
// Each queue has also a local queue, which gets chosen from first. Only if the
|
||||
// local queue is empty, items from the sub-queues are dequeued.
|
||||
type LeafQueue struct { |
||||
// local queue
|
||||
ch RequestChannel |
||||
// index of where this item is located in the mapping
|
||||
pos QueueIndex |
||||
// index of the sub-queues
|
||||
current QueueIndex |
||||
// mapping for sub-queues
|
||||
mapping *Mapping[*LeafQueue] |
||||
// name of the queue
|
||||
name string |
||||
// maximum queue size of the local queue
|
||||
size int |
||||
} |
||||
|
||||
// newLeafQueue creates a new LeafQueue instance
|
||||
func newLeafQueue(size int, name string) *LeafQueue { |
||||
m := &Mapping[*LeafQueue]{} |
||||
m.Init(64) // TODO(chaudum): What is a good initial value?
|
||||
return &LeafQueue{ |
||||
ch: make(RequestChannel, size), |
||||
pos: StartIndex, |
||||
current: StartIndex, |
||||
mapping: m, |
||||
name: name, |
||||
size: size, |
||||
} |
||||
} |
||||
|
||||
// add recursively adds queues based on given path
|
||||
func (q *LeafQueue) add(ident QueuePath) *LeafQueue { |
||||
if len(ident) == 0 { |
||||
return nil |
||||
} |
||||
curr := ident[0] |
||||
queue, created := q.getOrCreate(curr) |
||||
if created { |
||||
q.mapping.Put(queue.Name(), queue) |
||||
} |
||||
if len(ident[1:]) > 0 { |
||||
queue.add(ident[1:]) |
||||
} |
||||
return queue |
||||
} |
||||
|
||||
func (q *LeafQueue) getOrCreate(ident string) (subq *LeafQueue, created bool) { |
||||
subq = q.mapping.GetByKey(ident) |
||||
if subq == nil { |
||||
subq = newLeafQueue(q.size, ident) |
||||
created = true |
||||
} |
||||
return subq, created |
||||
} |
||||
|
||||
// Chan implements Queue
|
||||
func (q *LeafQueue) Chan() RequestChannel { |
||||
return q.ch |
||||
} |
||||
|
||||
// Dequeue implements Queue
|
||||
func (q *LeafQueue) Dequeue() Request { |
||||
// first, return item from local channel
|
||||
if len(q.ch) > 0 { |
||||
return <-q.ch |
||||
} |
||||
|
||||
// only if there are no items queued in the local queue, dequeue from sub-queues
|
||||
maxIter := q.mapping.Len() |
||||
for iters := 0; iters < maxIter; iters++ { |
||||
subq := q.mapping.GetNext(q.current) |
||||
if subq != nil { |
||||
q.current = subq.pos |
||||
item := subq.Dequeue() |
||||
if item != nil { |
||||
return item |
||||
} |
||||
q.mapping.Remove(subq.name) |
||||
} |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
// Name implements Queue
|
||||
func (q *LeafQueue) Name() string { |
||||
return q.name |
||||
} |
||||
|
||||
// Len implements Queue
|
||||
// It returns the length of the local queue and all sub-queues.
|
||||
// This may be expensive depending on the size of the queue tree.
|
||||
func (q *LeafQueue) Len() int { |
||||
count := len(q.ch) |
||||
for _, subq := range q.mapping.Values() { |
||||
count += subq.Len() |
||||
} |
||||
return count |
||||
} |
||||
|
||||
// Index implements Mapable
|
||||
func (q *LeafQueue) Pos() QueueIndex { |
||||
return q.pos |
||||
} |
||||
|
||||
// Index implements Mapable
|
||||
func (q *LeafQueue) SetPos(index QueueIndex) { |
||||
q.pos = index |
||||
} |
||||
|
||||
// String makes the queue printable
|
||||
func (q *LeafQueue) String() string { |
||||
sb := &strings.Builder{} |
||||
sb.WriteString("{") |
||||
fmt.Fprintf(sb, "name=%s, len=%d/%d, leafs=[", q.Name(), q.Len(), cap(q.ch)) |
||||
subqs := q.mapping.Values() |
||||
for i, m := range subqs { |
||||
sb.WriteString(m.String()) |
||||
if i < len(subqs)-1 { |
||||
sb.WriteString(",") |
||||
} |
||||
} |
||||
sb.WriteString("]") |
||||
sb.WriteString("}") |
||||
return sb.String() |
||||
} |
||||
@ -0,0 +1,171 @@ |
||||
package queue |
||||
|
||||
import ( |
||||
"testing" |
||||
|
||||
"github.com/stretchr/testify/require" |
||||
) |
||||
|
||||
type dummyRequest struct { |
||||
id int |
||||
} |
||||
|
||||
func r(id int) *dummyRequest { |
||||
return &dummyRequest{id} |
||||
} |
||||
|
||||
func TestLeafQueue(t *testing.T) { |
||||
|
||||
t.Run("add sub queues recursively", func(t *testing.T) { |
||||
pathA := QueuePath([]string{"l0", "l1", "l3"}) |
||||
pathB := QueuePath([]string{"l0", "l2", "l3"}) |
||||
|
||||
q := newLeafQueue(1, "root") |
||||
require.NotNil(t, q) |
||||
require.Equal(t, "root", q.Name()) |
||||
require.Equal(t, 0, q.Len()) |
||||
require.Equal(t, 0, q.mapping.Len()) |
||||
|
||||
q.add(pathA) |
||||
require.Equal(t, 1, q.mapping.Len()) |
||||
|
||||
q.add(pathB) |
||||
require.Equal(t, 1, q.mapping.Len()) |
||||
}) |
||||
|
||||
t.Run("enqueue/dequeue to/from subqueues", func(t *testing.T) { |
||||
/** |
||||
root: [0] |
||||
a: [1] |
||||
b: [2] |
||||
b0: [20] |
||||
b1: [21] |
||||
c: [3] |
||||
c0: [30] |
||||
c00: [300] |
||||
c01: [301] |
||||
c1: [31] |
||||
c10: [310] |
||||
c11: [311] |
||||
**/ |
||||
paths := []QueuePath{ |
||||
QueuePath([]string{"a"}), |
||||
QueuePath([]string{"b", "b0"}), |
||||
QueuePath([]string{"b", "b1"}), |
||||
QueuePath([]string{"c", "c0", "c00"}), |
||||
QueuePath([]string{"c", "c0", "c01"}), |
||||
QueuePath([]string{"c", "c1", "c10"}), |
||||
QueuePath([]string{"c", "c1", "c11"}), |
||||
} |
||||
|
||||
q := newLeafQueue(10, "root") |
||||
require.NotNil(t, q) |
||||
for _, p := range paths { |
||||
q.add(p) |
||||
} |
||||
|
||||
require.Equal(t, 3, q.mapping.Len()) |
||||
|
||||
// no items in any queues
|
||||
require.Equal(t, 0, q.Len()) |
||||
|
||||
q.Chan() <- r(0) |
||||
require.Equal(t, 1, q.Len()) |
||||
|
||||
q.mapping.GetByKey("a").Chan() <- r(1) |
||||
require.Equal(t, 2, q.Len()) |
||||
|
||||
q.mapping.GetByKey("b").Chan() <- r(2) |
||||
q.mapping.GetByKey("b").mapping.GetByKey("b0").Chan() <- r(20) |
||||
q.mapping.GetByKey("b").mapping.GetByKey("b1").Chan() <- r(21) |
||||
require.Equal(t, 5, q.Len()) |
||||
|
||||
q.mapping.GetByKey("c").Chan() <- r(3) |
||||
q.mapping.GetByKey("c").mapping.GetByKey("c0").Chan() <- r(30) |
||||
q.mapping.GetByKey("c").mapping.GetByKey("c0").mapping.GetByKey("c00").Chan() <- r(300) |
||||
q.mapping.GetByKey("c").mapping.GetByKey("c0").mapping.GetByKey("c01").Chan() <- r(301) |
||||
q.mapping.GetByKey("c").mapping.GetByKey("c1").Chan() <- r(31) |
||||
q.mapping.GetByKey("c").mapping.GetByKey("c1").mapping.GetByKey("c10").Chan() <- r(310) |
||||
q.mapping.GetByKey("c").mapping.GetByKey("c1").mapping.GetByKey("c11").Chan() <- r(311) |
||||
require.Equal(t, 12, q.Len()) |
||||
t.Log(q) |
||||
|
||||
items := make([]int, 0, q.Len()) |
||||
|
||||
for q.Len() > 0 { |
||||
r := q.Dequeue() |
||||
if r == nil { |
||||
continue |
||||
} |
||||
items = append(items, r.(*dummyRequest).id) |
||||
} |
||||
require.Len(t, items, 12) |
||||
require.Equal(t, []int{0, 1, 2, 3, 20, 30, 21, 31, 300, 310, 301, 311}, items) |
||||
}) |
||||
|
||||
t.Run("dequeue ensure round-robin", func(t *testing.T) { |
||||
/** |
||||
root: |
||||
a: [100, 101, 102] |
||||
b: [200] |
||||
c: [300, 301] |
||||
**/ |
||||
paths := []QueuePath{ |
||||
QueuePath([]string{"a"}), |
||||
QueuePath([]string{"b"}), |
||||
QueuePath([]string{"c"}), |
||||
} |
||||
|
||||
q := newLeafQueue(10, "root") |
||||
require.NotNil(t, q) |
||||
for _, p := range paths { |
||||
q.add(p) |
||||
} |
||||
|
||||
require.Equal(t, 3, q.mapping.Len()) |
||||
|
||||
// no items in any queues
|
||||
require.Equal(t, 0, q.Len()) |
||||
|
||||
q.mapping.GetByKey("a").Chan() <- r(100) |
||||
q.mapping.GetByKey("a").Chan() <- r(101) |
||||
q.mapping.GetByKey("a").Chan() <- r(102) |
||||
q.mapping.GetByKey("b").Chan() <- r(200) |
||||
q.mapping.GetByKey("c").Chan() <- r(300) |
||||
q.mapping.GetByKey("c").Chan() <- r(301) |
||||
|
||||
t.Log(q) |
||||
|
||||
items := make([]int, 0, q.Len()) |
||||
|
||||
for q.Len() > 0 { |
||||
r := q.Dequeue() |
||||
if r == nil { |
||||
continue |
||||
} |
||||
items = append(items, r.(*dummyRequest).id) |
||||
} |
||||
require.Len(t, items, 6) |
||||
require.Equal(t, []int{100, 200, 300, 101, 301, 102}, items) |
||||
}) |
||||
|
||||
t.Run("empty sub-queues are removed", func(t *testing.T) { |
||||
q := newLeafQueue(10, "root") |
||||
q.add(QueuePath{"a"}) |
||||
q.add(QueuePath{"b"}) |
||||
|
||||
q.mapping.GetByKey("a").Chan() <- r(1) |
||||
q.mapping.GetByKey("b").Chan() <- r(2) |
||||
|
||||
t.Log(q) |
||||
|
||||
// drain queue
|
||||
r := q.Dequeue() |
||||
for r != nil { |
||||
r = q.Dequeue() |
||||
} |
||||
|
||||
require.Nil(t, q.mapping.GetByKey("a")) |
||||
require.Nil(t, q.mapping.GetByKey("b")) |
||||
}) |
||||
} |
||||
@ -0,0 +1,117 @@ |
||||
package queue |
||||
|
||||
type Mapable interface { |
||||
*tenantQueue | *LeafQueue |
||||
// https://github.com/golang/go/issues/48522#issuecomment-924348755
|
||||
Pos() QueueIndex |
||||
SetPos(index QueueIndex) |
||||
} |
||||
|
||||
var empty = string([]byte{byte(0)}) |
||||
|
||||
// Mapping is a map-like data structure that allows accessing its items not
|
||||
// only by key but also by index.
|
||||
// When an item is removed, the iinternal key array is not resized, but the
|
||||
// removed place is marked as empty. This allows to remove keys without
|
||||
// changing the index of the remaining items after the removed key.
|
||||
// Mapping uses *tenantQueue as concrete value and keys of type string.
|
||||
// The data structure is not thread-safe.
|
||||
type Mapping[v Mapable] struct { |
||||
m map[string]v |
||||
keys []string |
||||
empty []QueueIndex |
||||
} |
||||
|
||||
func (m *Mapping[v]) Init(size int) { |
||||
m.m = make(map[string]v, size) |
||||
m.keys = make([]string, 0, size) |
||||
m.empty = make([]QueueIndex, 0, size) |
||||
} |
||||
|
||||
func (m *Mapping[v]) Put(key string, value v) bool { |
||||
// do not allow empty string or 0 byte string as key
|
||||
if key == "" || key == empty { |
||||
return false |
||||
} |
||||
if len(m.empty) == 0 { |
||||
value.SetPos(QueueIndex(len(m.keys))) |
||||
m.keys = append(m.keys, key) |
||||
} else { |
||||
idx := m.empty[0] |
||||
m.empty = m.empty[1:] |
||||
m.keys[idx] = key |
||||
value.SetPos(idx) |
||||
} |
||||
m.m[key] = value |
||||
return true |
||||
} |
||||
|
||||
func (m *Mapping[v]) Get(idx QueueIndex) v { |
||||
if len(m.keys) == 0 { |
||||
return nil |
||||
} |
||||
k := m.keys[idx] |
||||
return m.GetByKey(k) |
||||
} |
||||
|
||||
func (m *Mapping[v]) GetNext(idx QueueIndex) v { |
||||
if len(m.keys) == 0 { |
||||
return nil |
||||
} |
||||
|
||||
// convert to int
|
||||
i := int(idx) |
||||
// proceed to the next index
|
||||
i = i + 1 |
||||
// start from beginning if next index exceeds slice length
|
||||
if i >= len(m.keys) { |
||||
i = 0 |
||||
} |
||||
|
||||
for i < len(m.keys) { |
||||
k := m.keys[i] |
||||
if k != empty { |
||||
return m.GetByKey(k) |
||||
} |
||||
i++ |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
func (m *Mapping[v]) GetByKey(key string) v { |
||||
// do not allow empty string or 0 byte string as key
|
||||
if key == "" || key == empty { |
||||
return nil |
||||
} |
||||
return m.m[key] |
||||
} |
||||
|
||||
func (m *Mapping[v]) Remove(key string) bool { |
||||
e := m.m[key] |
||||
if e == nil { |
||||
return false |
||||
} |
||||
delete(m.m, key) |
||||
m.keys[e.Pos()] = empty |
||||
m.empty = append(m.empty, e.Pos()) |
||||
return true |
||||
} |
||||
|
||||
func (m *Mapping[v]) Keys() []string { |
||||
return m.keys |
||||
} |
||||
|
||||
func (m *Mapping[v]) Values() []v { |
||||
values := make([]v, 0, len(m.keys)) |
||||
for _, k := range m.keys { |
||||
if k == empty { |
||||
continue |
||||
} |
||||
values = append(values, m.m[k]) |
||||
} |
||||
return values |
||||
} |
||||
|
||||
func (m *Mapping[v]) Len() int { |
||||
return len(m.keys) - len(m.empty) |
||||
} |
||||
@ -0,0 +1,85 @@ |
||||
package queue |
||||
|
||||
import ( |
||||
"testing" |
||||
|
||||
"github.com/stretchr/testify/require" |
||||
) |
||||
|
||||
func TestQueueMapping(t *testing.T) { |
||||
// Individual sub-tests in this test case are reflecting a scenario and need
|
||||
// to be executed in sequential order.
|
||||
|
||||
m := &Mapping[*LeafQueue]{} |
||||
m.Init(16) |
||||
|
||||
require.Equal(t, m.Len(), 0) |
||||
|
||||
t.Run("put item to mapping", func(t *testing.T) { |
||||
q1 := newLeafQueue(10, "queue-1") |
||||
m.Put(q1.Name(), q1) |
||||
require.Equal(t, 1, m.Len()) |
||||
require.Equal(t, []string{"queue-1"}, m.Keys()) |
||||
}) |
||||
|
||||
t.Run("insert order is preserved if there is no empty slot", func(t *testing.T) { |
||||
q2 := newLeafQueue(10, "queue-2") |
||||
m.Put(q2.Name(), q2) |
||||
require.Equal(t, 2, m.Len()) |
||||
require.Equal(t, []string{"queue-1", "queue-2"}, m.Keys()) |
||||
}) |
||||
|
||||
t.Run("insert into empty slot if item was removed previously", func(t *testing.T) { |
||||
ok := m.Remove("queue-1") |
||||
require.True(t, ok) |
||||
require.Equal(t, 1, m.Len()) |
||||
q3 := newLeafQueue(10, "queue-3") |
||||
m.Put(q3.Name(), q3) |
||||
require.Equal(t, 2, m.Len()) |
||||
require.Equal(t, []string{"queue-3", "queue-2"}, m.Keys()) |
||||
}) |
||||
|
||||
t.Run("insert order is preserved across keys and values", func(t *testing.T) { |
||||
q4 := newLeafQueue(10, "queue-4") |
||||
m.Put(q4.Name(), q4) |
||||
require.Equal(t, 3, m.Len()) |
||||
for idx, v := range m.Values() { |
||||
require.Equal(t, v.Name(), m.Keys()[idx]) |
||||
} |
||||
}) |
||||
|
||||
t.Run("get by key", func(t *testing.T) { |
||||
key := "queue-2" |
||||
item := m.GetByKey(key) |
||||
require.Equal(t, key, item.Name()) |
||||
require.Equal(t, QueueIndex(1), item.Pos()) |
||||
}) |
||||
|
||||
t.Run("get by empty key returns nil", func(t *testing.T) { |
||||
require.Nil(t, m.GetByKey("")) |
||||
require.Nil(t, m.GetByKey(empty)) |
||||
}) |
||||
|
||||
t.Run("get next item based on index must not skip when items are removed", func(t *testing.T) { |
||||
item := m.GetNext(StartIndex) |
||||
require.Equal(t, "queue-3", item.Name()) |
||||
item = m.GetNext(item.Pos()) |
||||
require.Equal(t, "queue-2", item.Name()) |
||||
m.Remove(item.Name()) |
||||
item = m.GetNext(item.Pos()) |
||||
require.Equal(t, "queue-4", item.Name()) |
||||
}) |
||||
|
||||
t.Run("get next item out of range returns first item", func(t *testing.T) { |
||||
item := m.GetNext(100) |
||||
require.Equal(t, "queue-3", item.Name()) |
||||
}) |
||||
|
||||
t.Run("get next item skips empty slots", func(t *testing.T) { |
||||
item := m.GetNext(100) |
||||
require.Equal(t, "queue-3", item.Name()) |
||||
item = m.GetNext(item.Pos()) |
||||
require.Equal(t, "queue-4", item.Name()) |
||||
}) |
||||
|
||||
} |
||||
Loading…
Reference in new issue