Skip to content

Commit d5ec0ae

Browse files
aditya1702claude
andcommitted
Add streaming-loadtest ledger backend for apply-load replay
A dev-only LEDGER_BACKEND_TYPE=streaming-loadtest that reads stream-framed LedgerCloseMeta from named pipes written by stellar-core apply-load (one FIFO per transaction profile), renumbers each stream onto the consumer's requested sequence with per-pipe diffs, merges the per-sequence frames into one mixed-traffic ledger via the SDK's loadtest.MergeLedgers, and stamps monotone wall-clock close times (apply-load emits closeTime 0). Renumbering makes both sides restartable without a database reset: a restarted apply-load resets to raw sequence 1 and is mapped onto the next requested ledger; a restarted consumer resumes from its cursor. Pacing via --loadtest-ledger-close-duration bounds the ledger rate, and FIFO backpressure throttles the generators to match. Because apply-load's benchmark mode publishes no history archive, this backend type skips the archive connection and the cursor-0 checkpoint bootstrap: ingestion starts from ledger 1 on an empty database and balance state accumulates from the ledger stream. Everything downstream of the backend (live ingest loop, processors, persistence) is unchanged, so a load test exercises the same code path as production ingestion. Includes an opt-in corpus test (STREAMING_LOADTEST_CORPUS) that replays real apply-load output through the backend and the production transaction reader; verified against v27 sac/custom_token/soroswap corpora. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent a89672c commit d5ec0ae

12 files changed

Lines changed: 1321 additions & 15 deletions

cmd/ingest.go

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,11 +87,11 @@ func (c *ingestCmd) Command() *cobra.Command {
8787
},
8888
{
8989
Name: "archive-url",
90-
Usage: "Archive URL for history archives",
90+
Usage: "Archive URL for history archives. Required for every backend except 'streaming-loadtest', which runs without a history archive.",
9191
OptType: types.String,
9292
ConfigKey: &cfg.ArchiveURL,
9393
FlagDefault: "https://history.stellar.org/prd/core-testnet/core_testnet_001/",
94-
Required: true,
94+
Required: false,
9595
},
9696
{
9797
Name: "checkpoint-frequency",
@@ -103,12 +103,30 @@ func (c *ingestCmd) Command() *cobra.Command {
103103
},
104104
{
105105
Name: "ledger-backend-type",
106-
Usage: "Type of ledger backend to use for fetching ledgers. Options: 'rpc' or 'datastore' (default)",
106+
Usage: "Type of ledger backend to use for fetching ledgers. Options: 'rpc', 'datastore' (default), or 'streaming-loadtest' (dev-only; reads apply-load ledger meta from named pipes)",
107107
OptType: types.String,
108108
ConfigKey: &ledgerBackendType,
109109
FlagDefault: string(ingest.LedgerBackendTypeDatastore),
110110
Required: false,
111111
},
112+
{
113+
Name: "loadtest-meta-pipe-paths",
114+
Usage: "Dev-only. Comma-separated named pipe (FIFO) paths carrying apply-load ledger meta, one per apply-load process. Required when ledger-backend-type is 'streaming-loadtest', ignored otherwise.",
115+
OptType: types.String,
116+
CustomSetValue: utils.SetConfigOptionStringList,
117+
ConfigKey: &cfg.LoadtestMetaPipePaths,
118+
FlagDefault: "",
119+
Required: false,
120+
},
121+
{
122+
Name: "loadtest-ledger-close-duration",
123+
Usage: "Dev-only. Minimum interval between ledgers in streaming-loadtest mode (Go duration string, e.g. \"5s\"). \"0s\" leaves the stream uncapped.",
124+
OptType: types.String,
125+
CustomSetValue: utils.SetConfigOptionDuration,
126+
ConfigKey: &cfg.LoadtestLedgerCloseDuration,
127+
FlagDefault: "0s",
128+
Required: false,
129+
},
112130
{
113131
Name: "chunk-interval",
114132
Usage: "TimescaleDB chunk time interval for hypertables. Only affects future chunks. Uses PostgreSQL INTERVAL syntax.",
@@ -175,8 +193,20 @@ func (c *ingestCmd) Command() *cobra.Command {
175193
cfg.LedgerBackendType = ingest.LedgerBackendTypeRPC
176194
case string(ingest.LedgerBackendTypeDatastore):
177195
cfg.LedgerBackendType = ingest.LedgerBackendTypeDatastore
196+
case string(ingest.LedgerBackendTypeStreamingLoadtest):
197+
cfg.LedgerBackendType = ingest.LedgerBackendTypeStreamingLoadtest
178198
default:
179-
return fmt.Errorf("invalid ledger-backend-type '%s', must be 'rpc' or 'datastore'", ledgerBackendType)
199+
return fmt.Errorf("invalid ledger-backend-type '%s', must be 'rpc', 'datastore', or 'streaming-loadtest'", ledgerBackendType)
200+
}
201+
202+
// The streaming-loadtest backend needs its pipes and runs without a history
203+
// archive; every other backend reads a history archive to build initial state.
204+
if cfg.LedgerBackendType == ingest.LedgerBackendTypeStreamingLoadtest {
205+
if len(cfg.LoadtestMetaPipePaths) == 0 {
206+
return fmt.Errorf("loadtest-meta-pipe-paths is required when ledger-backend-type is 'streaming-loadtest'")
207+
}
208+
} else if cfg.ArchiveURL == "" {
209+
return fmt.Errorf("archive-url is required when ledger-backend-type is '%s'", ledgerBackendType)
180210
}
181211

182212
appTracker, err := sentry.NewSentryTracker(sentryDSN, stellarEnvironment, 5)

cmd/protocol_migrate.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,10 @@ func buildMigrationCommand(
132132
}
133133
case string(ingest.LedgerBackendTypeDatastore):
134134
// datastore-bucket-path is validated via Required:true in DatastoreOptions.
135+
case string(ingest.LedgerBackendTypeStreamingLoadtest):
136+
// Migrations replay arbitrary historical ranges; the streaming backend only
137+
// ever yields the synthetic ledgers currently being written to its pipes.
138+
return fmt.Errorf("--ledger-backend-type %q is not supported for protocol migration", opts.ledgerBackendType)
135139
default:
136140
return fmt.Errorf("invalid --ledger-backend-type %q, must be 'rpc' or 'datastore'", opts.ledgerBackendType)
137141
}

cmd/utils/custom_set_value.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,27 @@ func SetConfigOptionAssets(co *config.ConfigOption) error {
108108
return nil
109109
}
110110

111+
// SetConfigOptionStringList parses a comma-separated list from the CLI flag or environment
112+
// variable and stores the result in a *[]string ConfigKey. Surrounding whitespace is trimmed
113+
// from each element and empty elements are dropped, so "a, b," yields ["a", "b"]. An empty
114+
// input yields an empty slice.
115+
func SetConfigOptionStringList(co *config.ConfigOption) error {
116+
key, ok := co.ConfigKey.(*[]string)
117+
if !ok {
118+
return unexpectedTypeError(key, co)
119+
}
120+
121+
values := []string{}
122+
for _, value := range strings.Split(viper.GetString(co.Name), ",") {
123+
if trimmed := strings.TrimSpace(value); trimmed != "" {
124+
values = append(values, trimmed)
125+
}
126+
}
127+
*key = values
128+
129+
return nil
130+
}
131+
111132
// SetConfigOptionDuration parses a Go duration string (e.g. "5m", "10s") from the CLI flag
112133
// or environment variable and stores the result in a *time.Duration ConfigKey.
113134
func SetConfigOptionDuration(co *config.ConfigOption) error {

cmd/utils/custom_set_value_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,46 @@ func TestSetConfigOptionAssets(t *testing.T) {
252252
}
253253
}
254254

255+
func TestSetConfigOptionStringList(t *testing.T) {
256+
opts := struct{ paths []string }{}
257+
258+
co := config.ConfigOption{
259+
Name: "loadtest-meta-pipe-paths",
260+
OptType: types.String,
261+
CustomSetValue: SetConfigOptionStringList,
262+
ConfigKey: &opts.paths,
263+
}
264+
265+
testCases := []customSetterTestCase[[]string]{
266+
{
267+
name: "yields an empty slice if the value is empty",
268+
wantResult: []string{},
269+
},
270+
{
271+
name: "handles a single value through the CLI flag",
272+
args: []string{"--loadtest-meta-pipe-paths", "/tmp/a.pipe"},
273+
wantResult: []string{"/tmp/a.pipe"},
274+
},
275+
{
276+
name: "trims whitespace and drops empty elements",
277+
args: []string{"--loadtest-meta-pipe-paths", " /tmp/a.pipe , ,/tmp/b.pipe,"},
278+
wantResult: []string{"/tmp/a.pipe", "/tmp/b.pipe"},
279+
},
280+
{
281+
name: "handles a list through the ENV var",
282+
envValue: "/tmp/a.pipe,/tmp/b.pipe",
283+
wantResult: []string{"/tmp/a.pipe", "/tmp/b.pipe"},
284+
},
285+
}
286+
287+
for _, tc := range testCases {
288+
t.Run(tc.name, func(t *testing.T) {
289+
opts.paths = nil
290+
customSetterTester(t, tc, co)
291+
})
292+
}
293+
}
294+
255295
func TestSetConfigOptionDuration(t *testing.T) {
256296
opts := struct{ d time.Duration }{}
257297

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ require (
157157
github.com/subosito/gotenv v1.6.0 // indirect
158158
github.com/tklauser/go-sysconf v0.3.12 // indirect
159159
github.com/tklauser/numcpus v0.6.1 // indirect
160+
github.com/xdrpp/goxdr v0.1.1 // indirect
160161
github.com/yusufpapurcu/wmi v1.2.4 // indirect
161162
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
162163
go.opentelemetry.io/contrib/detectors/gcp v1.38.0 // indirect
@@ -173,6 +174,7 @@ require (
173174
go.yaml.in/yaml/v2 v2.4.3 // indirect
174175
golang.org/x/crypto v0.52.0 // indirect
175176
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
177+
golang.org/x/mod v0.35.0 // indirect
176178
golang.org/x/net v0.55.0 // indirect
177179
golang.org/x/oauth2 v0.32.0 // indirect
178180
golang.org/x/sys v0.45.0 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,8 @@ golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2
465465
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
466466
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
467467
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
468+
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
469+
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
468470
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
469471
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
470472
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=

internal/ingest/ingest.go

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ const (
4545
LedgerBackendTypeRPC LedgerBackendType = "rpc"
4646
// LedgerBackendTypeDatastore uses cloud storage (S3/GCS) to fetch ledgers
4747
LedgerBackendTypeDatastore LedgerBackendType = "datastore"
48+
// LedgerBackendTypeStreamingLoadtest reads synthetic ledgers from named
49+
// pipes written by stellar-core apply-load. Dev-only, for load testing
50+
// the standard ingestion path.
51+
LedgerBackendTypeStreamingLoadtest LedgerBackendType = "streaming-loadtest"
4852
)
4953

5054
type Configs struct {
@@ -67,6 +71,12 @@ type Configs struct {
6771
LedgerBackendType LedgerBackendType
6872
// Datastore holds the datastore ledger backend configuration (flag/env driven).
6973
Datastore DatastoreConfig
74+
// LoadtestMetaPipePaths are the FIFO paths for the streaming-loadtest
75+
// backend, one per apply-load process (their frames are merged per ledger).
76+
LoadtestMetaPipePaths []string
77+
// LoadtestLedgerCloseDuration is the minimum interval between ledgers in
78+
// streaming-loadtest mode. 0 = uncapped.
79+
LoadtestLedgerCloseDuration time.Duration
7080
// BackfillWorkers limits concurrent batch processing during backfill.
7181
// Defaults to runtime.NumCPU(). Lower values reduce RAM usage.
7282
BackfillWorkers int
@@ -244,16 +254,24 @@ func setupDeps(ctx context.Context, cfg Configs) (services.IngestService, func()
244254
MetricsService: m,
245255
}
246256

247-
// Initialize history archive once for use by both TokenIngestionService and IngestService
248-
archive, err := historyarchive.Connect(
249-
cfg.ArchiveURL,
250-
historyarchive.ArchiveOptions{
251-
NetworkPassphrase: cfg.NetworkPassphrase,
252-
CheckpointFrequency: uint32(cfg.CheckpointFrequency),
253-
},
254-
)
255-
if err != nil {
256-
return nil, nil, fmt.Errorf("connecting to history archive: %w", err)
257+
// Initialize history archive once for use by both TokenIngestionService and IngestService.
258+
// The streaming-loadtest backend runs without one: apply-load's benchmark mode
259+
// publishes no history archive, so ingestion starts from an empty database and
260+
// balance state materializes from the ledger stream itself. archive must stay a
261+
// nil interface (not a typed-nil *Archive) — downstream code branches on == nil.
262+
var archive historyarchive.ArchiveInterface
263+
if cfg.LedgerBackendType != LedgerBackendTypeStreamingLoadtest {
264+
connectedArchive, err := historyarchive.Connect(
265+
cfg.ArchiveURL,
266+
historyarchive.ArchiveOptions{
267+
NetworkPassphrase: cfg.NetworkPassphrase,
268+
CheckpointFrequency: uint32(cfg.CheckpointFrequency),
269+
},
270+
)
271+
if err != nil {
272+
return nil, nil, fmt.Errorf("connecting to history archive: %w", err)
273+
}
274+
archive = connectedArchive
257275
}
258276

259277
tokenIngestionService := services.NewTokenIngestionService(services.TokenIngestionServiceConfig{

internal/ingest/ledger_backend.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ func NewLedgerBackend(ctx context.Context, cfg Configs) (ledgerbackend.LedgerBac
1616
return newDatastoreLedgerBackend(ctx, cfg.Datastore, cfg.NetworkPassphrase)
1717
case LedgerBackendTypeRPC:
1818
return newRPCLedgerBackend(cfg)
19+
case LedgerBackendTypeStreamingLoadtest:
20+
return NewStreamingLoadtestLedgerBackend(StreamingLoadtestBackendConfig{
21+
MetaPipePaths: cfg.LoadtestMetaPipePaths,
22+
LedgerCloseDuration: cfg.LoadtestLedgerCloseDuration,
23+
})
1924
default:
2025
return nil, fmt.Errorf("unsupported ledger backend type: %s", cfg.LedgerBackendType)
2126
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package ingest
2+
3+
import (
4+
"context"
5+
"os"
6+
"strings"
7+
"testing"
8+
"time"
9+
10+
"github.com/stellar/go-stellar-sdk/ingest/ledgerbackend"
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
14+
"github.com/stellar/wallet-backend/internal/indexer"
15+
)
16+
17+
// TestStreamingLoadtestBackendRealCorpus replays real `stellar-core apply-load`
18+
// meta through the backend and the production transaction reader. It proves,
19+
// against real core output rather than hand-built fixtures, that renumbered
20+
// and merged ledgers still parse through the exact code path live ingestion
21+
// uses (transaction-set-to-result pairing included).
22+
//
23+
// Opt-in: set STREAMING_LOADTEST_CORPUS to a comma-separated list of meta.xdr
24+
// files (regular files work; EOF exercises the reopen path by replaying the
25+
// file as a new stream epoch). Generate them by running
26+
// `stellar-core apply-load` (BUILD_TESTS image) with METADATA_OUTPUT_STREAM
27+
// pointed at a file, one run per transaction profile.
28+
func TestStreamingLoadtestBackendRealCorpus(t *testing.T) {
29+
corpus := os.Getenv("STREAMING_LOADTEST_CORPUS")
30+
if corpus == "" {
31+
t.Skip("set STREAMING_LOADTEST_CORPUS=<meta.xdr>[,<meta.xdr>...] to run")
32+
}
33+
paths := strings.Split(corpus, ",")
34+
35+
// apply-load hard-overrides its network passphrase to this value.
36+
const passphrase = "Apply Load"
37+
// Enough ledgers to cross at least one EOF/reopen boundary per file with
38+
// the reference smoke corpora (50 benchmark ledgers plus setup each).
39+
const ledgersToRead = 200
40+
41+
backend, err := NewStreamingLoadtestLedgerBackend(StreamingLoadtestBackendConfig{
42+
MetaPipePaths: paths,
43+
})
44+
require.NoError(t, err)
45+
46+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
47+
defer cancel()
48+
require.NoError(t, backend.PrepareRange(ctx, ledgerbackend.UnboundedRange(1)))
49+
defer func() {
50+
cancel()
51+
require.NoError(t, backend.Close())
52+
}()
53+
54+
var lastCloseTime int64
55+
totalTxs := 0
56+
for seq := uint32(1); seq <= ledgersToRead; seq++ {
57+
lcm, err := backend.GetLedger(ctx, seq)
58+
require.NoError(t, err, "ledger %d", seq)
59+
60+
require.Equal(t, seq, lcm.LedgerSequence())
61+
ct := lcm.LedgerCloseTime()
62+
require.Positive(t, ct, "ledger %d close time", seq)
63+
require.GreaterOrEqual(t, ct, lastCloseTime, "ledger %d close time regressed", seq)
64+
lastCloseTime = ct
65+
66+
// The production read path: this is what live ingestion runs on every
67+
// ledger, so a merged ledger it cannot parse would fail here first.
68+
txs, err := indexer.GetLedgerTransactions(ctx, passphrase, lcm)
69+
require.NoError(t, err, "reading transactions of ledger %d", seq)
70+
totalTxs += len(txs)
71+
}
72+
73+
assert.Positive(t, totalTxs)
74+
t.Logf("read %d ledgers, %d transactions total from %d file(s)", ledgersToRead, totalTxs, len(paths))
75+
}

0 commit comments

Comments
 (0)