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>
328 lines
8.0 KiB
Go
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()
|
|
}
|