Files
temporal/tests/testcore/test_env.go
Stephan Behnke adf694f823 Reuse test context for namespace setup (#11623)
## What changed?

Pass one test-owned context through namespace creation, namespace cache
polling, and search attribute registration during functional test setup.

## Why?

Reusing the test context avoids creating independent timeout contexts
for each setup RPC and ties their resources to the test lifecycle.
2026-08-24 08:35:43 -07:00

704 lines
23 KiB
Go

package testcore
import (
"context"
_ "embed"
"errors"
"fmt"
"os"
"strconv"
"strings"
"sync"
"testing"
"github.com/dgryski/go-farm"
"github.com/stretchr/testify/require"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
enumspb "go.temporal.io/api/enums/v1"
"go.temporal.io/api/workflowservice/v1"
sdkclient "go.temporal.io/sdk/client"
sdkworker "go.temporal.io/sdk/worker"
"go.temporal.io/server/api/adminservice/v1"
"go.temporal.io/server/common"
"go.temporal.io/server/common/archiver/provider"
"go.temporal.io/server/common/authorization"
"go.temporal.io/server/common/config"
"go.temporal.io/server/common/dynamicconfig"
"go.temporal.io/server/common/headers"
"go.temporal.io/server/common/log"
"go.temporal.io/server/common/namespace"
"go.temporal.io/server/common/testing/taskpoller"
"go.temporal.io/server/common/testing/testcontext"
"go.temporal.io/server/common/testing/testhooks"
"go.temporal.io/server/common/testing/testlogger"
"go.temporal.io/server/common/testing/testvars"
"go.temporal.io/server/temporal"
)
// shardSalt is used to distribute functional tests across shards.
// This value is automatically updated by the optimize-test-sharding workflow.
//
//go:embed shard_salt.txt
var shardSalt string
var _ Env = (*TestEnv)(nil)
type Env interface {
// T returns the *testing.T.
//
// Deprecated: use the suite's T() method instead.
T() *testing.T
Namespace() namespace.Name
NamespaceID() namespace.ID
FrontendClient() workflowservice.WorkflowServiceClient
AdminClient() adminservice.AdminServiceClient
GetTestCluster() *TestCluster
CloseShard(namespaceID string, workflowID string)
OverrideDynamicConfig(setting dynamicconfig.GenericSetting, value any) (cleanup func())
// Deprecated: use the suite's Context() method instead.
Context() context.Context
InjectHook(hook testhooks.Hook) (cleanup func())
}
type TestEnv struct {
*FunctionalTestBase
// Shadows FunctionalTestBase.Assertions with a per-test instance bound to
// this TestEnv's own *testing.T, avoiding data races when parallel tests
// share the same *FunctionalTestBase cluster.
// TODO: remove once all tests are migrated to TestEnv (and no longer use FunctionalTestBase directly).
*require.Assertions
Logger log.Logger
cluster *TestCluster
nsName namespace.Name
nsID namespace.ID
taskPoller *taskpoller.TaskPoller
t *testing.T
tv *testvars.TestVars
dedicatedGuard *dedicatedClusterGuard
sdkClientOnce sync.Once
sdkClient sdkclient.Client
sdkWorkerOnce sync.Once
sdkWorker sdkworker.Worker
sdkWorkerTQ string
}
type TestOption func(*testOptions)
type testOptions struct {
dedicatedCluster bool
needWorkerService bool
dedicatedReason string
disableTestloggerFailure bool
dynamicConfigSettings []dynamicConfigOverride
clusterOptions []TestClusterOption
testVars func(*testvars.TestVars) *testvars.TestVars
historyTaskRecorder bool
}
type dynamicConfigOverride struct {
setting dynamicconfig.GenericSetting
value any
}
type versionHeadersContextKey struct{}
// WithDedicatedCluster requests a dedicated (non-shared) cluster for the test.
// Use this for tests that have cluster-global side effects.
func WithDedicatedCluster() TestOption {
return func(o *testOptions) {
o.dedicatedCluster = true
}
}
// WithSpanExporter enables OpenTelemetry tracing with exporter on a dedicated test cluster.
func WithSpanExporter(exporter sdktrace.SpanExporter) TestOption {
return func(o *testOptions) {
o.dedicatedCluster = true
o.clusterOptions = append(o.clusterOptions, withSpanExporter(exporter))
o.dedicatedReason = "span exporter configured"
}
}
// WithDisableTestloggerFailure disables the test logger's behavior of failing
// the test when an error log matches a registered expectation (e.g. soft-assert
// errors tagged with tag.FailedAssertion). Use for tests that intentionally
// trigger and then verify soft-assert errors. Implies WithDedicatedCluster,
// because FailOnError is cluster-wide and disabling it on a shared cluster may
// hide failures in concurrent tests.
func WithDisableTestloggerFailure() TestOption {
return func(o *testOptions) {
o.dedicatedCluster = true
o.disableTestloggerFailure = true
o.dedicatedReason = "testlogger failures disabled"
}
}
// Deprecated: this option is no longer required and will be removed once all callers have been updated.
func WithSdkWorker() TestOption {
return func(o *testOptions) {
}
}
// WithTestVars customizes the default test variables for the environment.
func WithTestVars(fn func(*testvars.TestVars) *testvars.TestVars) TestOption {
return func(o *testOptions) {
o.testVars = fn
}
}
// WithWorkerService enables the system worker service. The service is off by
// default to avoid the worker overhead. This implies a dedicated cluster.
func WithWorkerService(reason string) TestOption {
return func(o *testOptions) {
o.dedicatedCluster = true
o.needWorkerService = true
o.dedicatedReason = "worker service required: " + reason
}
}
// WithMTLS enables mutual TLS on the test's cluster. This implies a dedicated
// cluster, since the TLS configuration cannot be shared across tests.
func WithMTLS() TestOption {
return func(o *testOptions) {
o.dedicatedCluster = true
o.clusterOptions = append(o.clusterOptions, withMTLS())
o.dedicatedReason = "mTLS enabled"
}
}
// WithPersistenceFaultInjection requests a dedicated cluster with the given persistence fault injection config.
func WithPersistenceFaultInjection(cfg *config.FaultInjection) TestOption {
return func(o *testOptions) {
o.dedicatedCluster = true
o.clusterOptions = append(o.clusterOptions, WithFaultInjectionConfig(cfg))
o.dedicatedReason = "fault injection config used"
}
}
// WithArchival enables archival on the test's cluster. This implies a dedicated
// cluster because archival is configured at the cluster level.
func WithArchival() TestOption {
return func(o *testOptions) {
o.dedicatedCluster = true
o.clusterOptions = append(o.clusterOptions, withArchivalConfig())
o.dedicatedReason = "archival enabled"
}
}
// WithCustomArchivers configures custom history and visibility archiver factories
// on the test's cluster. This implies a dedicated cluster because the factories are
// configured at the cluster level.
func WithCustomArchivers(historyFactory provider.CustomHistoryArchiverFactory, visibilityFactory provider.CustomVisibilityArchiverFactory) TestOption {
return func(o *testOptions) {
o.dedicatedCluster = true
o.clusterOptions = append(o.clusterOptions, func(params *testClusterParams) {
params.AdditionalServerOptions = append(params.AdditionalServerOptions,
temporal.WithCustomHistoryArchiverFactory(historyFactory),
temporal.WithCustomVisibilityArchiverFactory(visibilityFactory),
)
})
o.dedicatedReason = "custom archivers used"
}
}
// WithLogger sets a custom logger for the test's cluster, letting a test intercept
// server log output. This implies a dedicated cluster, since a custom logger cannot
// be shared across tests.
func WithLogger(logger log.Logger) TestOption {
return func(o *testOptions) {
o.dedicatedCluster = true
o.clusterOptions = append(o.clusterOptions, WithClusterLogger(logger))
o.dedicatedReason = "custom logger used"
}
}
// WithHistoryShardCount sets the number of history shards for the test's cluster.
// This implies a dedicated cluster, since shard count cannot be changed on a shared cluster.
func WithHistoryShardCount(n int32) TestOption {
return func(o *testOptions) {
o.dedicatedCluster = true
o.clusterOptions = append(o.clusterOptions, WithNumHistoryShards(n))
o.dedicatedReason = "custom history shard count used"
}
}
func WithHistoryTaskRecorder() TestOption {
return func(o *testOptions) {
o.dedicatedCluster = true
o.clusterOptions = append(o.clusterOptions, WithClusterHistoryTaskRecorder())
o.dedicatedReason = "task queue recorder used"
o.historyTaskRecorder = true
}
}
// WithDynamicConfig overrides a dynamic config setting for the test.
// For settings that can be namespace-scoped, a namespace constraint is applied.
// For all others that require a dedicated cluster, this implies `WithDedicatedCluster`.
func WithDynamicConfig(setting dynamicconfig.GenericSetting, value any) TestOption {
return func(o *testOptions) {
if err := setting.Validate(value); err != nil {
panic(fmt.Sprintf("invalid value for setting %s: %v", setting.Key(), err))
}
if !canBeNamespaceScoped(setting.Precedence()) {
o.dedicatedCluster = true
}
o.dynamicConfigSettings = append(o.dynamicConfigSettings, dynamicConfigOverride{setting: setting, value: value})
}
}
// NewEnv creates a new test environment with access to a Temporal cluster.
func NewEnv(t *testing.T, opts ...TestOption) *TestEnv {
t.Helper()
// Check test sharding early, before any expensive operations.
checkTestShard(t)
// Create the test context before any expensive setup, so that the deadline
// extension below can compensate for the time setup takes.
ctx := testcontext.For(t)
var options testOptions
for _, opt := range opts {
opt(&options)
}
dedicatedGuard := newDedicatedClusterGuard(options.dedicatedCluster)
if options.dedicatedReason != "" {
dedicatedGuard.record(options.dedicatedReason)
}
// For dedicated clusters, pass all dynamic config settings at cluster creation.
var startupConfig map[dynamicconfig.Key]any
if options.dedicatedCluster && len(options.dynamicConfigSettings) > 0 {
startupConfig = make(map[dynamicconfig.Key]any, len(options.dynamicConfigSettings))
for _, override := range options.dynamicConfigSettings {
if !canBeNamespaceScoped(override.setting.Precedence()) {
dedicatedGuard.record("global dynamic config used")
}
startupConfig[override.setting.Key()] = override.value
}
}
// Obtain the test cluster from the router.
base := testClusterRouter.get(t, clusterRequest{
dedicated: options.dedicatedCluster,
needWorkerService: options.needWorkerService,
dedicatedReason: options.dedicatedReason,
dynamicConfig: startupConfig,
clusterOpts: options.clusterOptions,
})
cluster := base.GetTestCluster()
// Create a dedicated namespace for the test to help with test isolation.
baseName := strings.ReplaceAll(t.Name(), "/", "-")
ns := namespace.Name(RandomizeStr(baseName))
nsID, err := base.RegisterNamespace(
ctx,
ns,
1, // 1 day retention
enumspb.ARCHIVAL_STATE_DISABLED,
"",
"",
)
if err != nil {
t.Fatalf("Failed to register namespace: %v", err)
}
tv := testvars.New(t)
if options.testVars != nil {
tv = options.testVars(tv)
}
// Attach version headers decorator to the test context.
testcontext.AttachDecorator(t, versionHeadersContextKey{}, headers.SetVersions)
// Restore as much of the test's timeout budget as the context's ceiling
// allows, now that setup is done.
testcontext.EnsureRemaining(testcontext.For(t), t, testcontext.DefaultTimeout())
env := &TestEnv{
FunctionalTestBase: base,
Assertions: require.New(t),
cluster: cluster,
nsName: ns,
nsID: nsID,
Logger: base.Logger,
taskPoller: taskpoller.New(t, cluster.FrontendClient(), ns.String()),
t: t,
tv: tv,
sdkWorkerTQ: RandomizeStr("tq-" + t.Name()),
dedicatedGuard: dedicatedGuard,
}
t.Cleanup(func() {
defer func() { dedicatedGuard = nil }()
if err := dedicatedGuard.validate(); err != nil && !t.Failed() {
t.Fatal(err)
}
})
if options.disableTestloggerFailure {
tl, ok := base.Logger.(*testlogger.TestLogger)
if !ok {
t.Fatalf("WithDisableTestloggerFailure requires a *testlogger.TestLogger logger, got %T", base.Logger)
}
prev := tl.FailOnError(false)
t.Cleanup(func() { tl.FailOnError(prev) })
}
// For shared clusters, apply all dynamic config settings as overrides.
if !options.dedicatedCluster && len(options.dynamicConfigSettings) > 0 {
for _, override := range options.dynamicConfigSettings {
env.OverrideDynamicConfig(override.setting, override.value)
}
}
if options.historyTaskRecorder {
recorder := cluster.GetHistoryTaskRecorder()
require.NotNil(t, recorder)
}
return env
}
// Use test env-specific namespace here for test isolation.
func (e *TestEnv) Namespace() namespace.Name {
return e.nsName
}
func (e *TestEnv) NamespaceID() namespace.ID {
return e.nsID
}
// InjectHook sets a test hook inside the cluster.
//
// It auto-detects the scope from the hook:
// - For namespace-scoped hooks: scopes it to the test's namespace
// - For global hooks: requires a dedicated cluster, except for suite-scoped legacy clusters.
func (e *TestEnv) InjectHook(hook testhooks.Hook) (cleanup func()) {
var scope any
switch hook.Scope() {
case testhooks.ScopeNamespace:
scope = e.nsID
case testhooks.ScopeGlobal:
if e.isShared && !testClusterRouter.hasSuiteScoped(e.t) {
e.t.Fatal("InjectHook: global hooks require a dedicated cluster; use testcore.WithDedicatedCluster()")
}
e.dedicatedGuard.record("global hook injected")
scope = testhooks.GlobalScope
default:
e.t.Fatalf("InjectHook: unknown scope %v", hook.Scope())
}
return e.cluster.host.injectHook(e.t, hook, scope)
}
func (e *TestEnv) SetOnAuthorize(
fn func(context.Context, *authorization.Claims, *authorization.CallTarget) (authorization.Result, error),
) {
e.t.Helper()
if e.isShared {
e.t.Fatal("SetOnAuthorize cannot be called on a shared cluster; use testcore.WithDedicatedCluster()")
}
e.dedicatedGuard.record("authorization callback")
e.cluster.host.SetOnAuthorize(fn)
e.t.Cleanup(func() {
e.cluster.host.SetOnAuthorize(nil)
})
}
func (e *TestEnv) SetOnGetClaims(fn func(*authorization.AuthInfo) (*authorization.Claims, error)) {
e.t.Helper()
if e.isShared {
e.t.Fatal("SetOnGetClaims cannot be called on a shared cluster; use testcore.WithDedicatedCluster()")
}
e.dedicatedGuard.record("authorization callback")
e.cluster.host.SetOnGetClaims(fn)
e.t.Cleanup(func() {
e.cluster.host.SetOnGetClaims(nil)
})
}
func (e *TestEnv) TaskPoller() *taskpoller.TaskPoller {
return e.taskPoller
}
// NoError asserts that err is nil.
//
// Deprecated: use require.NoError with the parent test or suite instead.
// TODO: remove once all tests are migrated to TestEnv (and no longer use FunctionalTestBase directly).
func (e *TestEnv) NoError(err error, msgAndArgs ...any) {
e.Assertions.NoError(err, msgAndArgs...)
}
// Error asserts that err is not nil.
//
// Deprecated: use require.Error with the parent test or suite instead.
// TODO: remove once all tests are migrated to TestEnv (and no longer use FunctionalTestBase directly).
func (e *TestEnv) Error(err error, msgAndArgs ...any) {
e.Assertions.Error(err, msgAndArgs...)
}
// Run executes a subtest.
//
// Deprecated: use the suite's Run method instead.
// TODO: remove once all tests are migrated to TestEnv (and no longer use FunctionalTestBase directly).
func (e *TestEnv) Run(name string, subtest func()) bool {
return e.FunctionalTestBase.Run(name, subtest)
}
// T returns the *testing.T.
//
// Deprecated: use the suite's T() method instead.
func (e *TestEnv) T() *testing.T {
return e.t
}
func (e *TestEnv) Tv() *testvars.TestVars {
return e.tv
}
// Context returns the test-level timeout context with RPC version headers already included.
// This context will be canceled when the test timeout occurs. Use this directly for all RPC
// operations - no need to wrap with NewContext or add headers manually.
//
// For custom timeouts, use:
//
// ctx, cancel := context.WithTimeout(env.Context(), 10*time.Second)
// defer cancel()
//
// The context is deliberately not cached; see [testcontext.EnsureRemaining].
//
// Deprecated: use the suite's Context() method instead.
func (e *TestEnv) Context() context.Context {
return testcontext.For(e.t)
}
// WaitForChannel waits for ch to receive using the TestEnv context.
func (e *TestEnv) WaitForChannel(ch <-chan struct{}) {
e.t.Helper()
select {
case <-ch:
case <-e.Context().Done():
e.FailNow("context timeout while waiting for channel")
}
}
// SendToChannel sends to ch using the TestEnv context.
func (e *TestEnv) SendToChannel(ch chan<- struct{}) {
e.t.Helper()
select {
case ch <- struct{}{}:
case <-e.Context().Done():
e.FailNow("context timeout while sending to channel")
}
}
// SdkClient returns the SDK client. It is lazily initialized on the first call.
func (e *TestEnv) SdkClient() sdkclient.Client {
e.sdkClientOnce.Do(func() {
clientOptions := sdkclient.Options{
HostPort: e.FrontendGRPCAddress(),
Namespace: e.nsName.String(),
Logger: log.NewSdkLogger(e.Logger),
}
if provider := e.cluster.host.tlsConfigProvider; provider != nil {
clientOptions.ConnectionOptions.TLS = provider.FrontendClientConfig
}
client, err := sdkclient.Dial(clientOptions)
if err != nil {
e.t.Fatalf("Failed to create SDK client: %v", err)
}
e.sdkClient = client
e.t.Cleanup(func() {
client.Close()
client = nil
})
})
return e.sdkClient
}
// SdkWorker returns the SDK worker. It is lazily initialized on the first call.
func (e *TestEnv) SdkWorker() sdkworker.Worker {
e.sdkWorkerOnce.Do(func() {
client := e.SdkClient() // Ensure client is initialized
worker := sdkworker.New(client, e.sdkWorkerTQ, sdkworker.Options{})
if err := worker.Start(); err != nil {
e.t.Fatalf("Failed to start SDK worker: %v", err)
}
e.sdkWorker = worker
e.t.Cleanup(func() {
worker.Stop()
worker = nil
})
})
return e.sdkWorker
}
// WorkerTaskQueue returns the task queue name used by the SDK Worker.
func (e *TestEnv) WorkerTaskQueue() string {
return e.sdkWorkerTQ
}
// OverrideDynamicConfig overrides a dynamic config setting for the duration of this test.
// For settings that can be namespace-scoped, a namespace constraint is applied.
// All others cannot be applied to a shared cluster and require `WithDedicatedCluster`.
func (e *TestEnv) OverrideDynamicConfig(setting dynamicconfig.GenericSetting, value any) (cleanup func()) {
if e.isShared {
if !canBeNamespaceScoped(setting.Precedence()) {
e.t.Fatalf("OverrideDynamicConfig for setting %s (precedence %v) cannot be called on a shared cluster; use testcore.WithDedicatedCluster()", setting.Key(), setting.Precedence())
}
// Wrap value with namespace constraint for test isolation on shared clusters.
ns := e.nsName.String()
if cvs, ok := value.([]dynamicconfig.ConstrainedValue); ok {
result := make([]dynamicconfig.ConstrainedValue, len(cvs))
for i, cv := range cvs {
cv.Constraints.Namespace = ns
result[i] = cv
}
value = result
} else {
value = []dynamicconfig.ConstrainedValue{{
Constraints: dynamicconfig.Constraints{Namespace: ns},
Value: value,
}}
}
} else if !canBeNamespaceScoped(setting.Precedence()) {
e.dedicatedGuard.record("global dynamic config used")
}
return e.cluster.host.overrideDynamicConfigForTest(e.t, setting.Key(), value)
}
// StartGlobalMetricCapture starts a cluster-global metrics capture for this test and automatically stops it during cleanup.
// Metric capture is cluster-global, so it is only safe on dedicated clusters.
// Misuse detection is best-effort and only applies to queried metrics that produced recordings.
func (e *TestEnv) StartGlobalMetricCapture() *GlobalMetricCapture {
if e.isShared {
e.t.Fatal("StartGlobalMetricCapture cannot be called on a shared cluster; use testcore.WithDedicatedCluster()")
}
e.dedicatedGuard.record("global metric capture") // note that globalCapture has its own misuse detection
handler := e.cluster.host.CaptureMetricsHandler()
if handler == nil {
e.t.Fatal("StartGlobalMetricCapture is unavailable because metrics capture is not enabled on this cluster")
}
capture := handler.StartCapture()
globalCapture := newGlobalMetricCapture(capture)
e.t.Cleanup(func() {
defer handler.StopCapture(capture)
globalCapture.checkForNamespaceCaptureMisuse()
})
return globalCapture
}
// StartNamespaceMetricCapture starts a metrics capture scoped to this test's namespace.
// Namespace captures are safe on shared clusters because reads are restricted to
// per-metric namespace-filtered iteration and reject non-namespaced metrics.
func (e *TestEnv) StartNamespaceMetricCapture() *NamespaceMetricCapture {
return e.StartNamespaceMetricCaptureFor(e.Namespace().String())
}
// StartNamespaceMetricCaptureFor starts a metrics capture scoped to the provided namespace.
func (e *TestEnv) StartNamespaceMetricCaptureFor(namespaceName string) *NamespaceMetricCapture {
handler := e.cluster.host.CaptureMetricsHandler()
if handler == nil {
e.t.Fatal("StartNamespaceMetricCapture is unavailable because metrics capture is not enabled on this cluster")
}
capture := handler.StartCapture()
e.t.Cleanup(func() {
handler.StopCapture(capture)
})
return newNamespaceMetricCapture(capture, namespaceName)
}
// CloseShard closes the shard that contains the given workflow.
// This is a cluster-global operation and cannot be called on shared clusters.
func (e *TestEnv) CloseShard(namespaceID string, workflowID string) {
if e.isShared {
e.t.Fatalf("CloseShard cannot be called on a shared cluster; use testcore.WithDedicatedCluster()")
}
e.dedicatedGuard.record("shard closed")
shardID := common.WorkflowIDToHistoryShard(namespaceID, workflowID, e.testClusterConfig.HistoryConfig.NumHistoryShards)
_, err := e.AdminClient().CloseShard(NewContext(), &adminservice.CloseShardRequest{
ShardId: shardID,
})
e.NoError(err)
}
func canBeNamespaceScoped(p dynamicconfig.Precedence) bool {
switch p {
case dynamicconfig.PrecedenceNamespace,
dynamicconfig.PrecedenceTaskQueue,
dynamicconfig.PrecedenceDestination:
return true
default:
return false
}
}
// checkTestShard supports test sharding based on environment variables.
// This distributes tests across multiple CI shards for parallel execution.
func checkTestShard(t *testing.T) {
totalStr := os.Getenv("TEST_TOTAL_SHARDS")
indexStr := os.Getenv("TEST_SHARD_INDEX")
if totalStr == "" || indexStr == "" {
return
}
total, err := strconv.Atoi(totalStr)
if err != nil || total < 1 {
t.Fatal("Couldn't convert TEST_TOTAL_SHARDS")
}
index, err := strconv.Atoi(indexStr)
if err != nil || index < 0 || index >= total {
t.Fatal("Couldn't convert TEST_SHARD_INDEX")
}
nameToHash := t.Name() + strings.TrimSpace(shardSalt)
testIndex := int(farm.Fingerprint32([]byte(nameToHash))) % total
if testIndex != index {
t.Skipf("Skipping %s in test shard %d/%d (it runs in %d)", t.Name(), index+1, total, testIndex+1)
}
t.Logf("Running %s in test shard %d/%d", t.Name(), index+1, total)
}
type dedicatedClusterGuard struct {
required bool
mu sync.Mutex
usageReason string
}
func newDedicatedClusterGuard(required bool) *dedicatedClusterGuard {
return &dedicatedClusterGuard{required: required}
}
// record marks that a dedicated-cluster-only feature was used, satisfying the guard.
func (u *dedicatedClusterGuard) record(reason string) {
if !u.required {
return
}
u.mu.Lock()
if u.usageReason == "" {
u.usageReason = reason
}
u.mu.Unlock()
}
// validate checks that the guard was satisfied i.e. a dedicated-cluster-only feature was used.
func (u *dedicatedClusterGuard) validate() error {
if !u.required {
return nil
}
u.mu.Lock()
usageReason := u.usageReason
u.mu.Unlock()
if usageReason == "" {
return errors.New("testcore.WithDedicatedCluster() was requested but no dedicated-cluster-only feature was used")
}
return nil
}