// Copyright 2025 The NATS Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package server import ( "encoding/binary" "errors" "io" "math" "slices" "strings" "time" "github.com/nats-io/nats-server/v2/server/thw" ) // Error for when we try to decode a binary-encoded message schedule with an unknown version number. var ErrMsgScheduleInvalidVersion = errors.New("msg scheduling: encoded version not known") const ( headerLen = 17 // 1 byte magic + 2x uint64s ) type MsgScheduling struct { run func() ttls *thw.HashWheel timer *time.Timer running bool deadline int64 schedules map[string]*MsgSchedule seqToSubj map[uint64]string inflight map[string]struct{} } type MsgSchedule struct { seq uint64 ts int64 } func newMsgScheduling(run func()) *MsgScheduling { return &MsgScheduling{ run: run, ttls: thw.NewHashWheel(), schedules: make(map[string]*MsgSchedule), seqToSubj: make(map[uint64]string), inflight: make(map[string]struct{}), } } func (ms *MsgScheduling) add(seq uint64, subj string, ts int64) { ms.init(seq, subj, ts) ms.resetTimer() } func (ms *MsgScheduling) init(seq uint64, subj string, ts int64) { if sched, ok := ms.schedules[subj]; ok { delete(ms.seqToSubj, sched.seq) // Remove and add separately, since they'll have different sequences. ms.ttls.Remove(sched.seq, sched.ts) ms.ttls.Add(seq, ts) sched.ts, sched.seq = ts, seq } else { ms.ttls.Add(seq, ts) ms.schedules[subj] = &MsgSchedule{seq: seq, ts: ts} } ms.seqToSubj[seq] = subj delete(ms.inflight, subj) } func (ms *MsgScheduling) update(subj string, ts int64) { if sched, ok := ms.schedules[subj]; ok { // Remove and add separately, it's for the same sequence, but if replicated // this server could not know the previous timestamp yet. ms.ttls.Remove(sched.seq, sched.ts) ms.ttls.Add(sched.seq, ts) sched.ts = ts delete(ms.inflight, subj) ms.resetTimer() } } func (ms *MsgScheduling) markInflight(subj string) { if _, ok := ms.schedules[subj]; ok { ms.inflight[subj] = struct{}{} } } func (ms *MsgScheduling) isInflight(subj string) bool { _, ok := ms.inflight[subj] return ok } func (ms *MsgScheduling) remove(seq uint64) { if subj, ok := ms.seqToSubj[seq]; ok { ms.removeSubject(subj) } } func (ms *MsgScheduling) removeSubject(subj string) { if sched, ok := ms.schedules[subj]; ok { ms.ttls.Remove(sched.seq, sched.ts) delete(ms.schedules, subj) delete(ms.seqToSubj, sched.seq) delete(ms.inflight, subj) } } func (ms *MsgScheduling) clearInflight() { ms.inflight = make(map[string]struct{}) } func (ms *MsgScheduling) resetTimer() { // If we're already scheduling messages, it will make sure to reset. // Don't trigger again, as that could result in many expire goroutines. if ms.running { return } next := ms.ttls.GetNextExpiration(math.MaxInt64) if next == math.MaxInt64 { clearTimer(&ms.timer) return } fireIn := time.Until(time.Unix(0, next)) // Make sure we aren't firing too often either way, otherwise we can // negatively impact stream ingest performance. if fireIn < 250*time.Millisecond { fireIn = 250 * time.Millisecond } // If we want to kick the timer to run later than what was assigned before, don't reset it. // Otherwise, we could get in a situation where the timer is continuously reset, and it never runs. deadline := time.Now().UnixNano() + fireIn.Nanoseconds() if ms.deadline > 0 && deadline > ms.deadline { return } ms.deadline = deadline if ms.timer != nil { ms.timer.Reset(fireIn) } else { ms.timer = time.AfterFunc(fireIn, ms.run) } } func (ms *MsgScheduling) getScheduledMessages(loadMsg func(seq uint64, smv *StoreMsg) *StoreMsg, loadLast func(subj string, smv *StoreMsg) *StoreMsg) []*inMsg { var ( smv StoreMsg srcSmv StoreMsg sm *StoreMsg msgs []*inMsg ) ms.ttls.ExpireTasks(func(seq uint64, ts int64) bool { // Need to grab the message for the specified sequence, and check // if it hasn't been removed in the meantime. sm = loadMsg(seq, &smv) if sm != nil { // If already inflight, don't duplicate a scheduled message. The stream could // be replicated and the scheduled message could take some time to propagate. subj := sm.subj if ms.isInflight(subj) { return false } // Validate the contents are correct if not, we just remove it from THW. pattern := bytesToString(sliceHeader(JSSchedulePattern, sm.hdr)) if pattern == _EMPTY_ { ms.remove(seq) return true } loc, apiErr := loadMessageScheduleLocation(sm.hdr) if apiErr != nil { ms.remove(seq) return true } next, repeat, ok := parseMsgSchedule(pattern, loc, ts) if !ok { ms.remove(seq) return true } ttl, ok := getMessageScheduleTTL(sm.hdr) if !ok { ms.remove(seq) return true } target := getMessageScheduleTarget(sm.hdr) if target == _EMPTY_ { ms.remove(seq) return true } rollup := getMessageScheduleRollup(sm.hdr) source := getMessageScheduleSource(sm.hdr) if source != _EMPTY_ { // Fall back to the scheduled message's own content if the source has no last message. if srcSm := loadLast(source, &srcSmv); srcSm != nil { sm = srcSm } } // Copy, as this is retrieved directly from storage, and we'll need to keep hold of this for some time. // And in the case of headers, we'll copy all of them, but make changes. hdr, msg := copyBytes(sm.hdr), copyBytes(sm.msg) // Strip headers specific to message scheduling. // Covers Nats-Schedule, Nats-Schedule-*, and Nats-Scheduler. hdr = removeHeaderIfPrefixPresent(hdr, "Nats-Schedule") // Strip headers that could prevent persisting this scheduled message. hdr = removeHeaderIfPrefixPresent(hdr, "Nats-Expected-") hdr = removeHeaderIfPresent(hdr, JSMsgId) hdr = removeHeaderIfPresent(hdr, JSMessageTTL) hdr = removeHeaderIfPresent(hdr, JSMsgRollup) // Add headers for the scheduled message. hdr = genHeader(hdr, JSScheduler, subj) if !repeat { hdr = genHeader(hdr, JSScheduleNext, JSScheduleNextPurge) // Purge the schedule message itself. } else { hdr = genHeader(hdr, JSScheduleNext, next.Format(time.RFC3339)) // Next time the schedule fires. } if ttl != _EMPTY_ { hdr = genHeader(hdr, JSMessageTTL, ttl) } if rollup != _EMPTY_ { hdr = genHeader(hdr, JSMsgRollup, rollup) } msgs = append(msgs, &inMsg{seq: seq, subj: target, hdr: hdr, msg: msg}) ms.markInflight(subj) return false } ms.remove(seq) return true }) // THW is unordered, so must sort by sequence. slices.SortFunc(msgs, func(a, b *inMsg) int { if a.seq == b.seq { return 0 } else if a.seq < b.seq { return -1 } else { return 1 } }) return msgs } // encode writes out the contents of the schedule into a binary snapshot // and returns it. The high seq number is included in the snapshot and will // be returned on decode. func (ms *MsgScheduling) encode(highSeq uint64) []byte { count := uint64(len(ms.schedules)) b := make([]byte, 0, headerLen+(count*(2*binary.MaxVarintLen64))) b = append(b, 1) // Magic version b = binary.LittleEndian.AppendUint64(b, count) // Entry count b = binary.LittleEndian.AppendUint64(b, highSeq) // Stamp for subj, sched := range ms.schedules { slen := min(uint64(len(subj)), math.MaxUint16) b = binary.LittleEndian.AppendUint16(b, uint16(slen)) b = append(b, subj[:slen]...) b = binary.AppendVarint(b, sched.ts) b = binary.AppendUvarint(b, sched.seq) } return b } // decode snapshots a binary-encoded schedule and replaces the contents of this // schedule with them. Returns the high seq number from the snapshot. func (ms *MsgScheduling) decode(b []byte) (uint64, error) { if len(b) < headerLen { return 0, io.ErrShortBuffer } if b[0] != 1 { return 0, ErrMsgScheduleInvalidVersion } count := binary.LittleEndian.Uint64(b[1:]) stamp := binary.LittleEndian.Uint64(b[9:]) b = b[headerLen:] for i := uint64(0); i < count; i++ { sl := int(binary.LittleEndian.Uint16(b)) b = b[2:] if len(b) < sl { return 0, io.ErrUnexpectedEOF } subj := string(b[:sl]) b = b[sl:] ts, tn := binary.Varint(b) if tn < 0 { return 0, io.ErrUnexpectedEOF } seq, vn := binary.Uvarint(b[tn:]) if vn < 0 { return 0, io.ErrUnexpectedEOF } ms.init(seq, subj, ts) b = b[tn+vn:] } return stamp, nil } // parseMsgSchedule parses a message schedule pattern and returns the time // to fire, whether it is a repeating schedule, and whether the pattern was valid. func parseMsgSchedule(pattern string, loc *time.Location, ts int64) (time.Time, bool, bool) { if pattern == _EMPTY_ { return time.Time{}, false, true } // Exact time. if strings.HasPrefix(pattern, "@at ") { // Time zone is not supported for @at. if loc != nil { return time.Time{}, false, false } t, err := time.Parse(time.RFC3339, pattern[4:]) return t, false, err == nil } // Repeating on a simple interval. if strings.HasPrefix(pattern, "@every ") { // Time zone is not supported for @every. if loc != nil { return time.Time{}, false, false } dur, err := time.ParseDuration(pattern[7:]) if err != nil { return time.Time{}, false, false } // Only allow intervals of at least a second. if dur.Seconds() < 1 { return time.Time{}, false, false } // If this schedule would trigger multiple times, for example after a restart, skip ahead and only fire once. next := time.Unix(0, ts).UTC().Round(time.Second).Add(dur) if now := time.Now().UTC(); next.Before(now) { next = now.Round(time.Second).Add(dur) } return next, true, true } // Predefined schedules for cron. switch pattern { case "@yearly", "@annually": pattern = "0 0 0 1 1 *" case "@monthly": pattern = "0 0 0 1 * *" case "@weekly": pattern = "0 0 0 * * 0" case "@daily", "@midnight": pattern = "0 0 0 * * *" case "@hourly": pattern = "0 0 * * * *" } // Parse the cron pattern. next, err := parseCron(pattern, loc, ts) if err != nil { return time.Time{}, false, false } // If this schedule would trigger multiple times, for example after a restart, skip ahead and only fire once. if now := time.Now().UTC(); next.Before(now) { ts = now.Round(time.Second).UnixNano() next, err = parseCron(pattern, loc, ts) if err != nil { return time.Time{}, false, false } } return next, true, true }