message

package
v1.15.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 27, 2026 License: MIT Imports: 11 Imported by: 0

Documentation

Index

Constants

View Source
const NonObjectPayloadKey = "payload"

NonObjectPayloadKey is where a payload that is not a JSON object is exposed.

Sources are free to emit a bare string, a scalar, an array, or bytes that are not JSON at all. Such a payload has no field names to merge into the message root, so it is preserved under this single key instead. That keeps it addressable by transformations and, crucially, keeps it in the serialised output: previously only objects survived and everything else was silently dropped.

The name is deliberately "payload": array payloads have always been exposed this way, and it is the template variable the UI advertises for notification sinks ({{.payload}}). Widening the existing key to cover strings and scalars keeps every working template working, where a new name would not.

Variables

This section is empty.

Functions

func OverReleaseCount

func OverReleaseCount() int64

OverReleaseCount reports how many times a message was released after its refcount already reached zero. Any non-zero value means some owner is releasing a reference it does not hold; treat it as a correctness bug, not a tuning signal.

func PopulateFromMap added in v1.12.0

func PopulateFromMap(msg hermod.Message, data map[string]any)

PopulateFromMap builds a message from a ToMap()-shaped sample -- the shape the workflow editor holds, sends to the preview endpoint and stores as a source sample.

It lives here, rather than beside either of its callers, because it is the single answer to "what would the engine see for this sample". The editor's SQL query builder resolved query variables against the sample map itself while the preview endpoint resolved them against a message built from it, and the two disagree about the CDC envelope: ToMap re-nests the after-image under "after", and this function unwraps it back into the root, which is where the engine's Data() has it. So `{{.after.payload}}` returned rows in the builder and bound NULL in the node that ran the same text. One definition, two readers -- see TestTheBuilderBindsWhatTheEngineBinds.

func ReleaseMessage

func ReleaseMessage(m hermod.Message)

ReleaseMessage returns a message to the pool.

func ResetOverReleaseCount

func ResetOverReleaseCount()

ResetOverReleaseCount zeroes the counter. Intended for tests that assert a pipeline runs with balanced reference counting.

func SampleIsCDC added in v1.12.0

func SampleIsCDC(data map[string]any) bool

SampleIsCDC reports whether ToMap will serialise this sample as a CDC event, which is what decides whether the data map doubles as the after-image. It mirrors the one condition ToMap uses: a non-empty operation.

func SanitizeMap

func SanitizeMap(m map[string]any) map[string]any

SanitizeMap sanitizes all values in a map.

func SanitizeValue

func SanitizeValue(v any) any

SanitizeValue converts special types (like UUIDs) to JSON-friendly strings.

func TryFixJSON

func TryFixJSON(data []byte) []byte

TryFixJSON attempts to fix common JSON issues like trailing commas to make unmarshaling more lenient.

It works on the bytes rather than on a string copy of them. The first thing this does is decide whether the payload even looks like JSON, and it used to copy the whole body into a string to find out — which for a text, CSV or binary body is the answer "no" at the cost of a full copy, on every message.

Types

type DefaultMessage

type DefaultMessage struct {
	// contains filtered or unexported fields
}

DefaultMessage is a concrete implementation of the hermod.Message interface. It uses a sync.Pool to minimize allocations.

func AcquireMessage

func AcquireMessage() *DefaultMessage

AcquireMessage gets a message from the pool.

func (*DefaultMessage) After

func (m *DefaultMessage) After() []byte

func (*DefaultMessage) Before

func (m *DefaultMessage) Before() []byte

func (*DefaultMessage) ClearCachedPayload

func (m *DefaultMessage) ClearCachedPayload()

ClearCachedPayload clears only the marshaled payload bytes.

func (*DefaultMessage) ClearPayloads

func (m *DefaultMessage) ClearPayloads()

ClearPayloads clears the data content of the message but keeps metadata/system fields.

func (*DefaultMessage) Clone

func (m *DefaultMessage) Clone() hermod.Message

func (*DefaultMessage) Data

func (m *DefaultMessage) Data() map[string]any

func (*DefaultMessage) DataRef

func (m *DefaultMessage) DataRef() map[string]any

DataRef returns the live data map. The lock is released before it does, so a caller reading or writing the result holds nothing.

That is safe today only because of who calls it, and the reasoning is not visible from any one call site — so it is written down here. Three rules, and every caller satisfies one:

  • Mutate only a private copy. foreach walks and deletes through the DataRef of a Clone, and the PII scan — which recursively ranges the map, the exact operation the runtime makes fatal — is handed a Clone on its own goroutine. Clone deep-copies the data map, which is what makes both sound.
  • One consumer per object. The traversal clones when a node has more than one target, so two processNode goroutines never share a message.
  • Writes happen only where ownership is exclusive. A message is taken by exactly one worker goroutine, and when it is later fanned out to N sinks — the same object, one goroutine each — every holder is a reader. Sinks mutate only messages they construct themselves, which TestSinksDoNotMutateMessagesTheyAreGiven enforces rather than trusts.

Break any of those and this races SetData, which the runtime reports as `fatal error: concurrent map read and map write` and no recover() can contain. Prefer Data() unless the clone is measurably too expensive; ToMap() if the value outlives the caller's stack. And do not let this comment age the way OrderingKey's ownership argument did — it was true when written and false a caller later.

func (*DefaultMessage) ID

func (m *DefaultMessage) ID() string

func (*DefaultMessage) MarshalJSON

func (m *DefaultMessage) MarshalJSON() ([]byte, error)

func (*DefaultMessage) Metadata

func (m *DefaultMessage) Metadata() map[string]string

func (*DefaultMessage) MetadataRef

func (m *DefaultMessage) MetadataRef() map[string]string

func (*DefaultMessage) MetadataValue added in v1.8.1

func (m *DefaultMessage) MetadataValue(key string) (string, bool)

MetadataValue reads one metadata entry without copying the map.

Metadata() has to clone — handing out the live map would race with a concurrent SetMetadata — so reading a single key through it copies every other entry to throw them away. The trace propagator does exactly that twice per sink write (traceparent, then tracestate), and it is not alone. This does the lookup under the same read lock, so it is as safe and costs nothing.

func (*DefaultMessage) Operation

func (m *DefaultMessage) Operation() hermod.Operation

func (*DefaultMessage) Payload

func (m *DefaultMessage) Payload() []byte

func (*DefaultMessage) PayloadLen added in v1.8.1

func (m *DefaultMessage) PayloadLen() int

PayloadLen reports len(Payload()) without copying the payload.

The engine asks for a payload's size far more often than for the payload: batch-by-bytes accounting sums it for every message and the per-write debug log reports it. Going through Payload() for that cloned the bytes and threw the copy away — together 2.8 GB of the 4.9 GB a 150k-message benchmark allocated, to produce two integers.

It materialises the data map exactly as Payload() does, so the two never disagree about a message whose payload has not been marshalled yet; TestPayloadLenMatchesPayload holds them together.

func (*DefaultMessage) RefCount

func (m *DefaultMessage) RefCount() int32

RefCount reports the message's current reference count.

It exists so the ownership contract can actually be asserted rather than reasoned about. Every node executor must return messages the caller owns one reference to; that invariant is invisible without being able to read the count, which is why a violation in the traversal's source branch went unnoticed until it was corrupting data. Use it in tests and diagnostics, not to make control-flow decisions: the value can change under you at any moment.

func (*DefaultMessage) Release

func (m *DefaultMessage) Release()

func (*DefaultMessage) Reset

func (m *DefaultMessage) Reset()

Reset clears the message state so it can be reused.

func (*DefaultMessage) Retain

func (m *DefaultMessage) Retain()

func (*DefaultMessage) Schema

func (m *DefaultMessage) Schema() string

func (*DefaultMessage) SetAfter

func (m *DefaultMessage) SetAfter(after []byte)

func (*DefaultMessage) SetBefore

func (m *DefaultMessage) SetBefore(before []byte)

func (*DefaultMessage) SetData

func (m *DefaultMessage) SetData(key string, value any)

func (*DefaultMessage) SetID

func (m *DefaultMessage) SetID(id string)

Setters for DefaultMessage

func (*DefaultMessage) SetMetadata

func (m *DefaultMessage) SetMetadata(key, value string)

func (*DefaultMessage) SetOperation

func (m *DefaultMessage) SetOperation(op hermod.Operation)

func (*DefaultMessage) SetPayload

func (m *DefaultMessage) SetPayload(payload []byte)

func (*DefaultMessage) SetSchema

func (m *DefaultMessage) SetSchema(schema string)

func (*DefaultMessage) SetTable

func (m *DefaultMessage) SetTable(table string)

func (*DefaultMessage) Table

func (m *DefaultMessage) Table() string

func (*DefaultMessage) ToMap

func (m *DefaultMessage) ToMap() map[string]any

ToMap returns a snapshot of the message that shares no mutable state with it.

Independence is the contract, not a detail. Every caller either marshals the result or hands it to another goroutine — a trace step recorded against PostgreSQL, a live-viewer frame fanned out to SSE subscribers — while the pipeline keeps transforming the message it came from.

This used to copy the top level only, so each nested map[string]any in the snapshot was the same map the live message holds. SetData with a dotted key walks into that nested map and assigns in place, which put a write inside a map json.Marshal was already iterating on the trace goroutine. Concurrent map access is a runtime fatal error rather than a panic, so the recover() guarding that marshal could not contain it: the engine exited 2 mid-trace.

The deep copy is paid by callers who are about to serialise this anyway, which costs strictly more than copying it. Data() and DataRef() are unchanged — they are the hot read path and they do not cross a goroutine boundary.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL