What version were you using?
- nack: the controller-runtime controller (
internal/controller/stream_controller.go). Reproduces on current main.
- jsm.go:
v0.4.1 (the version pinned in nack's go.mod).
- nats-server: not relevant -- this is a controller-side config-mapping defect, independent of server version (reproduced against nats-server 2.x).
What environment was the server running in?
nack JetStream controller running in Kubernetes, reconciling jetstream.nats.io/v1beta2 Stream resources. Environment-independent: the defect is in how the controller maps the CR spec to a stream config, not in the runtime.
Is this defect reproducible?
Yes, deterministically.
-
Apply a Stream with maxAge omitted (or set to ""):
apiVersion: jetstream.nats.io/v1beta2
kind: Stream
metadata:
name: test-stream
spec:
name: TEST_STREAM
subjects: ["test.>"]
storage: file
# maxAge intentionally omitted
-
Let nack create the stream, then inspect it (nats stream info TEST_STREAM, or the resource's stream.nats.io/state annotation).
-
Observed: MaxAge is 8760h0m0s (1 year), not unlimited.
Root cause:
streamSpecToConfig only appends the jsm.MaxAge option when the field is non-empty -- if spec.MaxAge != "" (internal/controller/stream_controller.go:359). An empty maxAge produces no MaxAge option.
- The create path calls
js.NewStream(name, opts...) (stream_controller.go:228), which is jsm.go's NewStreamFromDefault(name, DefaultStream, opts...) (jsm.go streams.go:185). DefaultStream sets MaxAge: 24 * 365 * time.Hour = 8760h (streams.go:39). With no overriding option, the new stream inherits that 1-year default.
- The update path calls
s.UpdateConfiguration(*serverState, opts...) (stream_controller.go:239), which uses the current server config as the base, so an empty maxAge leaves the existing value untouched -- it is never reset to 0 either.
The same skip-when-empty pattern affects duplicateWindow (stream_controller.go:384), subjectDeleteMarkerTtl (:508), and maxMsgsPerSubject (the > 0 guard, :351). Note the controller already avoids this class of bug for the togglable bool fields via an "always-set" bulk setter (:516-532) whose comment describes exactly this hazard ("opts that don't touch a field leave its previous value intact"); the duration/string fields were not given the same treatment.
Given the capability you are leveraging, describe your expectation?
The CRD documents maxAge as: "Maximum age of any message in the stream, expressed in Go's time.Duration format. Empty for unlimited." (deploy/crds.yml). So an omitted/empty maxAge should produce an unlimited stream (MaxAge = 0), and reconciliation should enforce that -- including resetting MaxAge to 0 on an existing stream that currently has a non-zero value.
Given the expectation, what is the defect you are observing?
An omitted/empty maxAge instead yields a stream with MaxAge = 1 year (8760h) on create (inherited from jsm.DefaultStream), and on update leaves whatever MaxAge the stream already has. The documented "Empty for unlimited" contract is not honored in either case; the only way to get or enforce unlimited is to set maxAge: "0" explicitly.
This is a regression from the legacy controller (controllers/jetstream/stream.go), which always set MaxAge via jsm.MaxAge(getDurationFromString(spec.MaxAge)), and getDurationFromString("") returns 0 (stream.go:589) -- so the legacy controller explicitly sent MaxAge(0), overriding the template default.
Suggested fix
Emit the duration options unconditionally (mirroring the bool fields' always-set treatment), parsing an empty value as 0: drop the if spec.MaxAge != "" guard in streamSpecToConfig and apply jsm.MaxAge(0) when empty. Apply the same to duplicateWindow, subjectDeleteMarkerTtl, and maxMsgsPerSubject. This restores the "Empty for unlimited" contract and makes the field reconcile correctly on both create and update.
What version were you using?
internal/controller/stream_controller.go). Reproduces on currentmain.v0.4.1(the version pinned in nack'sgo.mod).What environment was the server running in?
nack JetStream controller running in Kubernetes, reconciling
jetstream.nats.io/v1beta2Streamresources. Environment-independent: the defect is in how the controller maps the CR spec to a stream config, not in the runtime.Is this defect reproducible?
Yes, deterministically.
Apply a
StreamwithmaxAgeomitted (or set to""):Let nack create the stream, then inspect it (
nats stream info TEST_STREAM, or the resource'sstream.nats.io/stateannotation).Observed:
MaxAgeis8760h0m0s(1 year), not unlimited.Root cause:
streamSpecToConfigonly appends thejsm.MaxAgeoption when the field is non-empty --if spec.MaxAge != ""(internal/controller/stream_controller.go:359). An emptymaxAgeproduces no MaxAge option.js.NewStream(name, opts...)(stream_controller.go:228), which is jsm.go'sNewStreamFromDefault(name, DefaultStream, opts...)(jsm.go streams.go:185).DefaultStreamsetsMaxAge: 24 * 365 * time.Hour=8760h(streams.go:39). With no overriding option, the new stream inherits that 1-year default.s.UpdateConfiguration(*serverState, opts...)(stream_controller.go:239), which uses the current server config as the base, so an emptymaxAgeleaves the existing value untouched -- it is never reset to 0 either.The same skip-when-empty pattern affects
duplicateWindow(stream_controller.go:384),subjectDeleteMarkerTtl(:508), andmaxMsgsPerSubject(the> 0guard,:351). Note the controller already avoids this class of bug for the togglable bool fields via an "always-set" bulk setter (:516-532) whose comment describes exactly this hazard ("opts that don't touch a field leave its previous value intact"); the duration/string fields were not given the same treatment.Given the capability you are leveraging, describe your expectation?
The CRD documents
maxAgeas: "Maximum age of any message in the stream, expressed in Go's time.Duration format. Empty for unlimited." (deploy/crds.yml). So an omitted/emptymaxAgeshould produce an unlimited stream (MaxAge = 0), and reconciliation should enforce that -- including resettingMaxAgeto 0 on an existing stream that currently has a non-zero value.Given the expectation, what is the defect you are observing?
An omitted/empty
maxAgeinstead yields a stream withMaxAge = 1 year(8760h) on create (inherited fromjsm.DefaultStream), and on update leaves whateverMaxAgethe stream already has. The documented "Empty for unlimited" contract is not honored in either case; the only way to get or enforce unlimited is to setmaxAge: "0"explicitly.This is a regression from the legacy controller (
controllers/jetstream/stream.go), which always set MaxAge viajsm.MaxAge(getDurationFromString(spec.MaxAge)), andgetDurationFromString("")returns0(stream.go:589) -- so the legacy controller explicitly sentMaxAge(0), overriding the template default.Suggested fix
Emit the duration options unconditionally (mirroring the bool fields' always-set treatment), parsing an empty value as
0: drop theif spec.MaxAge != ""guard instreamSpecToConfigand applyjsm.MaxAge(0)when empty. Apply the same toduplicateWindow,subjectDeleteMarkerTtl, andmaxMsgsPerSubject. This restores the "Empty for unlimited" contract and makes the field reconcile correctly on both create and update.