-
Notifications
You must be signed in to change notification settings - Fork 8
Expand file tree
/
Copy pathmanager.go
More file actions
322 lines (281 loc) · 9.59 KB
/
Copy pathmanager.go
File metadata and controls
322 lines (281 loc) · 9.59 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
package rita
import (
"context"
"fmt"
"log/slog"
"maps"
"strings"
"time"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"github.com/synadia-labs/rita/clock"
"github.com/synadia-labs/rita/id"
"github.com/synadia-labs/rita/types"
)
const (
eventStoreNameTmpl = "ES_%s"
eventStoreSubjectTmpl = "$ES.%s."
// tenantMetaKey marks a stream as a tenant store via its metadata. Its
// presence is what GetEventStore keys on to set the store's mode; the value
// is a minimal non-empty marker. The mode is fixed at creation and never toggled.
tenantMetaKey = "rita.tenant"
tenantMetaVal = "1"
// natsMetaPrefix marks metadata keys the NATS server manages itself (stream
// API level, server version). They appear on reads but must not be supplied
// on writes — the server re-derives them — so a merge drops them rather than
// echoing a stale snapshot back.
natsMetaPrefix = "_nats."
)
// streamName returns the JetStream stream name ("ES_<name>") backing the
// store with the given name. Single owner of the template so the
// backward-compatible naming cannot drift between call sites.
func streamName(name string) string {
return fmt.Sprintf(eventStoreNameTmpl, name)
}
// subjectRoot returns the untenanted subject prefix ("$ES.<name>.") for the
// store with the given name. Same single-owner rationale as streamName.
func subjectRoot(name string) string {
return fmt.Sprintf(eventStoreSubjectTmpl, name)
}
type managerOption func(o *Manager) error
func (f managerOption) addOption(o *Manager) error {
return f(o)
}
// ManagerOption models a option when creating a type registry.
type ManagerOption interface {
addOption(o *Manager) error
}
// WithRegistry sets an explicit type registry. A nil registry keeps the
// default binary mode, matching the historical nil-field behavior.
func WithRegistry(types *types.Registry) ManagerOption {
return managerOption(func(o *Manager) error {
if types != nil {
o.types = registryTypes{r: types}
}
return nil
})
}
// WithClock sets a clock implementation. Default it clock.Time.
func WithClock(clock clock.Clock) ManagerOption {
return managerOption(func(o *Manager) error {
o.clock = clock
return nil
})
}
// WithIDer sets a unique ID generator implementation. Default is id.NUID.
func WithIDer(id id.ID) ManagerOption {
return managerOption(func(o *Manager) error {
o.id = id
return nil
})
}
func WithLogger(logger *slog.Logger) ManagerOption {
return managerOption(func(o *Manager) error {
o.logger = logger
return nil
})
}
// WithAPIPrefix sets a custom JetStream API prefix on the NATS connection.
func WithAPIPrefix(apiPrefix string) ManagerOption {
return managerOption(func(o *Manager) error {
o.apiPrefix = apiPrefix
return nil
})
}
// eventSubject builds the fully-qualified subject for an event, scoped to this
// store and (when the handle is tenant-scoped) its tenant. It funnels through
// subjectPrefix so the untenanted form stays byte-identical to
// "$ES.<name>.<entity>.<type>" and an unscoped tenant-mode handle gets
// ErrTenantRequired.
func (s *EventStore) eventSubject(event *Event) (string, error) {
return s.subjectPrefix(event.Entity + "." + event.Type)
}
type EventStoreConfig struct {
Name string
Description string
Metadata map[string]string
Replicas int
Storage jetstream.StorageType
Placement *jetstream.Placement
RePublish *jetstream.RePublish
MaxMsgs int64
MaxAge time.Duration
MaxBytes int64
// Tenancy creates the store as a tenant store: every store operation must go
// through a tenant-scoped handle (see (*EventStore).Tenant) and every event
// subject carries a leading tenant token. The mode is fixed at creation and
// recorded in stream metadata; it cannot be toggled by a later update.
Tenancy bool
}
// streamMetadata augments the given metadata with the reserved tenancy marker,
// allocating the map if it is nil. Callers invoke it only for tenant stores, so
// untenanted streams keep their metadata untouched and stay byte-identical.
func streamMetadata(metadata map[string]string) map[string]string {
if metadata == nil {
metadata = make(map[string]string, 1)
}
metadata[tenantMetaKey] = tenantMetaVal
return metadata
}
// mergeStreamMetadata overlays the user-supplied metadata onto the metadata
// already stored on the stream. JetStream replaces a stream's metadata wholesale
// on update, so without this a caller that does not re-supply a custom key set by
// an earlier create or update would silently drop it. User-supplied keys win on
// conflict; server-managed natsMetaPrefix keys are dropped so the server can
// re-derive them. Returns nil when the result is empty so metadata-free streams
// stay byte-identical.
func mergeStreamMetadata(existing, supplied map[string]string) map[string]string {
merged := make(map[string]string, len(existing)+len(supplied))
for k, v := range existing {
if strings.HasPrefix(k, natsMetaPrefix) {
continue
}
merged[k] = v
}
maps.Copy(merged, supplied)
if len(merged) == 0 {
return nil
}
return merged
}
// toStreamConfig projects the store configuration onto the JetStream stream
// config. It is the single owner of this mapping so create and update cannot
// drift apart. Metadata is passed explicitly because the two callers derive it
// differently (tenancy marker vs merge with existing).
func (c EventStoreConfig) toStreamConfig(metadata map[string]string) jetstream.StreamConfig {
return jetstream.StreamConfig{
Name: streamName(c.Name),
Description: c.Description,
Metadata: metadata,
Subjects: []string{subjectRoot(c.Name) + ">"},
Replicas: c.Replicas,
Storage: c.Storage,
Placement: c.Placement,
RePublish: c.RePublish,
MaxMsgs: c.MaxMsgs,
MaxAge: c.MaxAge,
MaxBytes: c.MaxBytes,
AllowAtomicPublish: true,
AllowDirect: true,
}
}
// Manager creates and manages EventStore instances. It provides shared
// dependencies (type registry, ID generator, clock) to all stores it creates.
type Manager struct {
logger *slog.Logger
js jetstream.JetStream
apiPrefix string
types typeRegistry
id id.ID
clock clock.Clock
}
func (m *Manager) GetEventStore(ctx context.Context, name string) (*EventStore, error) {
if name == "" {
return nil, ErrEventStoreNameRequired
}
sname := streamName(name)
// Verify the stream exists and discover whether it is a tenant store.
str, err := m.js.Stream(ctx, sname)
if err != nil {
return nil, err
}
_, tenantMode := str.CachedInfo().Config.Metadata[tenantMetaKey]
e := &EventStore{
name: name,
stream: sname,
prefix: subjectRoot(name),
tenantMode: tenantMode,
js: m.js,
id: m.id,
clock: m.clock,
types: m.types,
logger: m.logger,
}
return e, nil
}
// Create creates the event store given the configuration. The stream
// name is the name of the store and the subjects default to "{name}.>".
func (m *Manager) CreateEventStore(ctx context.Context, config EventStoreConfig) (*EventStore, error) {
if config.Name == "" {
return nil, ErrEventStoreNameRequired
}
metadata := config.Metadata
if config.Tenancy {
metadata = streamMetadata(metadata)
}
_, err := m.js.CreateStream(ctx, config.toStreamConfig(metadata))
if err != nil {
return nil, err
}
es := EventStore{
name: config.Name,
stream: streamName(config.Name),
prefix: subjectRoot(config.Name),
tenantMode: config.Tenancy,
js: m.js,
id: m.id,
clock: m.clock,
types: m.types,
logger: m.logger,
}
return &es, nil
}
// Update updates the event store configuration. Tenancy is immutable: the
// existing stream's mode is preserved regardless of config.Tenancy, so an
// update that forgets to set it cannot silently demote a tenant store.
//
// Custom Metadata is merged with the stream's existing metadata rather than
// replacing it: keys present in config.Metadata are written (overriding any
// prior value) and keys omitted are preserved. Removing a key is therefore not
// expressible through Update today.
func (m *Manager) UpdateEventStore(ctx context.Context, config EventStoreConfig) error {
if config.Name == "" {
return ErrEventStoreNameRequired
}
str, err := m.js.Stream(ctx, streamName(config.Name))
if err != nil {
return err
}
existing := str.CachedInfo().Config.Metadata
_, config.Tenancy = existing[tenantMetaKey]
// Preserve custom metadata set by earlier creates/updates: JetStream replaces
// metadata wholesale, so an update that omits a key would otherwise drop it.
metadata := mergeStreamMetadata(existing, config.Metadata)
if config.Tenancy {
metadata = streamMetadata(metadata)
}
_, err = m.js.UpdateStream(ctx, config.toStreamConfig(metadata))
return err
}
// Delete deletes the event store.
func (m *Manager) DeleteEventStore(ctx context.Context, name string) error {
return m.js.DeleteStream(ctx, streamName(name))
}
// New initializes a new Manager instance with a NATS connection.
func New(nc *nats.Conn, opts ...ManagerOption) (*Manager, error) {
m := &Manager{
logger: slog.Default(),
id: id.NUID,
clock: clock.Time,
// Default degenerate registry: caller-owned type names, raw []byte
// bodies via the binary codec. WithRegistry replaces it.
types: binaryTypes{},
}
for _, o := range opts {
if err := o.addOption(m); err != nil {
return nil, err
}
}
var js jetstream.JetStream
var err error
if m.apiPrefix != "" {
js, err = jetstream.NewWithAPIPrefix(nc, m.apiPrefix)
} else {
js, err = jetstream.New(nc)
}
if err != nil {
return nil, err
}
m.js = js
return m, nil
}