blob: 5bc1e16e9613d5864fdcaf5adf01d4bd0e1c7a4e [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you 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 parse drives one round of assembly and publishes it to a
// conversation's round chain.
//
// Each round does the same three things: assemble the whole session from the
// index, compare that against what the chain already says, and write only the
// difference. Assembling everything each time is what makes a round
// reproducible - the same index always produces the same entities - and
// comparing before writing is what keeps a round small.
package parse
import (
"encoding/json"
"fmt"
"time"
"github.com/apache/skywalking-ai-sessionizer/internal/assemble"
"github.com/apache/skywalking-ai-sessionizer/internal/index"
"github.com/apache/skywalking-ai-sessionizer/internal/storage"
"github.com/apache/skywalking-ai-sessionizer/pkg/model"
"github.com/apache/skywalking-ai-sessionizer/pkg/sessiondata"
"github.com/apache/skywalking-ai-sessionizer/pkg/sessionflow"
)
// Parser is the interpretation version written into every round header.
//
// A change here that alters meaning starts a new chain generation. It does not
// retroactively correct earlier rounds and must not be folded together with
// them, so the version travels in the header where a reader can see the mix.
const Parser = "v1"
// policyBase is the version of the choices that are settings rather than
// interpretation: the segment gates, and how a commit window is proposed.
const policyBase = "v1"
// DefaultIdleGap is the quiet period that makes a segment a close candidate.
//
// It is part of the POLICY, not a free parameter, because changing it changes
// which segments a round proposes. A chain is one interpretation of one body of
// evidence, so a round produced under a different gap must not fold together
// with rounds produced under this one.
const DefaultIdleGap = assemble.DefaultIdleGap
// policyFor renders the effective policy, settings included.
//
// A version string that stayed "v1" while a setting changed underneath it would
// be worse than no version at all: the chain would look consistent and would
// not be.
func policyFor(idle time.Duration) string {
if idle == 0 {
idle = DefaultIdleGap
}
return fmt.Sprintf("%s+idle=%s", policyBase, idle)
}
// DefaultMaxRoundBytes bounds a round file. A round travels whole as one log
// record when a root is exported, so it is cut at the same budget as a landed
// file. A round's size follows its input window, so the parser narrows the
// window until the round fits and leaves the rest of the evidence to the next
// round.
const DefaultMaxRoundBytes = 2 << 20
// Options configures one parse round.
type Options struct {
Conversation string
Session string
IdleGap time.Duration
// MaxRoundBytes is the largest round file to publish. Zero means
// DefaultMaxRoundBytes. A round covering a single landed file is
// published whole even when it is larger, as a single source record
// larger than the landing budget is landed whole.
MaxRoundBytes int64
// Now supplies the clock for chain state. Rounds themselves carry no time,
// so this never reaches a digest.
Now func() time.Time
// Reindex rebuilds a session's index by re-reading its landed files.
//
// It is what makes the index genuinely disposable. Without it the index can
// only be built while collecting, so landed data separated from its source -
// an archive, a bundle sent to someone else, a restored backup - could be
// read but never parsed again, even though every byte needed is present.
//
// Nil means index.Rebuild, which reads the converted records and needs no
// adapter. A transport that keeps landed data somewhere other than the
// zone supplies its own.
Reindex func(z *storage.Zone, session string, ix *index.Index, afterSeq uint64) (int, error)
}
// Round reports what one parse round did.
type Round struct {
Session string
Conversation string
// Number is 0 when nothing changed and no round was written.
Number uint64
Digest string
Path string
FromSeq uint64
ThroughSeq uint64
// More reports that the round was cut at the byte budget and evidence
// past ThroughSeq is indexed already. A caller that wants the chain to
// reach the index calls Session again until More is false.
More bool
Nodes int
Relations int
Unresolved int
Tombstones int
Stats assemble.Stats
}
// Changed reports whether the round wrote anything.
func (r *Round) Changed() bool { return r.Number != 0 }
// Session parses one session and appends a round to its conversation chain.
//
// It is safe to call when nothing has changed: the comparison finds no
// difference and no round is written, so a watch loop does not grow the chain
// with empty rounds.
func Session(z *storage.Zone, opt Options) (*Round, error) {
if opt.Reindex == nil {
opt.Reindex = index.Rebuild
}
if opt.Conversation == "" {
return nil, fmt.Errorf("parse: conversation identity is supplied, never inferred")
}
if opt.Now == nil {
opt.Now = time.Now
}
ix, ok, err := index.Load(z.IndexDir(opt.Session), opt.Session)
if err != nil {
return nil, err
}
if !ok {
ix, err = rebuild(z, opt)
if err != nil {
return nil, err
}
}
// The index must not be read past what it actually covers. Its own state
// records that; parsing beyond it would describe records the index does not
// hold.
ixState, err := storage.LoadIndexState(z.IndexStatePath(opt.Session), opt.Session)
if err != nil {
return nil, err
}
// The watermark is fixed BEFORE assembly and assembly is bounded by it.
// Deciding afterwards would let a round contain nodes drawn from evidence
// its own header and input digest do not cover, which a concurrent
// collector makes likely rather than theoretical.
through := ixState.IndexedSeq
if through == 0 {
for i := range ix.Entries {
if s := uint64(ix.Entries[i].Seq); s > through {
through = s
}
}
}
policy := policyFor(opt.IdleGap)
chain := sessionflow.OpenChain(z.Root(), opt.Conversation)
// Publishing is a read followed by a write - decide the next round from what
// is on disk, then create it. Two builders doing that at once would both
// read round N and both write an N+1, and because the digest is part of the
// filename their files would not even collide.
lock, err := chain.Lock()
if err != nil {
return nil, err
}
defer func() { _ = lock.Unlock() }()
// The filesystem is the authority on how far the chain got. A crash between
// publishing a round and saving state leaves a round on disk that state does
// not mention, and folding must see it.
view, err := chain.Fold()
if err != nil {
return nil, err
}
if view.Round > 0 {
if view.Parser != Parser || view.Policy != policy {
return nil, fmt.Errorf(
"parse: this chain was built by parser %q under policy %q; this build is %q/%q. "+
"A chain is one interpretation of one body of evidence, so it cannot be extended "+
"across a change to either", view.Parser, view.Policy, Parser, policy)
}
if view.Session != opt.Session {
return nil, fmt.Errorf("parse: this chain carries session %q, not %q", view.Session, opt.Session)
}
}
out := &Round{
Session: opt.Session, Conversation: opt.Conversation,
FromSeq: view.ThroughSeq + 1, ThroughSeq: through,
}
if through < view.ThroughSeq {
return nil, fmt.Errorf("parse: the chain covers landed sequence %d but the index only reaches %d",
view.ThroughSeq, through)
}
budget := opt.MaxRoundBytes
if budget <= 0 {
budget = DefaultMaxRoundBytes
}
indexed := through
round := view.Round + 1
var (
d *delta
data []byte
digest string
inputDigest string
)
for {
res, err := assemble.Session(ix, assemble.Options{
Conversation: opt.Conversation,
Session: opt.Session,
IdleGap: opt.IdleGap,
ThroughSeq: through,
})
if err != nil {
return nil, err
}
out.Stats = res.Stats
d = diff(view, res)
// A round is written when EVIDENCE advanced, not only when the
// structure changed. Evidence that produced no new entity - a replayed
// block, a record type nothing reads - still has to move the chain's
// watermark, or the next round re-reads it forever and "no round" comes
// to mean two different things: nothing new arrived, and nothing new
// mattered.
if d.empty() && through <= view.ThroughSeq {
out.ThroughSeq = view.ThroughSeq
return out, nil
}
// The input digest chains from the LAST ROUND's own header, not from
// the state file. State is a cache and may be stale or absent after a
// crash; chaining from it would break the link binding each round to
// the evidence before it.
inputDigest, err = inputDigestFor(z, opt.Session, view.InputDigest, view.ThroughSeq, through)
if err != nil {
return nil, err
}
fromTime, throughTime := windowTimes(ix, view.ThroughSeq, through)
sessionFrom, sessionThrough, title := sessionRange(res, opt.Session)
w, err := sessionflow.NewWriter(sessionflow.Header{
Conversation: opt.Conversation, Session: opt.Session,
Round: round, Previous: view.Digest,
FromSeq: view.ThroughSeq + 1, ThroughSeq: through,
InputDigest: inputDigest, Parser: Parser, Policy: policy,
FromTime: fromTime, ThroughTime: throughTime,
SessionFromTime: sessionFrom, SessionThroughTime: sessionThrough,
Title: title, Talks: countKind(res, model.KindTalk), Steps: countSteps(res), Streams: countKind(res, model.KindStream),
Segments: countKind(res, model.KindSegment), Unresolved: countOpen(res),
})
if err != nil {
return nil, err
}
for _, n := range d.nodes {
if err := w.Node(n); err != nil {
return nil, err
}
}
for _, r := range d.relations {
if err := w.Relation(r); err != nil {
return nil, err
}
}
for _, u := range d.unresolved {
if err := w.Unresolved(u); err != nil {
return nil, err
}
}
data, digest, err = w.Close()
if err != nil {
return nil, err
}
// A round over the budget is built again over a narrower window,
// halved each time, until it fits or covers a single landed file.
// Nothing is lost: the evidence past the window is still indexed and
// the next round starts where this one stops.
if int64(len(data)) <= budget || through <= out.FromSeq {
break
}
through = out.FromSeq + (through-out.FromSeq)/2
}
out.ThroughSeq = through
out.More = through < indexed
path, err := chain.Publish(round, digest, data)
if err != nil {
return nil, err
}
// State is saved after the round is on disk. The reverse order would leave
// state pointing at a round that does not exist.
state := &sessionflow.State{Schema: 1, Conversation: opt.Conversation}
state.Head, state.HeadDigest = round, digest
state.ThroughSeq, state.InputDigest = through, inputDigest
state.Parser, state.Policy = Parser, policy
if err := chain.SaveState(state, opt.Now()); err != nil {
return nil, err
}
out.Number, out.Digest, out.Path = round, digest, path
out.Nodes, out.Relations, out.Unresolved = len(d.nodes), len(d.relations), len(d.unresolved)
out.Tombstones = d.tombstones
return out, nil
}
// rebuild reconstructs a missing index from the landed files themselves.
//
// The rebuilt index is written back, so this cost is paid once rather than on
// every round. It is safe to write because the index is derived: if it is wrong
// or stale, deleting it and coming back here reproduces it.
func rebuild(z *storage.Zone, opt Options) (*index.Index, error) {
if opt.Reindex == nil {
return nil, fmt.Errorf("parse: no index for session %s, and no way to rebuild one", opt.Session)
}
ix := index.New(opt.Session)
n, err := opt.Reindex(z, opt.Session, ix, 0)
if err != nil {
return nil, fmt.Errorf("parse: rebuild index for %s: %w", opt.Session, err)
}
if n == 0 {
return nil, fmt.Errorf("parse: no landed records for session %s", opt.Session)
}
if err := ix.Write(z.IndexDir(opt.Session)); err != nil {
return nil, err
}
st := storage.NewIndexState(opt.Session)
st.Schema, st.Entries = index.Schema, len(ix.Entries)
for i := range ix.Entries {
if s := uint64(ix.Entries[i].Seq); s > st.IndexedSeq {
st.IndexedSeq = s
}
}
if err := st.Save(z.IndexStatePath(opt.Session), opt.Now()); err != nil {
return nil, err
}
return ix, nil
}
// inputDigestFor extends the chain's input digest with the evidence this round
// newly consumed.
//
// Only files above the previous watermark are read, so the cost is proportional
// to what is new rather than to the whole conversation. A landed file is written
// once and never appended to, so its digest is fixed from the moment it appears.
func inputDigestFor(z *storage.Zone, session, previous string, after, through uint64) (string, error) {
files, err := storage.LandedFiles(z, session)
if err != nil {
return "", err
}
var added []string
for _, f := range files {
if f.Seq <= after || f.Seq > through {
continue
}
d, err := storage.FileDigest(f.Path)
if err != nil {
return "", err
}
added = append(added, d)
}
return sessionflow.ChainInputDigest(previous, added), nil
}
// windowTimes is the earliest and the latest record time among the landed
// files a round consumes, from the index. They are what the runtime wrote,
// so they are evidence and reproduce with the round. Both are empty when no
// record in the window carries a time.
func windowTimes(ix *index.Index, after, through uint64) (from, throughTime string) {
var lo, hi int64
ok := false
for i := range ix.Entries {
e := &ix.Entries[i]
if uint64(e.Seq) <= after || uint64(e.Seq) > through || e.TS == 0 {
continue
}
if !ok || e.TS < lo {
lo = e.TS
}
if !ok || e.TS > hi {
hi = e.TS
}
ok = true
}
if !ok {
return "", ""
}
return sessiondata.FormatTime(lo), sessiondata.FormatTime(hi)
}
// sessionRange reads the session's own time range and title off the
// session node the assembler built, so the header repeats exactly what the
// node carries.
func sessionRange(res *assemble.Result, session string) (from, through, title string) {
id := sessionflow.NodeID("session", session)
for i := range res.Nodes {
if res.Nodes[i].ID != id || len(res.Nodes[i].Attrs) == 0 {
continue
}
var a struct {
From string `json:"from_time"`
Through string `json:"through_time"`
Title string `json:"title"`
}
_ = json.Unmarshal(res.Nodes[i].Attrs, &a)
return a.From, a.Through, a.Title
}
return "", "", ""
}
func countKind(res *assemble.Result, kind string) int {
n := 0
for i := range res.Nodes {
if res.Nodes[i].Kind == kind {
n++
}
}
return n
}
// countSteps counts the leaves of the tree, every node that is not
// structure, which is what a list of conversations shows as steps and what
// the asz.view document counts. The assembler's own Stats.Steps counts
// differently and is not what a reader compares against.
func countSteps(res *assemble.Result) int {
n := 0
for i := range res.Nodes {
switch res.Nodes[i].Kind {
case model.KindConversation, model.KindSegment, model.KindSession, model.KindStream, model.KindEpoch, model.KindTalk, model.KindRun:
default:
n++
}
}
return n
}
func countOpen(res *assemble.Result) int {
n := 0
for i := range res.Unresolved {
if res.Unresolved[i].State == "open" {
n++
}
}
return n
}
// delta is what one round must write.
type delta struct {
nodes []sessionflow.Node
relations []sessionflow.Relation
unresolved []sessionflow.Unresolved
tombstones int
}
func (d *delta) empty() bool {
return len(d.nodes) == 0 && len(d.relations) == 0 && len(d.unresolved) == 0
}
// diff compares a fresh assembly against what the chain already says.
//
// Only what changed is written. An entity the assembly produced unchanged is
// left out, because absence in a round means unchanged - which is exactly what
// keeps a round proportional to new work rather than to the whole conversation.
//
// An entity the chain holds that the assembly no longer produces gets a
// tombstone. Silence would mean unchanged, so removal has to be said.
func diff(view *sessionflow.View, res *assemble.Result) *delta {
d := &delta{}
seenNode := map[string]bool{}
for _, n := range res.Nodes {
seenNode[n.ID] = true
if prev, ok := view.Nodes[n.ID]; ok && sameNode(prev, &n) {
continue
}
d.nodes = append(d.nodes, n)
}
for id, prev := range view.Nodes {
if !seenNode[id] {
d.nodes = append(d.nodes, sessionflow.Node{Entity: sessionflow.Entity{ID: id, Tombstone: true}})
d.tombstones++
_ = prev
}
}
seenRel := map[string]bool{}
for _, r := range res.Relations {
seenRel[r.ID] = true
if prev, ok := view.Relations[r.ID]; ok && sameRelation(prev, &r) {
continue
}
d.relations = append(d.relations, r)
}
for id := range view.Relations {
if !seenRel[id] {
d.relations = append(d.relations, sessionflow.Relation{Entity: sessionflow.Entity{ID: id, Tombstone: true}})
d.tombstones++
}
}
seenUnres := map[string]bool{}
for _, u := range res.Unresolved {
seenUnres[u.ID] = true
if prev, ok := view.Unresolved[u.ID]; ok && sameUnresolved(prev, &u) {
continue
}
d.unresolved = append(d.unresolved, u)
}
// An entry the assembly stopped producing has been resolved. It is superseded
// with that state rather than removed: a reader must be able to see that the
// gap existed and how it closed, and absence cannot say that.
for id, prev := range view.Unresolved {
if seenUnres[id] || prev.State != sessionflow.UnresolvedOpen {
continue
}
done := *prev
done.State = sessionflow.UnresolvedResolved
d.unresolved = append(d.unresolved, done)
}
sortDelta(d)
return d
}
// Comparing an assembled entity against a folded one has to ignore the two
// fields that are not about the entity at all.
//
// Revision is derived from chain position, so comparing it would make every
// entity look changed in every round. The frame tag is set by the writer, so a
// folded entity carries it and a freshly assembled one does not - comparing it
// would ALSO make every entity look changed, and every round would rewrite the
// whole conversation.
func normalize(e *sessionflow.Entity) {
e.Revision, e.T = 0, ""
}
func sameNode(a, b *sessionflow.Node) bool {
x, y := *a, *b
normalize(&x.Entity)
normalize(&y.Entity)
return jsonEqual(x, y)
}
func sameRelation(a, b *sessionflow.Relation) bool {
x, y := *a, *b
normalize(&x.Entity)
normalize(&y.Entity)
return jsonEqual(x, y)
}
func sameUnresolved(a, b *sessionflow.Unresolved) bool {
x, y := *a, *b
normalize(&x.Entity)
normalize(&y.Entity)
return jsonEqual(x, y)
}
func jsonEqual(a, b any) bool {
x, err1 := json.Marshal(a)
y, err2 := json.Marshal(b)
if err1 != nil || err2 != nil {
return false
}
return string(x) == string(y)
}