2828import java .io .IOException ;
2929import java .time .Duration ;
3030import java .util .List ;
31+ import java .util .Set ;
3132import java .util .concurrent .ConcurrentHashMap ;
3233import java .util .logging .Level ;
3334import java .util .logging .Logger ;
@@ -50,6 +51,14 @@ public class NatsProvisioner {
5051 private final ConcurrentHashMap <String , KeyValue > kvCache = new ConcurrentHashMap <>();
5152 private final ConcurrentHashMap <String , Object > kvLocks = new ConcurrentHashMap <>();
5253
54+ // stream name → provisioned (set membership acts as the boolean flag)
55+ private final Set <String > streamsDone = ConcurrentHashMap .newKeySet ();
56+ private final ConcurrentHashMap <String , Object > streamLocks = new ConcurrentHashMap <>();
57+
58+ // "streamName/requestedConsumerName" → actual consumer name (may differ for stale-rebind)
59+ private final ConcurrentHashMap <String , String > consumerCache = new ConcurrentHashMap <>();
60+ private final ConcurrentHashMap <String , Object > consumerLocks = new ConcurrentHashMap <>();
61+
5362 public NatsProvisioner (Connection connection , JetStreamManagement jsm , RqueueNatsConfig config )
5463 throws IOException {
5564 this .connection = connection ;
@@ -90,8 +99,12 @@ public KeyValue ensureKv(String bucketName, Duration ttl)
9099 } catch (JetStreamApiException missing ) {
91100 // bucket absent — fall through to create
92101 }
93- KeyValueConfiguration .Builder cfg =
94- KeyValueConfiguration .builder ().name (bucketName ).compression (true );
102+ RqueueNatsConfig .StreamDefaults sd = config .getStreamDefaults ();
103+ KeyValueConfiguration .Builder cfg = KeyValueConfiguration .builder ()
104+ .name (bucketName )
105+ .compression (true )
106+ .replicas (sd .getReplicas ())
107+ .storageType (sd .getStorage ());
95108 if (ttl != null && !ttl .isZero () && !ttl .isNegative ()) {
96109 cfg .ttl (ttl );
97110 }
@@ -105,43 +118,54 @@ public KeyValue ensureKv(String bucketName, Duration ttl)
105118 // ---- Stream provisioning ----------------------------------------------
106119
107120 /**
108- * Ensure a JetStream stream exists with the given subjects. If absent and {@code
109- * autoCreateStreams=true}, creates one using {@link RqueueNatsConfig.StreamDefaults}.
121+ * Ensure a JetStream stream exists with the given subjects. Hits the NATS backend at most once
122+ * per stream name per process lifetime; subsequent calls return immediately from the in-process
123+ * cache.
110124 */
111125 public void ensureStream (String streamName , List <String > subjects ) {
112- try {
113- StreamInfo existing = safeGetStreamInfo (streamName );
114- if (existing != null ) {
126+ if (streamsDone .contains (streamName )) {
127+ return ;
128+ }
129+ Object lock = streamLocks .computeIfAbsent (streamName , k -> new Object ());
130+ synchronized (lock ) {
131+ if (streamsDone .contains (streamName )) {
115132 return ;
116133 }
117- if (!config .isAutoCreateStreams ()) {
134+ try {
135+ StreamInfo existing = safeGetStreamInfo (streamName );
136+ if (existing == null ) {
137+ if (!config .isAutoCreateStreams ()) {
138+ throw new RqueueNatsException (
139+ "Stream '" + streamName + "' does not exist and autoCreateStreams=false" );
140+ }
141+ RqueueNatsConfig .StreamDefaults sd = config .getStreamDefaults ();
142+ StreamConfiguration .Builder b = StreamConfiguration .builder ()
143+ .name (streamName )
144+ .subjects (subjects )
145+ .replicas (sd .getReplicas ())
146+ .storageType (sd .getStorage ())
147+ .retentionPolicy (sd .getRetention ())
148+ .duplicateWindow (sd .getDuplicateWindow ())
149+ .compressionOption (CompressionOption .S2 );
150+ if (sd .getMaxMsgs () > 0 ) {
151+ b .maxMessages (sd .getMaxMsgs ());
152+ }
153+ if (sd .getMaxBytes () > 0 ) {
154+ b .maxBytes (sd .getMaxBytes ());
155+ }
156+ jsm .addStream (b .build ());
157+ }
158+ } catch (IOException | JetStreamApiException e ) {
118159 throw new RqueueNatsException (
119- "Stream '" + streamName + "' does not exist and autoCreateStreams=false" );
120- }
121- RqueueNatsConfig .StreamDefaults sd = config .getStreamDefaults ();
122- StreamConfiguration .Builder b = StreamConfiguration .builder ()
123- .name (streamName )
124- .subjects (subjects )
125- .replicas (sd .getReplicas ())
126- .storageType (sd .getStorage ())
127- .retentionPolicy (sd .getRetention ())
128- .duplicateWindow (sd .getDuplicateWindow ())
129- .compressionOption (CompressionOption .S2 );
130- if (sd .getMaxMsgs () > 0 ) {
131- b .maxMessages (sd .getMaxMsgs ());
160+ "Failed to ensure stream '" + streamName + "' for subjects " + subjects , e );
132161 }
133- if (sd .getMaxBytes () > 0 ) {
134- b .maxBytes (sd .getMaxBytes ());
135- }
136- jsm .addStream (b .build ());
137- } catch (IOException | JetStreamApiException e ) {
138- throw new RqueueNatsException (
139- "Failed to ensure stream '" + streamName + "' for subjects " + subjects , e );
162+ streamsDone .add (streamName );
140163 }
141164 }
142165
143166 /**
144167 * Ensure a durable pull consumer exists, returning the actual consumer name to use for binding.
168+ * Hits the NATS backend at most once per (stream, consumer) pair per process lifetime.
145169 */
146170 public String ensureConsumer (
147171 String streamName ,
@@ -150,43 +174,51 @@ public String ensureConsumer(
150174 long maxDeliver ,
151175 long maxAckPending ,
152176 String filterSubject ) {
177+ String cacheKey = streamName + "/" + consumerName ;
178+ String cached = consumerCache .get (cacheKey );
179+ if (cached != null ) {
180+ return cached ;
181+ }
182+ Object lock = consumerLocks .computeIfAbsent (cacheKey , k -> new Object ());
183+ synchronized (lock ) {
184+ cached = consumerCache .get (cacheKey );
185+ if (cached != null ) {
186+ return cached ;
187+ }
188+ String actual = doEnsureConsumer (
189+ streamName , consumerName , ackWait , maxDeliver , maxAckPending , filterSubject );
190+ consumerCache .put (cacheKey , actual );
191+ return actual ;
192+ }
193+ }
194+
195+ private String doEnsureConsumer (
196+ String streamName ,
197+ String consumerName ,
198+ Duration ackWait ,
199+ long maxDeliver ,
200+ long maxAckPending ,
201+ String filterSubject ) {
153202 try {
154203 ConsumerInfo info = safeGetConsumerInfo (streamName , consumerName );
155204 if (info != null ) {
156205 ConsumerConfiguration cc = info .getConsumerConfiguration ();
157206 if (cc .getAckWait () != null && !cc .getAckWait ().equals (ackWait )) {
158- log .log (
159- Level .WARNING ,
160- "Consumer "
161- + streamName
162- + "/"
163- + consumerName
164- + " ackWait differs (existing="
165- + cc .getAckWait ()
166- + ", desired="
167- + ackWait
168- + ") - leaving existing config in place." );
207+ log .log (Level .WARNING ,
208+ "Consumer " + streamName + "/" + consumerName
209+ + " ackWait differs (existing=" + cc .getAckWait ()
210+ + ", desired=" + ackWait + ") - leaving existing config in place." );
169211 }
170212 if (cc .getMaxDeliver () != maxDeliver ) {
171- log .log (
172- Level .WARNING ,
173- "Consumer "
174- + streamName
175- + "/"
176- + consumerName
177- + " maxDeliver differs (existing="
178- + cc .getMaxDeliver ()
179- + ", desired="
180- + maxDeliver
181- + ") - leaving existing config in place." );
213+ log .log (Level .WARNING ,
214+ "Consumer " + streamName + "/" + consumerName
215+ + " maxDeliver differs (existing=" + cc .getMaxDeliver ()
216+ + ", desired=" + maxDeliver + ") - leaving existing config in place." );
182217 }
183218 return consumerName ;
184219 }
185220 if (!config .isAutoCreateConsumers ()) {
186- throw new RqueueNatsException ("Consumer '"
187- + consumerName
188- + "' on stream '"
189- + streamName
221+ throw new RqueueNatsException ("Consumer '" + consumerName + "' on stream '" + streamName
190222 + "' does not exist and autoCreateConsumers=false" );
191223 }
192224 ConsumerConfiguration .Builder cb = ConsumerConfiguration .builder ()
@@ -204,8 +236,6 @@ public String ensureConsumer(
204236 } catch (JetStreamApiException e ) {
205237 // Error 10100 = "filtered consumer not unique" — a consumer with the same filter
206238 // already exists on the stream (stale from a previous naming scheme or crashed run).
207- // List all consumers and check if one matches our filter; reuse it rather than
208- // failing the startup.
209239 if (e .getApiErrorCode () == 10100 && filterSubject != null ) {
210240 return tryFindAndBindStaleConsumer (streamName , filterSubject , consumerName );
211241 }
0 commit comments