Files
pubsub/server/mqtt_ex_bench_test.go
Ivan Kozlovic feafe12e53 [IMPROVED] MQTT: Retained msgs handling with cross account and transform
This PR allows retained messages to work even if a situation where
the retained message stream sources from another account and has
subject transform.

Regardless of that, the addition and removal of retained messages are
now happening from the retained message stream's JS consumer callback.
Previously, when receiving a retained message with an empty payload
the server would remove the retained message on the given subject
and notify the rest of the servers in the network with a NATS message.
With this PR, the server simply stores the message with the empty
body and all servers in the cluster can process this "delete" of
a retained message by handling the fact that the payload is empty
in `processRetainedMsg` which is the callback for the JS consumer.

This PR still keeps the network notification for backward compatibility
and makes sure that the older server cannot decode (and therefore
ignore) a stored retained message with an empty payload.

This PR also fixes the fact that if a retained message can no longer
be found in the stream, it is appropriately removed from the map.

Also, when recovering a retained message, the topic/subject is
recreated based on the subject of the message read from the stream
(minus the prefix). This allows subject transform to be properly
supported.

The server was previously adding shadow subscriptions' subject
in the list of retained message subjects that need to be loaded,
which I believe is wrong since those are on different account.
No message would be found on the origin subject on the import
account.

The PR also includes various other fixes. One thing not done in
this PR is to replace the use of Sublist with a GenericSublist
that could be more useful/efficient, but would have to port
the ReverseMatch to the generic sublist. This is trivial but
would have added more changes to this already big PR. This can
be done in a different one.

Signed-off-by: Ivan Kozlovic <ivan@synadia.com>
2025-12-11 13:56:36 -07:00

328 lines
8.0 KiB
Go

// Copyright 2024-2025 The NATS Authors
// Licensed 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.
//go:build !skip_mqtt_tests
package server
import (
"fmt"
"strconv"
"testing"
"time"
)
const (
KB = 1024
)
type mqttBenchMatrix struct {
QOS []int
MessageSize []int
Topics []int
Publishers []int
Subscribers []int
}
type mqttBenchContext struct {
QOS int
MessageSize int
Topics int
Publishers int
Subscribers int
Host string
Port int
}
var mqttBenchDefaultMatrix = mqttBenchMatrix{
QOS: []int{0, 1, 2},
MessageSize: []int{100, 1 * KB, 10 * KB},
Topics: []int{100},
Publishers: []int{1},
Subscribers: []int{1},
}
type MQTTBenchmarkResult struct {
Ops int `json:"ops"`
NS map[string]time.Duration `json:"ns"`
Bytes int64 `json:"bytes"`
}
func BenchmarkXMQTT(b *testing.B) {
if mqttTestCommandPath == "" {
b.Skip(`"mqtt-test" command is not found in $PATH.`)
}
bc := mqttBenchContext{}
b.Run("Server", func(b *testing.B) {
b.Cleanup(bc.startServer(b, false))
bc.runAll(b)
})
b.Run("Cluster", func(b *testing.B) {
b.Cleanup(bc.startCluster(b, false))
bc.runAll(b)
})
b.Run("Server-no-RMSCache", func(b *testing.B) {
b.Cleanup(bc.startServer(b, true))
bc.benchmarkSubRet(b)
})
b.Run("Cluster-no-RMSCache", func(b *testing.B) {
b.Cleanup(bc.startCluster(b, true))
bc.benchmarkSubRet(b)
})
}
func (bc mqttBenchContext) runAll(b *testing.B) {
bc.benchmarkPub(b)
bc.benchmarkPubRetained(b)
bc.benchmarkPubSub(b)
bc.benchmarkSubRet(b)
}
// makes a copy of bc
func (bc mqttBenchContext) benchmarkPub(b *testing.B) {
m := mqttBenchDefaultMatrix.
NoSubscribers().
NoTopics()
b.Run("PUB", func(b *testing.B) {
m.runMatrix(b, bc, func(b *testing.B, bc *mqttBenchContext) {
bc.runAndReport(b, "pub",
"--qos", strconv.Itoa(bc.QOS),
"--messages", strconv.Itoa(b.N),
"--size", strconv.Itoa(bc.MessageSize),
"--publishers", strconv.Itoa(bc.Publishers),
)
})
})
}
// makes a copy of bc
func (bc mqttBenchContext) benchmarkPubRetained(b *testing.B) {
// This bench is meaningless for QOS0 since the client considers the message
// sent as soon as it's written out. It is also useless for QOS2 since the
// flow takes a lot longer, and the difference of publishing as retained or
// not is lost in the noise.
m := mqttBenchDefaultMatrix.
NoSubscribers().
NoTopics().
QOS1Only()
b.Run("PUBRET", func(b *testing.B) {
m.runMatrix(b, bc, func(b *testing.B, bc *mqttBenchContext) {
bc.runAndReport(b, "pub", "--retain",
"--qos", strconv.Itoa(bc.QOS),
"--messages", strconv.Itoa(b.N),
"--size", strconv.Itoa(bc.MessageSize),
"--publishers", strconv.Itoa(bc.Publishers),
)
})
})
}
// makes a copy of bc
func (bc mqttBenchContext) benchmarkPubSub(b *testing.B) {
// This test uses a single built-in topic, and a built-in publisher, so no
// reason to run it for topics and publishers.
m := mqttBenchDefaultMatrix.
NoTopics().
NoPublishers()
b.Run("PUBSUB", func(b *testing.B) {
m.runMatrix(b, bc, func(b *testing.B, bc *mqttBenchContext) {
bc.runAndReport(b, "pubsub",
"--qos", strconv.Itoa(bc.QOS),
"--messages", strconv.Itoa(b.N),
"--size", strconv.Itoa(bc.MessageSize),
"--subscribers", strconv.Itoa(bc.Subscribers),
)
})
})
}
// makes a copy of bc
func (bc mqttBenchContext) benchmarkSubRet(b *testing.B) {
// This test uses a built-in publisher, and it makes most sense to measure
// the retained message delivery "overhead" on a QoS0 subscription; without
// the extra time involved in actually subscribing.
m := mqttBenchDefaultMatrix.
NoPublishers().
QOS0Only()
b.Run("SUBRET", func(b *testing.B) {
m.runMatrix(b, bc, func(b *testing.B, bc *mqttBenchContext) {
bc.runAndReport(b, "subret",
"--qos", strconv.Itoa(bc.QOS),
"--topics", strconv.Itoa(bc.Topics), // number of retained messages
"--subscribers", strconv.Itoa(bc.Subscribers),
"--size", strconv.Itoa(bc.MessageSize),
"--repeat", strconv.Itoa(b.N), // number of subscribe requests
)
})
})
}
func (bc mqttBenchContext) runAndReport(b *testing.B, name string, extraArgs ...string) {
b.Helper()
r := mqttRunExCommandTest(b, name, mqttNewDial("", "", bc.Host, bc.Port, ""), extraArgs...)
r.report(b)
}
func (bc *mqttBenchContext) startServer(b *testing.B, disableRMSCache bool) func() {
b.Helper()
b.StopTimer()
o := testMQTTDefaultOptions()
s := testMQTTRunServer(b, o)
o = s.getOpts()
bc.Host = o.MQTT.Host
bc.Port = o.MQTT.Port
mqttInitTestServer(b, mqttNewDial("", "", bc.Host, bc.Port, ""))
return func() {
testMQTTShutdownServer(s)
}
}
func (bc *mqttBenchContext) startCluster(b *testing.B, disableRMSCache bool) func() {
b.Helper()
b.StopTimer()
conf := `
listen: 127.0.0.1:-1
server_name: %s
jetstream: {max_mem_store: 256MB, max_file_store: 2GB, store_dir: '%s'}
cluster {
name: %s
listen: 127.0.0.1:%d
routes = [%s]
}
mqtt {
listen: 127.0.0.1:-1
stream_replicas: 3
}
# For access to system account.
accounts { $SYS { users = [ { user: "admin", pass: "s3cr3t!" } ] } }
`
cl := createJetStreamClusterWithTemplate(b, conf, "MQTT", 3)
o := cl.randomNonLeader().getOpts()
bc.Host = o.MQTT.Host
bc.Port = o.MQTT.Port
mqttInitTestServer(b, mqttNewDial("", "", bc.Host, bc.Port, ""))
return func() {
cl.shutdown()
}
}
func mqttBenchWrapForMatrixField(
vFieldPtr *int,
arr []int,
f func(b *testing.B, bc *mqttBenchContext),
namef func(int) string,
) func(b *testing.B, bc *mqttBenchContext) {
if len(arr) == 0 {
return f
}
return func(b *testing.B, bc *mqttBenchContext) {
for _, value := range arr {
*vFieldPtr = value
b.Run(namef(value), func(b *testing.B) {
f(b, bc)
})
}
}
}
func (m mqttBenchMatrix) runMatrix(b *testing.B, bc mqttBenchContext, f func(*testing.B, *mqttBenchContext)) {
b.Helper()
f = mqttBenchWrapForMatrixField(&bc.MessageSize, m.MessageSize, f, func(size int) string {
return sizeKB(size)
})
f = mqttBenchWrapForMatrixField(&bc.Topics, m.Topics, f, func(n int) string {
return fmt.Sprintf("%dtopics", n)
})
f = mqttBenchWrapForMatrixField(&bc.Publishers, m.Publishers, f, func(n int) string {
return fmt.Sprintf("%dpubc", n)
})
f = mqttBenchWrapForMatrixField(&bc.Subscribers, m.Subscribers, f, func(n int) string {
return fmt.Sprintf("%dsubc", n)
})
f = mqttBenchWrapForMatrixField(&bc.QOS, m.QOS, f, func(qos int) string {
return fmt.Sprintf("QOS%d", qos)
})
b.ResetTimer()
b.StartTimer()
f(b, &bc)
}
func (m mqttBenchMatrix) NoSubscribers() mqttBenchMatrix {
m.Subscribers = nil
return m
}
func (m mqttBenchMatrix) NoTopics() mqttBenchMatrix {
m.Topics = nil
return m
}
func (m mqttBenchMatrix) NoPublishers() mqttBenchMatrix {
m.Publishers = nil
return m
}
func (m mqttBenchMatrix) QOS0Only() mqttBenchMatrix {
m.QOS = []int{0}
return m
}
func (m mqttBenchMatrix) QOS1Only() mqttBenchMatrix {
m.QOS = []int{1}
return m
}
func sizeKB(size int) string {
unit := "B"
N := size
if size >= KB {
unit = "KB"
N = (N + KB/2) / KB
}
return fmt.Sprintf("%d%s", N, unit)
}
func (r MQTTBenchmarkResult) report(b *testing.B) {
// Disable the default ns metric in favor of custom X_ms/op.
b.ReportMetric(0, "ns/op")
// Disable MB/s since the github benchmarking action misinterprets the sign
// of the result (treats it as less is better).
b.SetBytes(0)
// b.SetBytes(r.Bytes)
for unit, ns := range r.NS {
nsOp := float64(ns) / float64(r.Ops)
b.ReportMetric(nsOp/1000000, unit+"_ms/op")
}
// Diable ReportAllocs() since it confuses the github benchmarking action
// with the noise.
// b.ReportAllocs()
}