Skip to content

Stream: an empty/omitted maxAge is never reconciled to unlimited -- a new stream inherits jsm DefaultStream's 1-year MaxAge #377

Description

@johnweldon

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.

  1. 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
  2. Let nack create the stream, then inspect it (nats stream info TEST_STREAM, or the resource's stream.nats.io/state annotation).

  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions