merged from gramsrv upstream
This commit is contained in:
parent
79c64ee916
commit
21a0856587
651 changed files with 54774 additions and 4590 deletions
|
|
@ -38,6 +38,16 @@ func run(ctx context.Context, args []string) error {
|
|||
return runKeygen(args[1:])
|
||||
case "provision":
|
||||
return runProvision(ctx, args[1:])
|
||||
case "plan-dataset":
|
||||
return runPlanDataset(args[1:])
|
||||
case "seed":
|
||||
return runSeed(ctx, args[1:])
|
||||
case "snapshot":
|
||||
return runSnapshot(ctx, args[1:])
|
||||
case "mutate-offline":
|
||||
return runMutateOffline(ctx, args[1:])
|
||||
case "startup-run":
|
||||
return runStartup(ctx, args[1:])
|
||||
case "run":
|
||||
return runLoad(ctx, args[1:])
|
||||
case "summarize":
|
||||
|
|
@ -50,6 +60,231 @@ func run(ctx context.Context, args []string) error {
|
|||
}
|
||||
}
|
||||
|
||||
func runPlanDataset(args []string) error {
|
||||
flags := flag.NewFlagSet("plan-dataset", flag.ContinueOnError)
|
||||
out := flags.String("out", filepath.FromSlash("data/loadtest/dataset.json"), "owner-only immutable dataset plan")
|
||||
accounts := flags.Int("accounts", 1000, "logical primary accounts in the provisioned manifest")
|
||||
seed := flags.Int64("seed", 20260827, "deterministic topology and idempotency seed")
|
||||
privateFanout := flags.Int("private-fanout", -1, "outgoing private messages per account; -1 uses min(10, accounts-1)")
|
||||
hotGroups := flags.Int("hot-groups", 10, "hot supergroup count")
|
||||
hotMembers := flags.Int("hot-members", 0, "members per hot supergroup; 0 uses all accounts")
|
||||
hotHistory := flags.Int("hot-history", 100, "messages per hot supergroup")
|
||||
mediumGroups := flags.Int("medium-groups", 100, "medium supergroup count")
|
||||
mediumMembers := flags.Int("medium-members", 100, "members per medium supergroup")
|
||||
mediumHistory := flags.Int("medium-history", 30, "messages per medium supergroup")
|
||||
smallGroups := flags.Int("small-groups", 200, "small supergroup count")
|
||||
smallMembers := flags.Int("small-members", 20, "members per small supergroup")
|
||||
smallHistory := flags.Int("small-history", 10, "messages per small supergroup")
|
||||
heavyGroups := flags.Int("heavy-groups", 200, "heavy-user supergroup count")
|
||||
heavyAccounts := flags.Int("heavy-accounts", 100, "accounts included in every heavy supergroup")
|
||||
heavyHistory := flags.Int("heavy-history", 30, "messages per heavy supergroup")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if flags.NArg() != 0 {
|
||||
return errors.New("plan-dataset accepts no positional arguments")
|
||||
}
|
||||
if *hotMembers == 0 {
|
||||
*hotMembers = *accounts
|
||||
}
|
||||
if *privateFanout == -1 {
|
||||
*privateFanout = min(10, max(*accounts-1, 0))
|
||||
}
|
||||
cfg := loadharness.DatasetConfig{
|
||||
Accounts: *accounts, Seed: *seed, PrivateFanout: *privateFanout,
|
||||
HotGroups: *hotGroups, HotMembers: *hotMembers, HotHistory: *hotHistory,
|
||||
MediumGroups: *mediumGroups, MediumMembers: min(*mediumMembers, *accounts), MediumHistory: *mediumHistory,
|
||||
SmallGroups: *smallGroups, SmallMembers: min(*smallMembers, *accounts), SmallHistory: *smallHistory,
|
||||
HeavyGroups: *heavyGroups, HeavyAccounts: min(*heavyAccounts, *accounts), HeavyHistory: *heavyHistory,
|
||||
}
|
||||
if _, err := os.Stat(*out); err == nil {
|
||||
existing, loadErr := loadharness.LoadDataset(*out)
|
||||
if loadErr != nil {
|
||||
return loadErr
|
||||
}
|
||||
if existing.Config != cfg {
|
||||
return fmt.Errorf("refusing to replace existing dataset plan %s with different config", *out)
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "dataset plan already exists at %s hash=%s groups=%d private_messages=%d\n",
|
||||
*out, existing.PlanSHA256, len(existing.Groups), len(existing.PrivateEdges))
|
||||
return nil
|
||||
} else if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
dataset, err := loadharness.PlanDataset(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := loadharness.WriteDataset(*out, dataset); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "dataset plan written to %s hash=%s groups=%d private_messages=%d\n",
|
||||
*out, dataset.PlanSHA256, len(dataset.Groups), len(dataset.PrivateEdges))
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSeed(ctx context.Context, args []string) error {
|
||||
flags := flag.NewFlagSet("seed", flag.ContinueOnError)
|
||||
manifest := flags.String("manifest", filepath.FromSlash("data/loadtest/manifest.json"), "provisioned manifest")
|
||||
sessionKey := flags.String("session-key", filepath.FromSlash("data/loadtest/session.key"), "session encryption key")
|
||||
rsaOverride := flags.String("rsa-key", "", "optional RSA public/private PEM override")
|
||||
dataset := flags.String("dataset", filepath.FromSlash("data/loadtest/dataset.json"), "immutable dataset plan")
|
||||
state := flags.String("state", filepath.FromSlash("data/loadtest/dataset-seed-state.json"), "resumable seed journal")
|
||||
concurrency := flags.Int("concurrency", 8, "parallel account workers (max 64)")
|
||||
operationTimeout := flags.Duration("operation-timeout", 30*time.Second, "maximum duration of one seed RPC")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if flags.NArg() != 0 {
|
||||
return errors.New("seed accepts no positional arguments")
|
||||
}
|
||||
result, err := loadharness.Seed(ctx, loadharness.SeedConfig{
|
||||
ManifestPath: *manifest, SessionKeyPath: *sessionKey, RSAKeyOverride: *rsaOverride,
|
||||
DatasetPath: *dataset, SeedStatePath: *state, Concurrency: *concurrency, OperationTimeout: *operationTimeout,
|
||||
}, func(event loadharness.SeedEvent) {
|
||||
status := "ok"
|
||||
if event.Err != nil {
|
||||
status = "error"
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "seed phase=%s %d/%d account=%d status=%s\n",
|
||||
event.Phase, event.Completed, event.Total, event.Account, status)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "seed complete private_messages=%d supergroups=%d invited_members=%d group_messages=%d rich_state_accounts=%d state=%s\n",
|
||||
result.PrivateMessages, result.Groups, result.InvitedMembers, result.GroupMessages, result.RichStateAccounts, *state)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSnapshot(ctx context.Context, args []string) error {
|
||||
flags := flag.NewFlagSet("snapshot", flag.ContinueOnError)
|
||||
manifest := flags.String("manifest", filepath.FromSlash("data/loadtest/manifest.json"), "provisioned manifest")
|
||||
sessionKey := flags.String("session-key", filepath.FromSlash("data/loadtest/session.key"), "session encryption key")
|
||||
rsaOverride := flags.String("rsa-key", "", "optional RSA public/private PEM override")
|
||||
dataset := flags.String("dataset", filepath.FromSlash("data/loadtest/dataset.json"), "immutable dataset plan")
|
||||
seedState := flags.String("seed-state", filepath.FromSlash("data/loadtest/dataset-seed-state.json"), "completed seed journal")
|
||||
clientState := flags.String("client-state", filepath.FromSlash("data/loadtest/client-state.json"), "baseline account/dialog/PTS snapshot")
|
||||
concurrency := flags.Int("concurrency", 8, "parallel account workers (max 64)")
|
||||
operationTimeout := flags.Duration("operation-timeout", 30*time.Second, "maximum duration of one snapshot RPC")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if flags.NArg() != 0 {
|
||||
return errors.New("snapshot accepts no positional arguments")
|
||||
}
|
||||
result, err := loadharness.SnapshotClientState(ctx, loadharness.SnapshotConfig{
|
||||
ManifestPath: *manifest, SessionKeyPath: *sessionKey, RSAKeyOverride: *rsaOverride,
|
||||
DatasetPath: *dataset, SeedStatePath: *seedState, ClientStatePath: *clientState,
|
||||
Concurrency: *concurrency, OperationTimeout: *operationTimeout,
|
||||
}, func(event loadharness.SnapshotEvent) {
|
||||
status := "ok"
|
||||
if event.Resumed {
|
||||
status = "resumed"
|
||||
}
|
||||
if event.Err != nil {
|
||||
status = "error"
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "snapshot %d/%d account=%d status=%s\n", event.Completed, event.Total, event.Account, status)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "snapshot complete accounts=%d dialogs=%d channel_dialogs=%d client_state=%s\n",
|
||||
result.Accounts, result.Dialogs, result.Channels, *clientState)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runMutateOffline(ctx context.Context, args []string) error {
|
||||
flags := flag.NewFlagSet("mutate-offline", flag.ContinueOnError)
|
||||
manifest := flags.String("manifest", filepath.FromSlash("data/loadtest/manifest.json"), "provisioned manifest")
|
||||
sessionKey := flags.String("session-key", filepath.FromSlash("data/loadtest/session.key"), "session encryption key")
|
||||
rsaOverride := flags.String("rsa-key", "", "optional RSA public/private PEM override")
|
||||
dataset := flags.String("dataset", filepath.FromSlash("data/loadtest/dataset.json"), "immutable dataset plan")
|
||||
seedState := flags.String("seed-state", filepath.FromSlash("data/loadtest/dataset-seed-state.json"), "completed seed journal")
|
||||
clientState := flags.String("client-state", filepath.FromSlash("data/loadtest/client-state.json"), "immutable old account/channel PTS snapshot")
|
||||
mutationState := flags.String("mutation-state", filepath.FromSlash("data/loadtest/offline-mutation-state.json"), "resumable offline mutation journal")
|
||||
concurrency := flags.Int("concurrency", 8, "parallel writer accounts (max 64)")
|
||||
operationTimeout := flags.Duration("operation-timeout", 30*time.Second, "maximum duration of one mutation RPC")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if flags.NArg() != 0 {
|
||||
return errors.New("mutate-offline accepts no positional arguments")
|
||||
}
|
||||
result, err := loadharness.MutateOffline(ctx, loadharness.MutateOfflineConfig{
|
||||
ManifestPath: *manifest, SessionKeyPath: *sessionKey, RSAKeyOverride: *rsaOverride,
|
||||
DatasetPath: *dataset, SeedStatePath: *seedState, ClientStatePath: *clientState,
|
||||
MutationStatePath: *mutationState, Concurrency: *concurrency, OperationTimeout: *operationTimeout,
|
||||
}, func(event loadharness.MutationEvent) {
|
||||
status := "ok"
|
||||
if event.Err != nil {
|
||||
status = "error"
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "mutate phase=%s %d/%d account=%d status=%s\n",
|
||||
event.Phase, event.Completed, event.Total, event.Account, status)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "offline mutation complete private_messages=%d dirty_channels=%d channel_messages=%d edited=%d deleted=%d pinned=%d state=%s\n",
|
||||
result.PrivateMessages, result.DirtyChannels, result.ChannelMessages, result.Edited, result.Deleted, result.Pinned, *mutationState)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runStartup(ctx context.Context, args []string) error {
|
||||
flags := flag.NewFlagSet("startup-run", flag.ContinueOnError)
|
||||
manifest := flags.String("manifest", filepath.FromSlash("data/loadtest/manifest.json"), "provisioned manifest")
|
||||
sessionKey := flags.String("session-key", filepath.FromSlash("data/loadtest/session.key"), "session encryption key")
|
||||
rsaOverride := flags.String("rsa-key", "", "optional RSA public/private PEM override")
|
||||
dataset := flags.String("dataset", filepath.FromSlash("data/loadtest/dataset.json"), "immutable dataset plan")
|
||||
seedState := flags.String("seed-state", filepath.FromSlash("data/loadtest/dataset-seed-state.json"), "completed seed journal")
|
||||
clientState := flags.String("client-state", filepath.FromSlash("data/loadtest/client-state.json"), "immutable old account/channel PTS snapshot")
|
||||
mutationState := flags.String("mutation-state", filepath.FromSlash("data/loadtest/offline-mutation-state.json"), "completed offline mutation journal")
|
||||
report := flags.String("report", filepath.FromSlash("data/loadtest/startup-report.json"), "startup correctness and latency report")
|
||||
events := flags.String("events", filepath.FromSlash("data/loadtest/startup-events.ndjson"), "periodic owner-only startup and server metric evidence")
|
||||
serverMetrics := flags.String("server-metrics", "http://127.0.0.1:6060/metrics", "server metrics URL; empty disables")
|
||||
profile := flags.String("profile", loadharness.StartupProfileTDesktopReturningV1, "startup workload: tdesktop-cold-returning-v1 or tdlib-returning-v1")
|
||||
startOrder := flags.String("start-order", loadharness.StartupOrderShuffled, "account launch order: shuffled or account-index")
|
||||
startOrderSeed := flags.Int64("start-order-seed", 0, "deterministic shuffled launch seed; 0 uses the dataset seed")
|
||||
accounts := flags.Int("accounts", 0, "limit first N accounts; 0 uses the complete dataset")
|
||||
ramp := flags.Duration("ramp", 30*time.Second, "connection start ramp duration")
|
||||
operationTimeout := flags.Duration("operation-timeout", 30*time.Second, "maximum duration of one startup RPC")
|
||||
sampleInterval := flags.Duration("sample-interval", 2*time.Second, "server resource sampling interval")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if flags.NArg() != 0 {
|
||||
return errors.New("startup-run accepts no positional arguments")
|
||||
}
|
||||
result, err := loadharness.StartupRun(ctx, loadharness.StartupRunConfig{
|
||||
ManifestPath: *manifest, SessionKeyPath: *sessionKey, RSAKeyOverride: *rsaOverride,
|
||||
DatasetPath: *dataset, SeedStatePath: *seedState, ClientStatePath: *clientState,
|
||||
MutationStatePath: *mutationState, ReportPath: *report, EventsPath: *events, ServerMetricsURL: *serverMetrics,
|
||||
Profile: *profile, StartOrder: *startOrder, StartOrderSeed: *startOrderSeed,
|
||||
AccountLimit: *accounts, RampDuration: *ramp, OperationTimeout: *operationTimeout,
|
||||
SampleInterval: *sampleInterval,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printStartupSummary(result)
|
||||
if !result.Pass {
|
||||
return fmt.Errorf("startup acceptance failed; see %s", *report)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func printStartupSummary(report *loadharness.StartupRunReport) {
|
||||
fmt.Fprintf(os.Stdout, "pass=%v business_ready=%d/%d dialogs=%d channel_dialogs=%d account_diff_calls=%d channel_diff_calls=%d channel_full=%d channel_too_long=%d channel_empty=%d\n",
|
||||
report.Pass, report.BusinessReady, report.ExpectedAccounts, report.DialogsObserved, report.ChannelDialogs,
|
||||
report.AccountDifference.Calls, report.ChannelDifference.Calls, report.ChannelDifference.Full,
|
||||
report.ChannelDifference.TooLong, report.ChannelDifference.Empty)
|
||||
for _, failure := range report.Failures {
|
||||
fmt.Fprintln(os.Stdout, "failure:", failure)
|
||||
}
|
||||
}
|
||||
|
||||
func runKeygen(args []string) error {
|
||||
flags := flag.NewFlagSet("keygen", flag.ContinueOnError)
|
||||
path := flags.String("out", filepath.FromSlash("data/loadtest/session.key"), "owner-only session encryption key file")
|
||||
|
|
@ -78,7 +313,7 @@ func runProvision(ctx context.Context, args []string) error {
|
|||
accounts := flags.Int("accounts", 450, "unique accounts")
|
||||
extraDevices := flags.Int("extra-devices", 50, "accounts receiving a second independent session")
|
||||
concurrency := flags.Int("concurrency", 8, "parallel provisioning workers (max 64)")
|
||||
phonePrefix := flags.String("phone-prefix", "+155500", "E.164 prefix followed by a six-digit account index")
|
||||
phonePrefix := flags.String("phone-prefix", loadharness.DefaultPhonePrefix, "possible reserved NANP prefix followed by a six-digit account index")
|
||||
firstName := flags.String("first-name-prefix", "Load", "generated first-name prefix")
|
||||
obfuscated := flags.Bool("obfuscated", true, "use TDesktop-like Obfuscated2 + abridged transport")
|
||||
pfs := flags.Bool("pfs", true, "bind temporary auth keys using PFS")
|
||||
|
|
@ -129,12 +364,17 @@ func runLoad(ctx context.Context, args []string) error {
|
|||
events := flags.String("events", filepath.FromSlash("data/loadtest/events.ndjson"), "periodic NDJSON evidence")
|
||||
fileFixture := flags.String("file-fixture", "", "reusable fixture JSON; empty stores beside manifest")
|
||||
serverMetrics := flags.String("server-metrics", "http://127.0.0.1:6060/metrics", "server metrics URL; empty disables")
|
||||
startOrder := flags.String("start-order", loadharness.StartupOrderShuffled, "session launch order: shuffled or account-index")
|
||||
startOrderSeed := flags.Int64("start-order-seed", 20260827, "deterministic shuffled launch seed")
|
||||
sessions := flags.Int("sessions", 0, "limit selected sessions; 0 uses all")
|
||||
duration := flags.Duration("duration", 30*time.Minute, "sustained load duration")
|
||||
recovery := flags.Duration("recovery", 7*time.Minute, "post-disconnect reclamation observation")
|
||||
ramp := flags.Duration("ramp", 2*time.Minute, "connection ramp duration")
|
||||
rpcInterval := flags.Duration("rpc-interval", 5*time.Second, "per-session background RPC interval")
|
||||
messageInterval := flags.Duration("message-interval", 30*time.Second, "per-primary-session message interval; negative disables")
|
||||
messageRate := flags.Float64("message-rate", 0, "aggregate fixed arrival rate in messages/second; use with message-interval=-1")
|
||||
messageQueue := flags.Int("message-queue", 8, "bounded pending sends per primary session for fixed-rate workload")
|
||||
deliverySettle := flags.Duration("delivery-settle", 10*time.Second, "maximum live-delivery settle time before final updates.getDifference reconciliation")
|
||||
fileInterval := flags.Duration("file-interval", time.Minute, "per-session upload.getFile interval")
|
||||
fileSize := flags.Int("file-size", 4<<20, "generated shared download fixture bytes; 0 disables")
|
||||
fileChunk := flags.Int("file-chunk", 1<<20, "upload.getFile bytes per request (max 1MiB)")
|
||||
|
|
@ -155,8 +395,10 @@ func runLoad(ctx context.Context, args []string) error {
|
|||
result, err := loadharness.Run(ctx, loadharness.RunConfig{
|
||||
ManifestPath: *manifest, SessionKeyPath: *sessionKey, RSAKeyOverride: *rsaOverride,
|
||||
ReportPath: *report, EventsPath: *events, FileFixturePath: *fileFixture, ServerMetricsURL: *serverMetrics,
|
||||
StartOrder: *startOrder, StartOrderSeed: *startOrderSeed,
|
||||
SessionLimit: *sessions, Duration: *duration, RecoveryDuration: *recovery, RampDuration: *ramp,
|
||||
RPCInterval: *rpcInterval, MessageInterval: *messageInterval, SampleInterval: *sampleInterval,
|
||||
RPCInterval: *rpcInterval, MessageInterval: *messageInterval, MessageRate: *messageRate,
|
||||
MessageQueueDepth: *messageQueue, DeliverySettle: *deliverySettle, SampleInterval: *sampleInterval,
|
||||
FileInterval: *fileInterval, FileSizeBytes: *fileSize, FileChunkBytes: *fileChunk, SetupTimeout: *setupTimeout,
|
||||
OperationTimeout: *operationTimeout,
|
||||
OfflineFraction: *offlineFraction, OfflineAt: *offlineAt, OfflineFor: *offlineFor,
|
||||
|
|
@ -183,6 +425,25 @@ func runSummarize(args []string) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var shape struct {
|
||||
BusinessReady *int `json:"business_ready"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &shape); err != nil {
|
||||
return err
|
||||
}
|
||||
if shape.BusinessReady != nil {
|
||||
var report loadharness.StartupRunReport
|
||||
decoder := json.NewDecoder(strings.NewReader(string(data)))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&report); err != nil {
|
||||
return err
|
||||
}
|
||||
printStartupSummary(&report)
|
||||
if !report.Pass {
|
||||
return errors.New("startup report did not pass")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var report loadharness.RunReport
|
||||
decoder := json.NewDecoder(strings.NewReader(string(data)))
|
||||
decoder.DisallowUnknownFields()
|
||||
|
|
@ -197,9 +458,9 @@ func runSummarize(args []string) error {
|
|||
}
|
||||
|
||||
func printSummary(report *loadharness.RunReport) {
|
||||
fmt.Fprintf(os.Stdout, "pass=%v sessions=%d peak_ready=%d reconnects=%d disconnects=%d flood_waits=%d fatal_errors=%d\n",
|
||||
fmt.Fprintf(os.Stdout, "pass=%v sessions=%d peak_ready=%d reconnects=%d disconnects=%d flood_waits=%d fatal_errors=%d scheduled=%d delivered=%d missing=%d\n",
|
||||
report.Pass, report.ExpectedSessions, report.PeakReadySessions, report.Reconnects, report.Disconnects,
|
||||
totalFloodWaits(report), report.WorkerFatalErrors)
|
||||
totalFloodWaits(report), report.WorkerFatalErrors, report.MessageScheduled, report.Delivery.Delivered, report.Delivery.Missing)
|
||||
for _, failure := range report.Failures {
|
||||
fmt.Fprintln(os.Stdout, "failure:", failure)
|
||||
}
|
||||
|
|
@ -214,12 +475,17 @@ func totalFloodWaits(report *loadharness.RunReport) uint64 {
|
|||
}
|
||||
|
||||
func usageError() error {
|
||||
return errors.New("expected one of: keygen, provision, run, summarize, help")
|
||||
return errors.New("expected one of: keygen, provision, plan-dataset, seed, snapshot, mutate-offline, startup-run, run, summarize, help")
|
||||
}
|
||||
|
||||
const usageText = `telesrv-load commands:
|
||||
keygen generate an owner-only AES-256 session key
|
||||
provision create accounts and encrypted sessions through real MTProto auth
|
||||
plan-dataset create an immutable real-data topology with stable RPC identities
|
||||
seed materialize private dialogs, supergroups and messages via real RPCs
|
||||
snapshot save paginated real dialogs and old account/channel PTS cursors
|
||||
mutate-offline create account/channel gaps while preserving the old cursors
|
||||
startup-run restore old cursors and measure dialogs/difference business readiness
|
||||
run execute sustained real-client load, offline recovery and reclamation
|
||||
summarize print the acceptance summary from a JSON report
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue