feat: enhance observability with prefix aggregation metrics
CI / changes (push) Successful in 7s
CI / openapi (push) Has been skipped
CI / go (push) Successful in 33s
CI / docker-web (push) Has been skipped
CI / docker-bird (push) Has been skipped
CI / bird2 (push) Successful in 16s
CI / docker-go (push) Successful in 8m30s

- Added new histograms to track prefix aggregation duration, raw count, and aggregated count during tenant rendering.
- Implemented `RecordPrefixAggregation` function to record metrics for aggregation performance.
- Updated `RenderTenantRevision` and `RenderTenantRevisionFromPrefixes` functions to log aggregation statistics.
- Refactored `smartAggregatePrefixRows` to support both IPv4 and IPv6 aggregation, improving overall prefix handling.
This commit is contained in:
Denozordec
2026-05-19 15:38:33 +07:00
parent 34ecc5c235
commit 07b97bddc1
4 changed files with 358 additions and 50 deletions
+34
View File
@@ -59,8 +59,42 @@ var (
Name: "build_info", Name: "build_info",
Help: "Build metadata (value always 1).", Help: "Build metadata (value always 1).",
}, []string{"version", "git_sha"}) }, []string{"version", "git_sha"})
prefixAggregationDuration = promauto.NewHistogram(prometheus.HistogramOpts{
Namespace: namespace,
Name: "prefix_aggregation_duration_seconds",
Help: "Time spent in smartAggregatePrefixRows during tenant render.",
Buckets: prometheus.ExponentialBuckets(0.0001, 2, 16),
})
prefixAggregationRawCount = promauto.NewHistogram(prometheus.HistogramOpts{
Namespace: namespace,
Name: "prefix_aggregation_raw_count",
Help: "Prefix row count before CIDR aggregation on tenant render.",
Buckets: prometheus.ExponentialBuckets(1, 2, 16),
})
prefixAggregationAggregatedCount = promauto.NewHistogram(prometheus.HistogramOpts{
Namespace: namespace,
Name: "prefix_aggregation_aggregated_count",
Help: "Prefix row count after CIDR aggregation on tenant render.",
Buckets: prometheus.ExponentialBuckets(1, 2, 16),
})
) )
// RecordPrefixAggregation records tenant render CIDR aggregation stats.
func RecordPrefixAggregation(rawCount, aggregatedCount int, duration time.Duration) {
if rawCount < 0 {
rawCount = 0
}
if aggregatedCount < 0 {
aggregatedCount = 0
}
prefixAggregationDuration.Observe(duration.Seconds())
prefixAggregationRawCount.Observe(float64(rawCount))
prefixAggregationAggregatedCount.Observe(float64(aggregatedCount))
}
// RecordJobTerminal increments jobs_finished_total for terminal statuses. // RecordJobTerminal increments jobs_finished_total for terminal statuses.
func RecordJobTerminal(kind, status string) { func RecordJobTerminal(kind, status string) {
switch status { switch status {
+161 -49
View File
@@ -8,6 +8,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"math/big"
"net" "net"
"net/http" "net/http"
"net/netip" "net/netip"
@@ -18,6 +19,7 @@ import (
"time" "time"
"evobgp/internal/birdfmt" "evobgp/internal/birdfmt"
"evobgp/internal/observability"
"evobgp/internal/store" "evobgp/internal/store"
"github.com/google/uuid" "github.com/google/uuid"
@@ -79,11 +81,14 @@ func RenderTenantRevision(ctx context.Context, st store.Backend, hc *http.Client
if err != nil { if err != nil {
return "", err return "", err
} }
rawCount := len(agg)
aggStart := time.Now()
agg = smartAggregatePrefixRows(agg)
observability.RecordPrefixAggregation(rawCount, len(agg), time.Since(aggStart))
hash := hashAggregatedMaterializationWithPeers(st, tenantID, agg) hash := hashAggregatedMaterializationWithPeers(st, tenantID, agg)
if prev := latestTenantRevision(st, tenantID); prev != nil && prev.ContentHash == hash { if prev := latestTenantRevision(st, tenantID); prev != nil && prev.ContentHash == hash {
return prev.ID, nil return prev.ID, nil
} }
agg = smartAggregatePrefixRows(agg)
revisionID = uuid.NewString() revisionID = uuid.NewString()
parent := parentRevision(st, tenantID, triggerModuleID) parent := parentRevision(st, tenantID, triggerModuleID)
@@ -106,11 +111,14 @@ func RenderTenantRevisionFromPrefixes(ctx context.Context, st store.Backend, hc
hc = http.DefaultClient hc = http.DefaultClient
} }
agg := append([]store.PrefixRow(nil), rows...) agg := append([]store.PrefixRow(nil), rows...)
rawCount := len(agg)
aggStart := time.Now()
agg = smartAggregatePrefixRows(agg)
observability.RecordPrefixAggregation(rawCount, len(agg), time.Since(aggStart))
hash := hashAggregatedMaterializationWithPeers(st, tenantID, agg) hash := hashAggregatedMaterializationWithPeers(st, tenantID, agg)
if prev := latestTenantRevision(st, tenantID); prev != nil && prev.ContentHash == hash { if prev := latestTenantRevision(st, tenantID); prev != nil && prev.ContentHash == hash {
return prev.ID, nil return prev.ID, nil
} }
agg = smartAggregatePrefixRows(agg)
revisionID = uuid.NewString() revisionID = uuid.NewString()
parent := parentRevision(st, tenantID, triggerModuleID) parent := parentRevision(st, tenantID, triggerModuleID)
@@ -426,14 +434,20 @@ type prefixGroupKey struct {
source string source string
} }
// smartAggregatePrefixRows performs "safe" IPv4 CIDR aggregation after full tenant materialization. // smartAggregatePrefixRows performs "safe" IPv4/IPv6 CIDR aggregation after full tenant materialization.
// We aggregate only inside identical community/source groups to preserve BIRD attributes semantics. // We aggregate only inside identical community/source groups to preserve BIRD attributes semantics.
func smartAggregatePrefixRows(rows []store.PrefixRow) []store.PrefixRow { func smartAggregatePrefixRows(rows []store.PrefixRow) []store.PrefixRow {
grouped := make(map[prefixGroupKey][]store.PrefixRow) groupedV4 := make(map[prefixGroupKey][]store.PrefixRow)
groupedV6 := make(map[prefixGroupKey][]store.PrefixRow)
var passthrough []store.PrefixRow var passthrough []store.PrefixRow
for _, row := range rows { for _, row := range rows {
pfx, err := netip.ParsePrefix(strings.TrimSpace(row.Prefix)) p := strings.TrimSpace(row.Prefix)
if err != nil || !pfx.Addr().Is4() { if strings.HasPrefix(p, "as:") {
passthrough = append(passthrough, row)
continue
}
pfx, err := netip.ParsePrefix(p)
if err != nil {
passthrough = append(passthrough, row) passthrough = append(passthrough, row)
continue continue
} }
@@ -443,17 +457,73 @@ func smartAggregatePrefixRows(rows []store.PrefixRow) []store.PrefixRow {
} }
r := row r := row
r.Prefix = pfx.Masked().String() r.Prefix = pfx.Masked().String()
grouped[k] = append(grouped[k], r) switch {
case pfx.Addr().Is4():
groupedV4[k] = append(groupedV4[k], r)
case pfx.Addr().Is6():
groupedV6[k] = append(groupedV6[k], r)
default:
passthrough = append(passthrough, row)
}
} }
out := append([]store.PrefixRow{}, passthrough...) out := append([]store.PrefixRow{}, passthrough...)
for _, grp := range grouped { out = append(out, aggregateGroupedRows(groupedV4, aggregateIPv4Group)...)
out = append(out, aggregateIPv4Group(grp)...) out = append(out, aggregateGroupedRows(groupedV6, aggregateIPv6Group)...)
sortPrefixRows(out)
return out
}
func aggregateGroupedRows(grouped map[prefixGroupKey][]store.PrefixRow, aggregateFn func([]store.PrefixRow) []store.PrefixRow) []store.PrefixRow {
if len(grouped) == 0 {
return nil
}
keys := make([]prefixGroupKey, 0, len(grouped))
for k := range grouped {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
if keys[i].community != keys[j].community {
return keys[i].community < keys[j].community
}
return keys[i].source < keys[j].source
})
var out []store.PrefixRow
for _, k := range keys {
out = append(out, aggregateFn(grouped[k])...)
} }
return out return out
} }
func sortPrefixRows(rows []store.PrefixRow) {
sort.Slice(rows, func(i, j int) bool {
if rows[i].Prefix != rows[j].Prefix {
return rows[i].Prefix < rows[j].Prefix
}
ci, cj := prefixRowCommunity(rows[i]), prefixRowCommunity(rows[j])
if ci != cj {
return ci < cj
}
return rows[i].Source < rows[j].Source
})
}
func prefixRowCommunity(r store.PrefixRow) string {
if r.CommunityID != nil {
return *r.CommunityID
}
return ""
}
func aggregateIPv4Group(rows []store.PrefixRow) []store.PrefixRow { func aggregateIPv4Group(rows []store.PrefixRow) []store.PrefixRow {
return aggregateCIDRGroup(rows, mergeSiblingPrefixesIPv4)
}
func aggregateIPv6Group(rows []store.PrefixRow) []store.PrefixRow {
return aggregateCIDRGroup(rows, mergeSiblingPrefixesIPv6)
}
func aggregateCIDRGroup(rows []store.PrefixRow, mergeFn func(map[string]store.PrefixRow) bool) []store.PrefixRow {
if len(rows) <= 1 { if len(rows) <= 1 {
return rows return rows
} }
@@ -463,7 +533,7 @@ func aggregateIPv4Group(rows []store.PrefixRow) []store.PrefixRow {
} }
pruneCoveredPrefixes(set) pruneCoveredPrefixes(set)
for { for {
if !mergeSiblingPrefixes(set) { if !mergeFn(set) {
break break
} }
pruneCoveredPrefixes(set) pruneCoveredPrefixes(set)
@@ -472,6 +542,7 @@ func aggregateIPv4Group(rows []store.PrefixRow) []store.PrefixRow {
for _, row := range set { for _, row := range set {
out = append(out, row) out = append(out, row)
} }
sortPrefixRows(out)
return out return out
} }
@@ -484,7 +555,7 @@ func pruneCoveredPrefixes(set map[string]store.PrefixRow) {
items := make([]item, 0, len(set)) items := make([]item, 0, len(set))
for k := range set { for k := range set {
p, err := netip.ParsePrefix(k) p, err := netip.ParsePrefix(k)
if err != nil || !p.Addr().Is4() { if err != nil {
continue continue
} }
items = append(items, item{key: k, pfx: p, bits: p.Bits()}) items = append(items, item{key: k, pfx: p, bits: p.Bits()})
@@ -507,7 +578,7 @@ func pruneCoveredPrefixes(set map[string]store.PrefixRow) {
} }
} }
func mergeSiblingPrefixes(set map[string]store.PrefixRow) bool { func mergeSiblingPrefixesIPv4(set map[string]store.PrefixRow) bool {
merged := false merged := false
seen := make(map[string]struct{}, len(set)) seen := make(map[string]struct{}, len(set))
for key, row := range set { for key, row := range set {
@@ -555,6 +626,60 @@ func u32ToIPv4(v uint32) netip.Addr {
return netip.AddrFrom4([4]byte{byte(v >> 24), byte(v >> 16), byte(v >> 8), byte(v)}) return netip.AddrFrom4([4]byte{byte(v >> 24), byte(v >> 16), byte(v >> 8), byte(v)})
} }
func mergeSiblingPrefixesIPv6(set map[string]store.PrefixRow) bool {
merged := false
seen := make(map[string]struct{}, len(set))
for key, row := range set {
if _, done := seen[key]; done {
continue
}
pfx, err := netip.ParsePrefix(key)
if err != nil || !pfx.Addr().Is6() {
continue
}
bits := pfx.Bits()
if bits <= 16 {
continue
}
netNum := ipv6PrefixNetwork(pfx)
blockSize := new(big.Int).Lsh(big.NewInt(1), uint(128-bits))
siblingNet := new(big.Int).Xor(netNum, blockSize)
siblingPfx := ipv6PrefixFromBigInt(siblingNet, bits).String()
if _, ok := set[siblingPfx]; !ok {
continue
}
parentBits := bits - 1
parentBlock := new(big.Int).Lsh(big.NewInt(1), uint(128-parentBits))
mask := new(big.Int).Sub(parentBlock, big.NewInt(1))
mask.Not(mask)
parentNet := new(big.Int).And(netNum, mask)
parentPfx := ipv6PrefixFromBigInt(parentNet, parentBits).String()
delete(set, key)
delete(set, siblingPfx)
parentRow := row
parentRow.Prefix = parentPfx
set[parentPfx] = parentRow
seen[key] = struct{}{}
seen[siblingPfx] = struct{}{}
merged = true
}
return merged
}
func ipv6PrefixNetwork(p netip.Prefix) *big.Int {
a := p.Masked().Addr().As16()
n := new(big.Int)
n.SetBytes(a[:])
return n
}
func ipv6PrefixFromBigInt(n *big.Int, bits int) netip.Prefix {
b := n.Bytes()
var a [16]byte
copy(a[16-len(b):], b)
return netip.PrefixFrom(netip.AddrFrom16(a), bits).Masked()
}
func parentRevision(st store.Backend, tenantID, moduleID string) *string { func parentRevision(st store.Backend, tenantID, moduleID string) *string {
items, _, _ := st.ListRevisions(tenantID, moduleID, "", 1) items, _, _ := st.ListRevisions(tenantID, moduleID, "", 1)
if len(items) == 0 { if len(items) == 0 {
@@ -573,16 +698,19 @@ func latestTenantRevision(st store.Backend, tenantID string) *store.Revision {
return items[0] return items[0]
} }
// hashAggregatedMaterialization hashes the full tenant-wide prefix set used for BIRD (all enabled modules). type prefixHashLine struct{ p, c, s string }
func hashAggregatedMaterialization(tenantID string, rows []store.PrefixRow) string {
type line struct{ p, c, s string } func dedupeSortedPrefixLines(rows []store.PrefixRow) []prefixHashLine {
var lines []line seen := make(map[string]struct{}, len(rows))
lines := make([]prefixHashLine, 0, len(rows))
for _, r := range rows { for _, r := range rows {
c := "" c := prefixRowCommunity(r)
if r.CommunityID != nil { key := r.Prefix + "\x00" + c + "\x00" + r.Source
c = *r.CommunityID if _, ok := seen[key]; ok {
continue
} }
lines = append(lines, line{r.Prefix, c, r.Source}) seen[key] = struct{}{}
lines = append(lines, prefixHashLine{r.Prefix, c, r.Source})
} }
sort.Slice(lines, func(i, j int) bool { sort.Slice(lines, func(i, j int) bool {
if lines[i].p != lines[j].p { if lines[i].p != lines[j].p {
@@ -593,7 +721,10 @@ func hashAggregatedMaterialization(tenantID string, rows []store.PrefixRow) stri
} }
return lines[i].s < lines[j].s return lines[i].s < lines[j].s
}) })
h := sha256.New() return lines
}
func writePrefixLinesHash(h interface{ Write([]byte) (int, error) }, tenantID string, lines []prefixHashLine) {
h.Write([]byte(strings.TrimSpace(tenantID))) h.Write([]byte(strings.TrimSpace(tenantID)))
h.Write([]byte{0}) h.Write([]byte{0})
for _, l := range lines { for _, l := range lines {
@@ -604,39 +735,20 @@ func hashAggregatedMaterialization(tenantID string, rows []store.PrefixRow) stri
h.Write([]byte(l.s)) h.Write([]byte(l.s))
h.Write([]byte{0}) h.Write([]byte{0})
} }
}
// hashAggregatedMaterialization hashes the post-aggregation tenant-wide prefix set used for BIRD.
func hashAggregatedMaterialization(tenantID string, rows []store.PrefixRow) string {
lines := dedupeSortedPrefixLines(rows)
h := sha256.New()
writePrefixLinesHash(h, tenantID, lines)
return fmt.Sprintf("sha256:%x", h.Sum(nil)) return fmt.Sprintf("sha256:%x", h.Sum(nil))
} }
func hashAggregatedMaterializationWithPeers(st store.Backend, tenantID string, rows []store.PrefixRow) string { func hashAggregatedMaterializationWithPeers(st store.Backend, tenantID string, rows []store.PrefixRow) string {
type line struct{ p, c, s string } lines := dedupeSortedPrefixLines(rows)
var lines []line
for _, r := range rows {
c := ""
if r.CommunityID != nil {
c = *r.CommunityID
}
lines = append(lines, line{r.Prefix, c, r.Source})
}
sort.Slice(lines, func(i, j int) bool {
if lines[i].p != lines[j].p {
return lines[i].p < lines[j].p
}
if lines[i].c != lines[j].c {
return lines[i].c < lines[j].c
}
return lines[i].s < lines[j].s
})
h := sha256.New() h := sha256.New()
h.Write([]byte(strings.TrimSpace(tenantID))) writePrefixLinesHash(h, tenantID, lines)
h.Write([]byte{0})
for _, l := range lines {
h.Write([]byte(l.p))
h.Write([]byte{1})
h.Write([]byte(l.c))
h.Write([]byte{1})
h.Write([]byte(l.s))
h.Write([]byte{0})
}
h.Write([]byte("peers")) h.Write([]byte("peers"))
h.Write([]byte{0}) h.Write([]byte{0})
peers := st.ListPeers(tenantID) peers := st.ListPeers(tenantID)
+150
View File
@@ -8,6 +8,15 @@ import (
"evobgp/internal/store" "evobgp/internal/store"
) )
func prefixRowSet(rows []store.PrefixRow) map[string]struct{} {
got := make(map[string]struct{}, len(rows))
for _, r := range rows {
c := prefixRowCommunity(r)
got[r.Prefix+"|"+c+"|"+r.Source] = struct{}{}
}
return got
}
func TestRefreshModule_AggregatesAllEnabledModules(t *testing.T) { func TestRefreshModule_AggregatesAllEnabledModules(t *testing.T) {
t.Setenv("EVOBGP_ASN_RESOLVE", "0") t.Setenv("EVOBGP_ASN_RESOLVE", "0")
@@ -119,3 +128,144 @@ func TestSmartAggregatePrefixRows_RespectsCommunityAndSource(t *testing.T) {
t.Fatalf("expected 3 resulting rows, got %d: %+v", len(out), out) t.Fatalf("expected 3 resulting rows, got %d: %+v", len(out), out)
} }
} }
func TestSmartAggregatePrefixRows_PruneCoveredPrefixes(t *testing.T) {
rows := []store.PrefixRow{
{Prefix: "10.0.0.0/16", Source: "ip_range"},
{Prefix: "10.0.0.0/24", Source: "ip_range"},
{Prefix: "10.0.1.0/24", Source: "ip_range"},
}
out := smartAggregatePrefixRows(rows)
got := prefixRowSet(out)
if len(out) != 1 {
t.Fatalf("expected 1 row after covered prune, got %d: %+v", len(out), out)
}
if _, ok := got["10.0.0.0/16||ip_range"]; !ok {
t.Fatalf("expected /16 only, got: %+v", out)
}
}
func TestSmartAggregatePrefixRows_MergeChainFourSlash24(t *testing.T) {
rows := []store.PrefixRow{
{Prefix: "10.0.0.0/24", Source: "ip_range"},
{Prefix: "10.0.1.0/24", Source: "ip_range"},
{Prefix: "10.0.2.0/24", Source: "ip_range"},
{Prefix: "10.0.3.0/24", Source: "ip_range"},
}
out := smartAggregatePrefixRows(rows)
got := prefixRowSet(out)
if _, ok := got["10.0.0.0/22||ip_range"]; !ok {
t.Fatalf("expected merged /22, got: %+v", out)
}
if len(out) != 1 {
t.Fatalf("expected 1 row, got %d: %+v", len(out), out)
}
}
func TestSmartAggregatePrefixRows_DoesNotMergeAdjacentSlash8(t *testing.T) {
rows := []store.PrefixRow{
{Prefix: "10.0.0.0/8", Source: "ip_range"},
{Prefix: "11.0.0.0/8", Source: "ip_range"},
}
out := smartAggregatePrefixRows(rows)
if len(out) != 2 {
t.Fatalf("expected two /8 prefixes, got %d: %+v", len(out), out)
}
}
func TestSmartAggregatePrefixRows_MergesIPv6Slash64(t *testing.T) {
rows := []store.PrefixRow{
{Prefix: "2001:db8:0:0::/64", Source: "ip_range"},
{Prefix: "2001:db8:0:1::/64", Source: "ip_range"},
}
out := smartAggregatePrefixRows(rows)
got := prefixRowSet(out)
if _, ok := got["2001:db8::/63||ip_range"]; !ok {
t.Fatalf("expected merged IPv6 /63, got: %+v", out)
}
if len(out) != 1 {
t.Fatalf("expected 1 row, got %d: %+v", len(out), out)
}
}
func TestSmartAggregatePrefixRows_StableOutputOrder(t *testing.T) {
rows := []store.PrefixRow{
{Prefix: "192.168.0.0/24", Source: "cdn:b"},
{Prefix: "10.0.0.0/24", Source: "ip_range"},
{Prefix: "10.0.1.0/24", Source: "ip_range"},
}
out1 := smartAggregatePrefixRows(rows)
out2 := smartAggregatePrefixRows(rows)
if len(out1) != len(out2) {
t.Fatalf("length mismatch: %d vs %d", len(out1), len(out2))
}
for i := range out1 {
if out1[i].Prefix != out2[i].Prefix || out1[i].Source != out2[i].Source {
t.Fatalf("order not stable at %d: %+v vs %+v", i, out1[i], out2[i])
}
}
}
func TestRenderTenantRevisionFromPrefixes_SkipsWhenRedundantRawRows(t *testing.T) {
t.Setenv("EVOBGP_ASN_RESOLVE", "0")
m := store.NewMemory()
m.SeedDemo()
tenant, _, modIP, _, _ := m.DemoIDs()
for _, mod := range m.ListModules(tenant) {
if mod == nil || mod.ID == modIP {
continue
}
disabled := false
if _, err := m.UpdateModule(tenant, mod.ID, &store.ModulePatch{Enabled: &disabled}); err != nil {
t.Fatal(err)
}
}
ctx := context.Background()
baseRows := []store.PrefixRow{{Prefix: "10.0.0.0/16", Source: "ip_range"}}
rev1, err := RenderTenantRevisionFromPrefixes(ctx, m, http.DefaultClient, tenant, modIP, baseRows)
if err != nil {
t.Fatal(err)
}
rev2, err := RenderTenantRevisionFromPrefixes(ctx, m, http.DefaultClient, tenant, modIP, append(baseRows,
store.PrefixRow{Prefix: "10.0.0.0/24", Source: "ip_range"},
))
if err != nil {
t.Fatal(err)
}
if rev2 != rev1 {
t.Fatalf("expected hash skip for redundant covered prefix, got rev1=%s rev2=%s", rev1, rev2)
}
before, _, _ := m.ListRevisions(tenant, "", "", 200)
rev3, err := RenderTenantRevisionFromPrefixes(ctx, m, http.DefaultClient, tenant, modIP, append(baseRows,
store.PrefixRow{Prefix: "10.0.0.0/24", Source: "ip_range"},
store.PrefixRow{Prefix: "10.0.0.0/24", Source: "ip_range"},
))
if err != nil {
t.Fatal(err)
}
if rev3 != rev1 {
t.Fatalf("expected hash skip for duplicate raw rows, got rev1=%s rev3=%s", rev1, rev3)
}
after, _, _ := m.ListRevisions(tenant, "", "", 200)
if len(after) != len(before) {
t.Fatalf("duplicate raw rows should not create revision, before=%d after=%d", len(before), len(after))
}
}
func TestHashAggregatedMaterialization_DedupesIdenticalRows(t *testing.T) {
rows := []store.PrefixRow{
{Prefix: "10.0.0.0/24", Source: "ip_range"},
{Prefix: "10.0.0.0/24", Source: "ip_range"},
}
h1 := hashAggregatedMaterialization("tenant-a", rows)
h2 := hashAggregatedMaterialization("tenant-a", []store.PrefixRow{
{Prefix: "10.0.0.0/24", Source: "ip_range"},
})
if h1 != h2 {
t.Fatalf("expected deduped hash to match, got %s vs %s", h1, h2)
}
}
+13 -1
View File
@@ -327,13 +327,22 @@ func (m *Memory) CreateRenderRevision(revisionID, tenantID, moduleID string, par
frag[k] = v frag[k] = v
} }
parent := parentRevisionID parent := parentRevisionID
createdAt := time.Now().UTC()
for _, r := range m.revisions {
if r == nil || r.TenantID != tenantID {
continue
}
if !r.CreatedAt.Before(createdAt) {
createdAt = r.CreatedAt.Add(time.Microsecond)
}
}
m.revisions[revisionID] = &Revision{ m.revisions[revisionID] = &Revision{
ID: revisionID, ID: revisionID,
TenantID: tenantID, TenantID: tenantID,
ModuleID: moduleID, ModuleID: moduleID,
ContentHash: contentHash, ContentHash: contentHash,
ParentRevisionID: parent, ParentRevisionID: parent,
CreatedAt: time.Now().UTC(), CreatedAt: createdAt,
MaterializedPrefixCount: len(prefixes), MaterializedPrefixCount: len(prefixes),
PreviewFragments: frag, PreviewFragments: frag,
} }
@@ -657,6 +666,9 @@ func (m *Memory) ListRevisions(tenantID, moduleID string, cursor string, limit i
all = append(all, r) all = append(all, r)
} }
sort.Slice(all, func(i, j int) bool { sort.Slice(all, func(i, j int) bool {
if all[i].CreatedAt.Equal(all[j].CreatedAt) {
return all[i].ID > all[j].ID
}
return all[i].CreatedAt.After(all[j].CreatedAt) return all[i].CreatedAt.After(all[j].CreatedAt)
}) })
off := 0 off := 0