merged from gramsrv upstream

This commit is contained in:
onysd 2026-09-01 12:06:31 +03:00
parent 79c64ee916
commit 21a0856587
651 changed files with 54774 additions and 4590 deletions

View file

@ -64,7 +64,7 @@ func run() error {
}
defer pool.Close()
hs := hoststats.NewPoller(cfg.BlobDir)
hs := hoststats.NewPoller(cfg.DiskStatsPath)
go hs.Run(ctx, hostStatsPollInterval)
srv, err := newServer(cfg, newReadStore(pool), hs)
@ -97,10 +97,10 @@ type uiConfig struct {
Password string
Token string
SessionKey []byte
// BlobDir is the local blob-storage root, reused only to pick which
// filesystem the dashboard's disk-free reading statfs's -- irrelevant when
// TELESRV_BLOB_BACKEND=s3, where disk space isn't the storage constraint.
BlobDir string
// DiskStatsPath points the dashboard host-disk sampler at the local path
// that matters for the selected blob backend: permanent localfs storage or
// the S3 upload spool.
DiskStatsPath string
// Permissions is the right set a panel session is issued with, from
// TELESRV_ADMIN_UI_PERMISSIONS. The shipped default is the single wildcard
// entry, so introducing the permission model never locks an operator out of a
@ -163,14 +163,21 @@ func loadConfig() (uiConfig, error) {
Password: appCfg.AdminUIPassword,
Token: appCfg.AdminUIToken,
SessionKey: sum[:],
DiskStatsPath: dashboardDiskPath(appCfg),
Permissions: appCfg.AdminUIPermissions,
HideThirdPartyVerification: appCfg.HideThirdPartyVerification,
BlobDir: appCfg.BlobDir,
IdentityDir: appCfg.IdentityDir,
RepoRoot: repoRoot,
}, nil
}
func dashboardDiskPath(cfg config.Config) string {
if strings.EqualFold(strings.TrimSpace(cfg.BlobBackendKind), "s3") && strings.TrimSpace(cfg.BlobStagingDir) != "" {
return cfg.BlobStagingDir
}
return cfg.BlobDir
}
func adminAPIURL(addr string) string {
addr = strings.TrimSpace(addr)
if addr == "" {

View file

@ -36,6 +36,8 @@ import (
// TELESRV_ADMIN_UI_PERMISSIONS and the ones the admin API enforces.
const (
permissionAll = "*"
permissionPremiumManage = "premium.manage"
permissionBotTokenRead = "bots.token.read"
permissionVerificationReview = "verification.review"
permissionVerificationRevoke = "verification.revoke"
// Third-party bot verification. Deliberately not implied by the official

View file

@ -110,7 +110,7 @@ func (s *server) routes() http.Handler {
mux.Handle("POST /api/actions/create-bot", s.requireAuthAPI(http.HandlerFunc(s.handleCreateBotAPI)))
mux.Handle("POST /api/actions/create-broadcast", s.requireAuthAPI(http.HandlerFunc(s.handleCreateBroadcastAPI)))
mux.Handle("POST /api/actions/delete-bot", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteBotAPI)))
mux.Handle("POST /api/actions/export-bot-token", s.requireAuthAPI(http.HandlerFunc(s.handleExportBotTokenAPI)))
mux.Handle("POST /api/actions/export-bot-token", s.requireAuthAPI(s.requirePermission(permissionBotTokenRead, http.HandlerFunc(s.handleExportBotTokenAPI))))
mux.Handle("POST /api/actions/set-channel-verified", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelVerifiedAPI)))
mux.Handle("POST /api/actions/revoke-sessions", s.requireAuthAPI(http.HandlerFunc(s.handleRevokeSessionsAPI)))
mux.Handle("POST /api/actions/delete-messages", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteMessagesAPI)))

View file

@ -144,6 +144,53 @@ func TestModerationReadAPIDisablesBrowserCaching(t *testing.T) {
}
}
func TestAuthorizationRowJSONPreservesInt64AsDecimalStrings(t *testing.T) {
const maxInt64 = int64(9223372036854775807)
raw, err := json.Marshal(AuthorizationRow{AuthKeyID: maxInt64, Hash: maxInt64})
if err != nil {
t.Fatalf("marshal authorization row: %v", err)
}
var got map[string]any
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("unmarshal authorization row: %v", err)
}
for _, field := range []string{"AuthKeyID", "Hash"} {
if got[field] != "9223372036854775807" {
t.Fatalf("authorization %s = %#v, want exact decimal string", field, got[field])
}
}
}
func TestRevokeSessionsBFFForwardsExactAuthorizationHash(t *testing.T) {
const authorizationHash = int64(2361577175213625973)
var got admin.RevokeSessionsRequest
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/accounts/revoke-sessions" || r.Header.Get("Authorization") != "Bearer secret" {
t.Fatalf("upstream request path=%q authorization=%q", r.URL.Path, r.Header.Get("Authorization"))
}
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
t.Fatal(err)
}
_ = json.NewEncoder(w).Encode(admin.CommandResult{CommandID: got.CommandID, Status: "completed", DryRun: got.DryRun})
}))
defer upstream.Close()
srv := &server{cfg: uiConfig{AdminAPIURL: upstream.URL, AdminAPIToken: "secret"}}
req := httptest.NewRequest(http.MethodPost, "/api/actions/revoke-sessions", strings.NewReader(`{
"reason":"precision regression","confirm":false,"user_id":1001,
"hash":"2361577175213625973"
}`))
req = req.WithContext(context.WithValue(req.Context(), actorKey{}, "operator"))
rec := httptest.NewRecorder()
srv.handleRevokeSessionsAPI(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if got.Hash != authorizationHash || got.Actor != "operator" || !got.DryRun {
t.Fatalf("forwarded revoke request = %+v", got)
}
}
func TestMintCollectibleUsernameBFFForwardsActorAndTolerantScalars(t *testing.T) {
const maxInt64 = int64(9223372036854775807)
var got admin.MintCollectibleUsernameRequest

View file

@ -23,8 +23,8 @@
})();
</script>
<script type="module" crossorigin src="/assets/index-D8u51wND.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-hA2EpjuH.css">
<script type="module" crossorigin src="/assets/index-CwTwvGWj.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-0MvM-hpw.css">
</head>
<body>
<div id="root"></div>

View file

@ -758,9 +758,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"dev": true,
"funding": [
{

View file

@ -193,3 +193,20 @@ export function parseIDs(value: string, invalidMessage = "msg ids invalid"): num
}
return ids;
}
// toUnixSeconds reads a datetime-local input. Such an input carries no zone, so
// the value parses as the operator's local time — which is the time they picked.
// 0 means "empty or unparseable", which every caller treats as "not scheduled".
export function toUnixSeconds(value: string): number {
if (!value.trim()) return 0;
const ms = new Date(value).getTime();
return Number.isFinite(ms) ? Math.floor(ms / 1000) : 0;
}
// localInputValue formats a datetime-local default some seconds out, so a
// scheduling form never opens on a value the server would reject as past.
export function localInputValue(offsetSeconds: number): string {
const at = new Date(Date.now() + offsetSeconds * 1000);
const pad = (n: number) => String(n).padStart(2, "0");
return `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())}T${pad(at.getHours())}:${pad(at.getMinutes())}`;
}

View file

@ -166,7 +166,7 @@ export function ownerLabel(row: CollectibleUsernameRow, vaultLabel: string): str
export function priceLabel(row: CollectibleUsernameRow): string {
const base = formatCurrency(row.Amount, row.Currency);
if (row.CryptoCurrency && row.CryptoAmount && row.CryptoAmount !== "0") {
return `${base} (${formatCurrency(row.CryptoAmount, row.CryptoCurrency)})`;
return `${formatCurrency(row.CryptoAmount, row.CryptoCurrency)} (${base})`;
}
return base;
}

View file

@ -5,6 +5,8 @@ import { Alert, PageFrame } from "./components/ui";
// (cmd/telesrv-admin/security.go). "*" is the wildcard an operator configures for
// a full-access session.
export const permissionAll = "*";
export const permissionPremiumManage = "premium.manage";
export const permissionBotTokenRead = "bots.token.read";
export const permissionVerificationReview = "verification.review";
export const permissionVerificationRevoke = "verification.revoke";
// Third-party verification is a separate mechanism and therefore a separate pair of

View file

@ -425,3 +425,34 @@
transform: rotate(360deg);
}
}
.secret-reveal {
display: grid;
gap: 6px;
padding: 10px;
background: var(--warn-tint);
border: 1px solid var(--warn-border);
border-radius: var(--radius);
}
.secret-reveal-label {
display: flex;
align-items: center;
gap: 6px;
color: var(--warn);
font-size: 12px;
font-weight: 800;
}
.secret-reveal-row { display: flex; align-items: center; gap: 10px; }
.secret-reveal-value {
overflow: hidden;
flex: 1 1 auto;
padding: 6px 10px;
color: var(--text-soft);
letter-spacing: .12em;
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
text-overflow: ellipsis;
white-space: nowrap;
}

View file

@ -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

109
cmd/telesrv-update/main.go Normal file
View file

@ -0,0 +1,109 @@
// Command telesrv-update serves native Telegram client update metadata and
// immutable, range-enabled desktop update packages.
package main
import (
"context"
"errors"
"flag"
"fmt"
"net"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"go.uber.org/zap"
"telesrv/internal/updatecdn"
)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "telesrv-update:", err)
os.Exit(1)
}
}
func run() error {
listenDefault := envOr("TELESRV_UPDATE_LISTEN", "127.0.0.1:2402")
manifestDefault := envOr("TELESRV_UPDATE_MANIFEST", "data/updates/manifest.json")
filesDefault := envOr("TELESRV_UPDATE_FILES_DIR", "data/updates/files")
listenAddr := flag.String("listen", listenDefault, "HTTP listen address")
manifestPath := flag.String("manifest", manifestDefault, "release manifest path")
filesDir := flag.String("files", filesDefault, "desktop update package directory")
check := flag.Bool("check", false, "validate the catalog and exit")
flag.Parse()
store, err := updatecdn.NewStore(*manifestPath, *filesDir)
if err != nil {
return fmt.Errorf("load update catalog: %w", err)
}
if *check {
fmt.Println("update catalog is valid")
return nil
}
handler, err := updatecdn.NewHandler(store)
if err != nil {
return err
}
listener, err := net.Listen("tcp", *listenAddr)
if err != nil {
return fmt.Errorf("listen on %s: %w", *listenAddr, err)
}
logger, err := zap.NewProduction()
if err != nil {
_ = listener.Close()
return fmt.Errorf("initialize logger: %w", err)
}
defer logger.Sync() //nolint:errcheck
server := &http.Server{
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 5 * time.Minute,
IdleTimeout: 2 * time.Minute,
MaxHeaderBytes: 32 << 10,
}
serveErr := make(chan error, 1)
go func() {
serveErr <- server.Serve(listener)
}()
logger.Info("update service started",
zap.String("listen", listener.Addr().String()),
zap.String("manifest", *manifestPath),
zap.String("files", *filesDir))
stopCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
select {
case err := <-serveErr:
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return fmt.Errorf("serve HTTP: %w", err)
case <-stopCtx.Done():
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("shutdown HTTP server: %w", err)
}
if err := <-serveErr; err != nil && !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("serve HTTP: %w", err)
}
logger.Info("update service stopped")
return nil
}
func envOr(key, fallback string) string {
if value, ok := os.LookupEnv(key); ok && value != "" {
return value
}
return fallback
}

View file

@ -10,6 +10,7 @@ import (
"os"
"os/signal"
"runtime"
runtimemetrics "runtime/metrics"
"strconv"
"strings"
"syscall"
@ -60,6 +61,7 @@ import (
"telesrv/internal/app/userprojection"
"telesrv/internal/app/users"
verificationapp "telesrv/internal/app/verification"
welcomemessagesapp "telesrv/internal/app/welcomemessages"
"telesrv/internal/botapi"
"telesrv/internal/config"
"telesrv/internal/domain"
@ -79,6 +81,7 @@ import (
"telesrv/internal/store/redisstore"
"telesrv/internal/telegramloginhttp"
"telesrv/internal/turnsrv"
"telesrv/internal/updatecdn"
"telesrv/internal/web"
)
@ -273,8 +276,9 @@ func startDebugServer(ctx context.Context, addr string, metricsHandler http.Hand
func goRuntimeGaugeSamples() []obsmetrics.GaugeSample {
var mem runtime.MemStats
runtime.ReadMemStats(&mem)
return []obsmetrics.GaugeSample{
samples := []obsmetrics.GaugeSample{
{Name: "telesrv_go_goroutines", Value: float64(runtime.NumGoroutine())},
{Name: "telesrv_go_scheduler_busy_seconds", Value: goSchedulerBusySeconds()},
{Name: "telesrv_go_heap_alloc_bytes", Value: float64(mem.HeapAlloc)},
{Name: "telesrv_go_heap_inuse_bytes", Value: float64(mem.HeapInuse)},
{Name: "telesrv_go_heap_objects", Value: float64(mem.HeapObjects)},
@ -283,6 +287,28 @@ func goRuntimeGaugeSamples() []obsmetrics.GaugeSample {
{Name: "telesrv_go_gc_cycles", Value: float64(mem.NumGC)},
{Name: "telesrv_go_gc_pause_seconds", Value: time.Duration(mem.PauseTotalNs).Seconds()},
}
if value, ok := processCPUSeconds(); ok {
samples = append(samples, obsmetrics.GaugeSample{Name: "telesrv_process_cpu_seconds", Value: value})
}
return samples
}
// goSchedulerBusySeconds is a Go scheduler-class estimate. The runtime
// documentation explicitly warns that CPU-class values are overestimates and
// are not comparable to operating-system process CPU time, so capacity reports
// use telesrv_process_cpu_seconds instead.
func goSchedulerBusySeconds() float64 {
samples := []runtimemetrics.Sample{
{Name: "/cpu/classes/total:cpu-seconds"},
{Name: "/cpu/classes/idle:cpu-seconds"},
}
runtimemetrics.Read(samples)
total := samples[0].Value.Float64()
idle := samples[1].Value.Float64()
if total <= idle {
return 0
}
return total - idle
}
func mtprotoRuntimeGaugeSamples(snapshot mtprotoedge.RuntimeSnapshot) []obsmetrics.GaugeSample {
@ -303,6 +329,15 @@ func mtprotoRuntimeGaugeSamples(snapshot mtprotoedge.RuntimeSnapshot) []obsmetri
{Name: "telesrv_mtproto_inbound_rpc_ready_connections", Value: float64(snapshot.InboundRPCReadyConnections)},
{Name: "telesrv_mtproto_inbound_rpc_task_limit", Value: float64(snapshot.InboundRPCMaxTasks)},
{Name: "telesrv_mtproto_inbound_rpc_byte_limit", Value: float64(snapshot.InboundRPCMaxBytes)},
{Name: "telesrv_mtproto_rpc_delivery_hook_workers", Value: float64(snapshot.RPCDeliveryHookWorkers)},
{Name: "telesrv_mtproto_rpc_delivery_hook_capacity", Value: float64(snapshot.RPCDeliveryHookCapacity)},
{Name: "telesrv_mtproto_rpc_delivery_hook_reserved", Value: float64(snapshot.RPCDeliveryHookReserved)},
{Name: "telesrv_mtproto_rpc_delivery_hook_queued", Value: float64(snapshot.RPCDeliveryHookQueued)},
{Name: "telesrv_mtproto_rpc_delivery_hook_running", Value: float64(snapshot.RPCDeliveryHookRunning)},
{Name: "telesrv_mtproto_rpc_delivery_hook_completed_total", Value: float64(snapshot.RPCDeliveryHookCompleted)},
{Name: "telesrv_mtproto_rpc_delivery_hook_rejected_total", Value: float64(snapshot.RPCDeliveryHookRejected)},
{Name: "telesrv_mtproto_rpc_delivery_hook_panics_total", Value: float64(snapshot.RPCDeliveryHookPanics)},
{Name: "telesrv_mtproto_rpc_delivery_hook_duration_seconds_total", Value: snapshot.RPCDeliveryHookDurationSeconds},
{Name: "telesrv_mtproto_inbound_frame_bytes", Value: float64(snapshot.InboundFrameBytes)},
{Name: "telesrv_mtproto_inbound_frame_byte_limit", Value: float64(snapshot.InboundFrameMaxBytes)},
{Name: "telesrv_mtproto_outbound_tracked_bytes", Labels: []obsmetrics.Label{{Name: "kind", Value: "body"}}, Value: float64(snapshot.OutboundTrackedBytes)},
@ -445,15 +480,20 @@ type rpcProjectionVerificationNotifier struct {
invalidator interface {
InvalidateRPCProjectionReadModelForUser(userID int64)
InvalidateRPCProjectionReadModelForChannel(channelID int64)
InvalidatePeerIdentityReadModel(domain.Peer)
}
users storepkg.UserCache
log *zap.Logger
users storepkg.UserCache
peerIdentity bool
log *zap.Logger
}
func (n rpcProjectionVerificationNotifier) NotifyPeerVerified(ctx context.Context, peer domain.Peer) error {
if n.invalidator == nil {
return nil
}
if n.peerIdentity {
n.invalidator.InvalidatePeerIdentityReadModel(peer)
}
switch peer.Type {
case domain.PeerTypeUser:
n.invalidator.InvalidateRPCProjectionReadModelForUser(peer.ID)
@ -570,6 +610,15 @@ func run(logger *zap.Logger) error {
zap.Bool("schema_dirty", migrationStatus.Dirty),
zap.Bool("schema_empty", migrationStatus.Empty),
)
blobRuntimeLock, err := postgres.AcquireBlobRuntimeLock(ctx, cfg.PostgresDSN)
if err != nil {
return fmt.Errorf("acquire blob runtime lock: %w", err)
}
defer func() {
if err := blobRuntimeLock.Close(); err != nil {
logger.Error("release blob runtime lock", zap.Error(err))
}
}()
pool, err := postgres.Open(ctx, cfg.PostgresDSN,
postgres.WithMaxConns(cfg.PostgresMaxConns),
postgres.WithMinConns(cfg.PostgresMinConns),
@ -649,7 +698,8 @@ func run(logger *zap.Logger) error {
if cfg.TelegramLoginEnabled {
telegramLoginHTTPHandler, err = telegramloginhttp.NewHandler(telegramloginhttp.Config{
Service: telegramLoginService, Tokens: telegramLoginIDTokens,
Limiter: redisstore.NewRateLimiter(rdb), AppName: cfg.PublicAppName,
BotUsernames: postgres.NewUserStore(pool),
Limiter: redisstore.NewRateLimiter(rdb), AppName: cfg.PublicAppName,
Logger: logger.Named("telegram-login-http"), TrustedProxyCIDRs: cfg.TelegramLoginTrustedProxyCIDRs,
AllowHTTP: cfg.TelegramLoginAllowHTTP,
})
@ -662,51 +712,157 @@ func run(logger *zap.Logger) error {
}
authKeyStore := postgres.NewAuthKeyStore(pool)
authKeyGetBatchStore, err := postgres.NewBatchedAuthKeyStore(
authKeyStore,
postgres.AuthKeyGetBatchConfig{
MaxSize: cfg.AuthKeyGetBatchMax, MaxWait: cfg.AuthKeyGetBatchWait,
QueueSize: cfg.AuthKeyGetBatchQueue, QueryTimeout: cfg.AuthKeyGetBatchTimeout,
},
)
if err != nil {
return err
}
defer authKeyGetBatchStore.Close()
authKeySessionLayerStore, err := postgres.NewBatchedAuthKeySessionLayerStore(
authKeyStore,
postgres.AuthKeySessionLayerBatchConfig{
MaxSize: cfg.LayerAdvanceBatchMax, MaxWait: cfg.LayerAdvanceBatchWait,
QueueSize: cfg.LayerAdvanceBatchQueue, QueryTimeout: cfg.LayerAdvanceBatchTimeout,
},
)
if err != nil {
return err
}
defer authKeySessionLayerStore.Close()
userStore := postgres.NewUserStore(pool)
authzStore := postgres.NewAuthorizationStore(pool)
adminStore := postgres.NewAdminStore(pool)
updateStateStore := postgres.NewUpdateStateStore(pool)
updateEventStore := postgres.NewUpdateEventStore(pool, postgres.WithUpdateEventLogger(logger.Named("store").Named("updates")))
phoneChangeStore := postgres.NewPhoneChangeStore(pool)
readModelVersionStore := storepkg.NewCachedReadModelVersionStore(postgres.NewReadModelVersionStore(pool), 0, 0)
readModelVersionBatchStore, err := storepkg.NewBatchedReadModelVersionStore(
postgres.NewReadModelVersionStore(pool),
storepkg.ReadModelVersionBatchConfig{
MaxKeys: cfg.ReadModelVersionBatchMaxKeys, MaxWait: cfg.ReadModelVersionBatchWait,
QueueSize: cfg.ReadModelVersionBatchQueue, QueryTimeout: cfg.ReadModelVersionBatchTimeout,
},
)
if err != nil {
return err
}
defer readModelVersionBatchStore.Close()
readModelVersionStore := storepkg.NewCachedReadModelVersionStore(
readModelVersionBatchStore,
0,
cfg.ReadModelVersionCacheMaxEntries,
)
dialogListSnapshotCache := redisstore.NewDialogListSnapshotCache(rdb, cfg.DialogListSnapshotRedisTTL)
activeChannelIDsPageCache := redisstore.NewActiveChannelIDsPageCache(rdb, cfg.ActiveChannelIDsRedisTTL)
dispatchOutboxStore := postgres.NewDispatchOutboxStore(pool, postgres.WithLeaseTimeout(cfg.OutboxLeaseTimeout))
bootstrapUpdateStore := postgres.NewBootstrapUpdateJobStore(pool)
bootstrapUpdateStore, err := postgres.NewBatchedBootstrapUpdateJobStore(
postgres.NewBootstrapUpdateJobStore(pool),
postgres.BootstrapReadyBatchConfig{
MaxSize: cfg.BootstrapReadyBatchMax, MaxWait: cfg.BootstrapReadyBatchWait,
QueueSize: cfg.BootstrapReadyBatchQueue, QueryTimeout: cfg.BootstrapReadyBatchTimeout,
Metrics: metricRegistry,
},
)
if err != nil {
return err
}
defer bootstrapUpdateStore.Close()
botAPIUpdateStore := postgres.NewBotAPIUpdateStore(pool)
botCallbackStore := redisstore.NewBotCallbackRegistryStore(rdb)
ephemeralStore := redisstore.NewEphemeralMessageStore(rdb)
ephemeralReportStore := postgres.NewEphemeralReportStore(pool)
welcomeMessageStore := postgres.NewWelcomeMessageStore(pool)
moderationReportStore := postgres.NewModerationReportStore(pool)
authDeliveryReportStore := postgres.NewAuthDeliveryReportStore(pool)
clientTelemetryStore := postgres.NewClientTelemetryStore(pool)
boxIDAllocator := redisstore.NewBoxIDAllocator(rdb, postgres.NewMessageBoxCounterSource(pool))
channelIDAllocator := redisstore.NewChannelIDAllocator(rdb, postgres.NewChannelIDCounterSource(pool))
channelMessageIDAllocator := redisstore.NewChannelMessageIDAllocator(rdb, postgres.NewChannelMessageIDCounterSource(pool))
secretChatIDAllocator := redisstore.NewSecretChatIDAllocator(rdb, postgres.NewSecretChatIDCounterSource(pool))
contactStore := userprojection.NewCachedContactStore(postgres.NewContactStore(pool), 0)
reverseContactStore, err := storepkg.NewBatchedReverseContactStore(
postgres.NewContactStore(pool),
storepkg.ReverseContactBatchConfig{
MaxPairs: cfg.ContactReverseBatchMaxPairs, MaxWait: cfg.ContactReverseBatchWait,
QueueSize: cfg.ContactReverseBatchQueue, QueryTimeout: cfg.ContactReverseBatchTimeout,
},
)
if err != nil {
return err
}
defer reverseContactStore.Close()
contactStore := userprojection.NewCachedContactStoreWithMaxViewers(
reverseContactStore,
0,
cfg.ContactSnapshotCacheMaxViewers,
)
dialogStore := postgres.NewDialogStore(pool)
chatlistStore := postgres.NewChatlistStore(pool)
messageStore := postgres.NewMessageStore(pool,
postgres.WithMessageAllocators(boxIDAllocator),
postgres.WithMessageLogger(logger.Named("store").Named("messages")))
broadcastStore := postgres.NewBroadcastStore(pool)
broadcastService := broadcastapp.NewService(broadcastStore,
broadcastapp.WithMessageSender(messageStore),
broadcastapp.WithLogger(logger.Named("broadcast")))
// 共享频道行/成员缓存 + 统一 read-model LISTEN/NOTIFY 实时失效:消除高频「逐 RPC
// 解析频道/成员」在客户端重连同步突发里重复读同一行的放大。
channelRowCache := postgres.NewChannelRowCache(cfg.ChannelRowCacheMaxEntries)
channelTopMessageCache := postgres.NewChannelTopMessageCache(cfg.ChannelTopMessageCacheMaxEntries)
channelMemberCache := postgres.NewChannelMemberCache(cfg.ChannelMemberCacheMaxEntries)
channelDialogCache := postgres.NewChannelDialogCache(cfg.ChannelDialogCacheMaxEntries)
channelDifferenceCache := postgres.NewChannelDifferenceBaseCache(
cfg.ChannelDifferenceCacheMaxEntries,
cfg.ChannelDifferenceCacheMaxBytes,
cfg.ChannelDifferenceCacheTTL,
)
channelBoostCache := postgres.NewChannelBoostCache(cfg.ChannelBoostCacheMaxEntries, cfg.ChannelBoostCacheTTL)
channelStore := postgres.NewChannelStore(pool,
postgres.WithChannelAllocators(channelIDAllocator, channelMessageIDAllocator),
postgres.WithChannelLogger(logger.Named("store").Named("channels")),
postgres.WithChannelRowCache(channelRowCache),
postgres.WithChannelTopMessageCache(channelTopMessageCache),
postgres.WithChannelMemberCache(channelMemberCache),
postgres.WithChannelDialogCache(channelDialogCache),
postgres.WithChannelDifferenceBaseCache(channelDifferenceCache),
postgres.WithChannelBoostCache(channelBoostCache))
communityStore := postgres.NewCommunityStore(pool, channelIDAllocator, channelMessageIDAllocator)
activeChannelIDsPageBatcher, err := postgres.NewActiveChannelIDsPageBatcher(
channelStore,
postgres.ActiveChannelIDsBatchConfig{
MaxSize: cfg.ActiveChannelIDsBatchMax, MaxWait: cfg.ActiveChannelIDsBatchWait,
QueueSize: cfg.ActiveChannelIDsBatchQueue, QueryTimeout: cfg.ActiveChannelIDsBatchTimeout,
Metrics: metricRegistry,
},
)
if err != nil {
return err
}
defer activeChannelIDsPageBatcher.Close()
metricRegistry.AddGaugeProvider(func() []obsmetrics.GaugeSample {
snapshot := channelDifferenceCache.Snapshot()
return []obsmetrics.GaugeSample{
{Name: "telesrv_channel_difference_cache_entries", Value: float64(snapshot.Entries)},
{Name: "telesrv_channel_difference_cache_weight_bytes", Value: float64(snapshot.Weight)},
{Name: "telesrv_channel_difference_cache_hits", Value: float64(snapshot.Hits)},
{Name: "telesrv_channel_difference_cache_misses", Value: float64(snapshot.Misses)},
{Name: "telesrv_channel_difference_cache_loads", Value: float64(snapshot.Loads)},
{Name: "telesrv_channel_difference_cache_load_errors", Value: float64(snapshot.LoadErrors)},
}
})
communityCatalogCache := postgres.NewCommunityCatalogCache()
communityStore := postgres.NewCommunityStore(pool, channelIDAllocator, channelMessageIDAllocator,
postgres.WithCommunityCatalogCache(communityCatalogCache))
pollStore := postgres.NewPollStore(pool)
mediaStore := postgres.NewMediaStore(pool)
// 头像投影缓存:所有 projector 共用一层短 TTL owner→头像缓存消除高频「返回用户」RPC
// 每次投影对每批 owner 固定 2 次的 CurrentProfilePhotosKind PG 查询。
cachedPhotos := userprojection.NewCachedPhotoProvider(mediaStore, userprojection.DefaultPhotoCacheTTL)
// 头像投影缓存:所有 projector 共用 owner→头像正/负 LRU。profile_photo NOTIFY
// 精确失效负责正常新鲜度,长 TTL 只覆盖漏通知,避免登录 ramp 周期性重查稳定负值。
cachedPhotos := userprojection.NewCachedPhotoProviderWithMaxEntries(
mediaStore,
cfg.ProfilePhotoCacheTTL,
cfg.ProfilePhotoCacheMaxEntries,
)
privacyStore := privacyapp.NewCachedPrivacyStore(postgres.NewPrivacyStore(pool), 0)
storyStore := postgres.NewStoryStore(pool)
// Transient upload-part scratch storage always stays on local disk
@ -902,6 +1058,11 @@ func run(logger *zap.Logger) error {
Commands: adminStore,
Restrictions: adminStore,
})
userProjectionFacts := userprojection.NewDurableUserProjectionFacts(
adminService,
readModelVersionStore,
cfg.UserProjectionFactCacheMaxEntries,
)
storageRetentionMaxAge := cfg.StorageRetentionMaxAge
if !cfg.StorageRetentionEnable {
storageRetentionMaxAge = 0
@ -932,7 +1093,7 @@ func run(logger *zap.Logger) error {
contactsService := contacts.NewService(contactStore, userStore).Configure(
contacts.WithPhotoProvider(cachedPhotos),
contacts.WithPrivacyEvaluator(privacyService),
contacts.WithAccountFreezeProvider(adminService),
contacts.WithAccountFreezeProvider(userProjectionFacts),
contacts.WithReadModelVersions(readModelVersionStore),
contacts.WithHideThirdPartyVerification(cfg.HideThirdPartyVerification),
)
@ -1036,6 +1197,33 @@ func run(logger *zap.Logger) error {
botsapp.WithDialogRateLimiter(rateLimiter, cfg.VerificationBotRateLimit, cfg.VerificationBotRateWindow),
botsapp.WithPublicBaseURL(cfg.PublicBaseURL),
botsapp.WithHideThirdPartyVerification(cfg.HideThirdPartyVerification))
// The built-in ChatBot and StickersBot are seeded with the default product
// name in their bio (users.about) and description (bots.description). Align
// them with the active branding on startup so the seeded "telesrv" text is
// replaced. SetBotInfo writes both fields; the sync is a no-op when the text
// already matches.
for _, botID := range []int64{domain.ChatBotUserID, domain.StickersBotUserID} {
var wantAbout, wantDesc string
switch botID {
case domain.ChatBotUserID:
wantAbout = domain.ChatBotDescription()
wantDesc = wantAbout
case domain.StickersBotUserID:
wantAbout = domain.StickersBotDescription()
wantDesc = wantAbout
}
if _, curAbout, curDesc, err := botsService.GetBotInfo(ctx, botID); err == nil && curAbout == wantAbout && curDesc == wantDesc {
continue
}
if _, err := botsService.SetBotInfo(ctx, botID, domain.BotInfoUpdate{
SetAbout: true,
About: wantAbout,
SetDescription: true,
Description: wantDesc,
}); err != nil {
logger.Warn("sync bot branding", zap.Int64("bot", botID), zap.Error(err))
}
}
groupCallStore := postgres.NewGroupCallStore(pool)
groupCallsService := groupcallsapp.NewService(groupCallStore, groupcallsapp.WithPublicBaseURL(cfg.PublicBaseURL))
// 群通话媒体面:内嵌 pion SFUM1+。SFU 的 liveness reporter 把媒体面存活
@ -1120,7 +1308,7 @@ func run(logger *zap.Logger) error {
// 私聊端对端加密Secret Chat握手状态机 + qts 投递队列(盲中继)。
secretChatStore := postgres.NewSecretChatStore(pool)
encryptedQueueStore := postgres.NewEncryptedQueueStore(pool)
secretChatService := secretchatapp.NewService(secretChatStore, encryptedQueueStore, secretChatIDAllocator)
secretChatService := secretchatapp.NewService(secretChatStore, encryptedQueueStore)
// Passkey:凭据持久化走 postgres;一次性挑战走进程内内存(短 TTL,与 QR 登录 token
// 同属进程内一次性凭据,不跨实例)。
passkeyStore := postgres.NewPasskeyStore(pool)
@ -1129,7 +1317,7 @@ func run(logger *zap.Logger) error {
passkeyapp.WithAllowedOrigins(cfg.PasskeyAllowedOrigins))
// 自定义云主题(Create a New Theme):主题目录与每用户已安装列表均持久化到 postgres。
themeService := themesapp.NewService(postgres.NewThemeStore(pool))
usersService := users.NewService(userStore, users.WithBaseUserCache(userCache), users.WithContactStore(contactStore), users.WithPhotoProvider(cachedPhotos), users.WithPrivacyEvaluator(privacyService), users.WithAccountFreezeProvider(adminService), users.WithHideThirdPartyVerification(cfg.HideThirdPartyVerification), users.WithReservedUsernames(cfg.ReservedUsernames))
usersService := users.NewService(userStore, users.WithBaseUserCache(userCache), users.WithContactStore(contactStore), users.WithPhotoProvider(cachedPhotos), users.WithPrivacyEvaluator(privacyService), users.WithAccountFreezeProvider(userProjectionFacts), users.WithHideThirdPartyVerification(cfg.HideThirdPartyVerification), users.WithReservedUsernames(cfg.ReservedUsernames))
privacyService.ConfigureReadModels(usersService, channelStore)
aiComposeService := aiapp.NewService(aiComposeStore, newAIComposeOptions(cfg, rateLimiter, usersService.PremiumActive, logger)...)
botsService.SetAIChatGenerator(aiComposeService)
@ -1137,9 +1325,21 @@ func run(logger *zap.Logger) error {
dialogs.WithContactStore(contactStore),
dialogs.WithPhotoProvider(cachedPhotos),
dialogs.WithPrivacyEvaluator(privacyService),
dialogs.WithAccountFreezeProvider(adminService),
dialogs.WithAccountFreezeProvider(userProjectionFacts),
dialogs.WithPremiumChecker(usersService.PremiumActive),
dialogs.WithReadModelVersions(readModelVersionStore),
dialogs.WithDialogHydrationCaches(
cfg.DialogPrivatePeerCacheMaxEntries,
cfg.DialogPrivatePeerCacheMaxBytes,
cfg.DialogDraftCacheMaxEntries,
cfg.DialogDraftCacheMaxBytes,
),
dialogs.WithDialogListSnapshotCache(
cfg.DialogListSnapshotCacheMaxEntries,
cfg.DialogListSnapshotCacheMaxHeaders,
cfg.DialogListSnapshotCacheTTL,
),
dialogs.WithSharedDialogListSnapshotCache(dialogListSnapshotCache),
)
// 编译期保证 *users.Service 满足 channel fan-out 跨 viewer 投影预热的可选能力;签名漂移会在
// 这里立刻断编译,而非在运行时静默退化回 O(viewer) 逐 viewer 投影。
@ -1147,11 +1347,19 @@ func run(logger *zap.Logger) error {
channelsService := channelapp.NewService(channelStore,
channelapp.WithBotProfileResolver(botsService),
channelapp.WithReadModelVersions(readModelVersionStore),
channelapp.WithActiveChannelIDsReadModel(
activeChannelIDsPageCache,
activeChannelIDsPageBatcher,
cfg.ActiveChannelIDsCacheMaxEntries,
cfg.ActiveChannelIDsCacheTTL,
metricRegistry,
),
channelapp.WithSendPermissionChecker(adminService),
channelapp.WithReservedUsernames(cfg.ReservedUsernames),
)
communitiesService := communitiesapp.NewService(communityStore)
ephemeralService := ephemeralapp.NewService(ephemeralStore, channelsService, usersService, botsService)
welcomeMessageService := welcomemessagesapp.NewService(welcomeMessageStore, channelsService)
storiesService := storiesapp.NewService(storyStore, storiesapp.WithChannelStoryAccess(channelsService))
chatlistsService := chatlistsapp.NewService(
chatlistStore,
@ -1164,7 +1372,7 @@ func run(logger *zap.Logger) error {
messageapp.WithContactStore(contactStore),
messageapp.WithPhotoProvider(cachedPhotos),
messageapp.WithPrivacyEvaluator(privacyService),
messageapp.WithAccountFreezeProvider(adminService),
messageapp.WithAccountFreezeProvider(userProjectionFacts),
messageapp.WithReadModelVersions(readModelVersionStore),
messageapp.WithBotResponder(botsService),
messageapp.WithSendPermissionChecker(adminService),
@ -1191,7 +1399,7 @@ func run(logger *zap.Logger) error {
dialogStore,
newTranslationOptions(cfg, rateLimiter, logger)...,
)
authService := auth.NewService(userStore, authzStore, codeStore, authKeyStore, tempAuthKeyStore, cfg.DevAuthCode,
authService := auth.NewService(userStore, authzStore, codeStore, authKeyGetBatchStore, tempAuthKeyStore, cfg.DevAuthCode,
auth.WithLoginMessages(messageStore, dialogStore),
auth.WithLoginCodeDelivery(messageStore),
auth.WithPasswords(passwordStore),
@ -1285,6 +1493,14 @@ func run(logger *zap.Logger) error {
logger.Info("default verifier seed complete", zap.Int64("bot_id", domain.VerifierBotUserID))
}
updatesService := updates.NewService(updateStateStore, updateEventStore, updates.WithLogger(logger.Named("app").Named("updates")))
var appUpdateResolver updatecdn.Resolver
if cfg.UpdateServiceURL != "" {
client, err := updatecdn.NewClient(cfg.UpdateServiceURL, cfg.UpdateRequestTimeout)
if err != nil {
return fmt.Errorf("initialize update service client: %w", err)
}
appUpdateResolver = client
}
router := rpc.New(rpc.Config{
DC: cfg.DC,
DefaultCountryCode: cfg.DefaultCountryCode,
@ -1304,17 +1520,29 @@ func run(logger *zap.Logger) error {
GroupCallMaxParticipants: cfg.GroupCallMaxParticipants,
RtmpIngestURL: cfg.LiveStreamRtmpURL,
PublicBaseURL: cfg.PublicBaseURL,
UpdatePublicURL: cfg.UpdatePublicURL,
PublicAppScheme: cfg.PublicAppScheme,
PublicAppLinkBase: cfg.PublicAppLinkBase,
// PFS temp→perm 解析缓存显式撤销会清缓存并断开连接re-bind 即时失效;
// 配置 TTL 只承担跨进程/异常失效兜底,避免大连接数周期性打满 PG。
TempKeyResolveCacheTTL: cfg.TempKeyResolveCacheTTL,
TempKeyResolveCacheMaxEntries: cfg.TempKeyResolveCacheMaxEntries,
TempKeyResolveCacheTTL: cfg.TempKeyResolveCacheTTL,
TempKeyResolveCacheMaxEntries: cfg.TempKeyResolveCacheMaxEntries,
PeerIdentityCacheMaxEntries: cfg.PeerIdentityCacheMaxEntries,
StoryActivePeerCacheMaxEntries: cfg.StoryActivePeerCacheMaxEntries,
StoryHiddenListCacheMaxEntries: cfg.StoryHiddenListCacheMaxEntries,
StoryHiddenListCacheMaxBytes: cfg.StoryHiddenListCacheMaxBytes,
PresenceLastSeenBatchMax: cfg.PresenceLastSeenBatchMax,
PresenceLastSeenBatchWait: cfg.PresenceLastSeenBatchWait,
PresenceLastSeenBatchQueue: cfg.PresenceLastSeenBatchQueue,
PresenceLastSeenBatchTimeout: cfg.PresenceLastSeenBatchTimeout,
PresenceLastSeenDrainTimeout: cfg.PresenceLastSeenDrainTimeout,
}, rpc.Deps{
Auth: authService,
AuthDeliveryReports: authDeliveryReportService,
ClientTelemetry: clientTelemetryService,
AuthKeySessionLayers: authKeyStore,
AuthKeySessionLayers: authKeySessionLayerStore,
ReadModelVersions: readModelVersionStore,
UserProjectionFacts: userProjectionFacts,
Account: accountService,
Privacy: privacyService,
Help: help.NewService(helpStore, helpStore,
@ -1323,73 +1551,77 @@ func run(logger *zap.Logger) error {
help.WithEmailSignupPhonePrefixes(cfg.EmailSignupPhonePrefixes),
help.WithAccountFreezeProvider(adminService),
),
AccountFreeze: adminService,
AICompose: aiComposeService,
Ephemeral: ephemeralService,
EphemeralPush: ephemeralStore,
Moderation: moderationService,
Users: usersService,
Usernames: usernamesService,
BotVerifications: botVerificationService,
TelegramLogin: telegramLoginRPCDependency(telegramLoginService),
Updates: updatesService,
BootstrapUpdates: bootstrapUpdateStore,
BotAPIUpdates: botAPIUpdateStore,
BotCallbacks: botCallbackStore,
Contacts: contactsService,
Dialogs: dialogsService,
Chatlists: chatlistsService,
Messages: messagesService,
Translation: translationService,
Channels: channelsService,
Communities: communitiesService,
Files: filesService,
PremiumPromo: filesService,
Bots: botsService,
ServiceBotCallbacks: botsService,
ServiceBotInlineResults: botsService,
Polls: pollsapp.NewService(pollStore),
Stories: storiesService,
Phone: phoneService,
SecretChats: secretChatService,
Passkey: passkeyService,
Themes: themeService,
GroupCalls: groupCallsService,
LiveStreams: liveStreamDep(liveStreamService),
SFU: sfuService,
TURN: turnService,
LangPack: langPackService,
Sessions: activeSessions,
Metrics: metricRegistry,
Inline: inlineRegistryStore,
Limiter: rateLimiter,
AppUpdates: appUpdateResolver,
AccountFreeze: userProjectionFacts,
AccountFreezeNotifications: adminService,
AICompose: aiComposeService,
Ephemeral: ephemeralService,
EphemeralPush: ephemeralStore,
WelcomeMessages: welcomeMessageService,
Moderation: moderationService,
Users: usersService,
Usernames: usernamesService,
BotVerifications: botVerificationService,
TelegramLogin: telegramLoginRPCDependency(telegramLoginService),
Updates: updatesService,
BootstrapUpdates: bootstrapUpdateStore,
BotAPIUpdates: botAPIUpdateStore,
BotCallbacks: botCallbackStore,
Contacts: contactsService,
Dialogs: dialogsService,
Chatlists: chatlistsService,
Messages: messagesService,
Translation: translationService,
Channels: channelsService,
Communities: communitiesService,
Files: filesService,
PremiumPromo: filesService,
Bots: botsService,
ServiceBotCallbacks: botsService,
ServiceBotInlineResults: botsService,
Polls: pollsapp.NewService(pollStore),
Stories: storiesService,
Phone: phoneService,
SecretChats: secretChatService,
Passkey: passkeyService,
Themes: themeService,
GroupCalls: groupCallsService,
LiveStreams: liveStreamDep(liveStreamService),
SFU: sfuService,
TURN: turnService,
LangPack: langPackService,
Sessions: activeSessions,
Metrics: metricRegistry,
Inline: inlineRegistryStore,
Limiter: rateLimiter,
}, logger.Named("rpc"), clock.System)
readModelListener := postgres.NewReadModelChangeListener(cfg.PostgresDSN, postgres.ReadModelCacheSet{
ReadModelVersions: readModelVersionStore,
ChannelRows: channelRowCache,
ChannelMembers: channelMemberCache,
ChannelDialogs: channelDialogCache,
ChannelBoosts: channelBoostCache,
Contacts: postgres.ContactReadModelCaches{contactStore, contactsService},
Dialogs: dialogsService,
Privacy: privacyService,
ProfilePhotos: cachedPhotos,
Stories: router,
ChannelFullBots: router,
ChannelBotMembers: channelsService,
ChannelMediaCounts: channelsService,
PrivateMediaCounts: messagesService,
RPCProjections: router,
BaseUsers: userCache,
BotProfiles: botsService,
AccountSettings: router,
ReadModelVersions: readModelVersionStore,
ChannelRows: channelRowCache,
ChannelTopMessages: channelTopMessageCache,
CommunityCatalog: communityCatalogCache,
ChannelMembers: channelMemberCache,
ChannelDialogs: channelDialogCache,
ChannelDifferences: channelDifferenceCache,
ChannelBoosts: channelBoostCache,
Contacts: postgres.ContactReadModelCaches{contactStore, contactsService},
Dialogs: dialogsService,
Privacy: privacyService,
ProfilePhotos: cachedPhotos,
Stories: router,
ChannelFullBots: router,
ChannelBotMembers: channelsService,
ChannelMediaCounts: channelsService,
PrivateMediaCounts: messagesService,
RPCProjections: router,
PeerIdentities: router,
BaseUsers: userCache,
BotProfiles: botsService,
AccountSettings: router,
UserProjectionFacts: userProjectionFacts,
}, logger.Named("store").Named("read-model-listener"))
go readModelListener.Run(ctx)
activeSessions.SetLifecycleObserver(router)
broadcastStore := postgres.NewBroadcastStore(pool)
broadcastService := broadcastapp.NewService(broadcastStore,
broadcastapp.WithMessageSender(messageStore),
broadcastapp.WithLogger(logger.Named("broadcast")))
adminService.Configure(adminapp.Dependencies{
Auth: authService,
Revoker: router,
@ -1453,9 +1685,10 @@ func run(logger *zap.Logger) error {
if notifier, ok := any(router).(botverificationapp.PeerNotifier); ok {
botVerificationService.SetPeerNotifier(compositeBotVerificationNotifier{
cache: rpcProjectionVerificationNotifier{
invalidator: router,
users: userCache,
log: verificationLogger,
invalidator: router,
users: userCache,
peerIdentity: true,
log: verificationLogger,
},
edge: notifier,
})
@ -1474,7 +1707,9 @@ func run(logger *zap.Logger) error {
// not wait on however long sending to all of them takes.
go broadcastapp.NewWorker(broadcastService, logger.Named("broadcast").Named("delivery"),
cfg.BroadcastWorkerInterval, cfg.BroadcastWorkerBatch).Run(ctx)
moderationActionOptions := []moderationapp.ActionExecutorOption{}
moderationActionOptions := []moderationapp.ActionExecutorOption{
moderationapp.WithAccountDeletionNotifier(router),
}
if cfg.PublicLinkWebAddr != "" {
moderationActionOptions = append(
moderationActionOptions,
@ -1503,6 +1738,7 @@ func run(logger *zap.Logger) error {
rpc.WithOutboxUpdateBuilder(router.BuildOutboxUpdates),
).Run(ctx)
go rpc.NewBootstrapUpdateDispatcher(router, logger.Named("rpc").Named("bootstrap")).Run(ctx)
go rpc.NewWelcomeDeliveryDispatcher(router, welcomeMessageStore, logger.Named("rpc").Named("welcome-delivery")).Run(ctx)
go rpc.NewScheduledDispatcher(router, logger.Named("rpc").Named("scheduled")).Run(ctx)
go rpc.NewSuggestedPostDispatcher(router, logger.Named("rpc").Named("suggested-post")).Run(ctx)
go rpc.NewExpiryDispatcher(router, logger.Named("rpc").Named("expiry")).Run(ctx)
@ -1510,6 +1746,7 @@ func run(logger *zap.Logger) error {
go rpc.NewGroupCallSweepDispatcher(router, logger.Named("rpc").Named("groupcall-sweep"), cfg.GroupCallSweepInterval, cfg.GroupCallCheckTTL).Run(ctx)
go router.RunChannelFanout(ctx)
go router.RunBotAPIEnqueue(ctx)
go router.RunPresenceLastSeenBatch(ctx)
go router.RunPresenceSweeper(ctx, time.Minute)
go activeSessions.RunPendingSweeper(ctx, time.Minute)
go router.RunPremiumSweeper(ctx, cfg.PremiumSweepInterval, cfg.PremiumSweepBatch)
@ -1567,7 +1804,7 @@ func run(logger *zap.Logger) error {
RSAKey: rsaKey,
IdentityDir: cfg.IdentityDir,
LayerRPC: router,
AuthKeys: authKeyStore,
AuthKeys: authKeyGetBatchStore,
ActiveSessions: activeSessions,
Metrics: metricRegistry,
ObfuscatedTCP: true,
@ -1582,6 +1819,8 @@ func run(logger *zap.Logger) error {
RPCGlobalWorkers: cfg.MTProtoRPCGlobalWorkers,
RPCGlobalMaxTasks: cfg.MTProtoRPCGlobalMaxTasks,
RPCGlobalMaxBytes: cfg.MTProtoRPCGlobalMaxBytes,
RPCDeliveryHookWorkers: cfg.MTProtoRPCDeliveryHookWorkers,
RPCDeliveryHookMaxPending: cfg.MTProtoRPCDeliveryHookMaxPending,
RPCExecutionMaxEntries: cfg.MTProtoRPCExecutionMaxEntries,
RPCExecutionAuthMaxEntries: cfg.MTProtoRPCExecutionAuthMaxEntries,
RPCExecutionSessionMaxEntries: cfg.MTProtoRPCExecutionSessionMaxEntries,

View file

@ -0,0 +1,7 @@
//go:build !aix && !darwin && !dragonfly && !freebsd && !linux && !netbsd && !openbsd && !solaris && !windows
package main
func processCPUSeconds() (float64, bool) {
return 0, false
}

View file

@ -0,0 +1,12 @@
//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris || windows
package main
import "testing"
func TestProcessCPUSecondsAvailable(t *testing.T) {
seconds, ok := processCPUSeconds()
if !ok || seconds < 0 {
t.Fatalf("process CPU seconds = %v, available=%v", seconds, ok)
}
}

View file

@ -0,0 +1,17 @@
//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris
package main
import "golang.org/x/sys/unix"
func processCPUSeconds() (float64, bool) {
var usage unix.Rusage
if err := unix.Getrusage(unix.RUSAGE_SELF, &usage); err != nil {
return 0, false
}
ns := unix.TimevalToNsec(usage.Utime) + unix.TimevalToNsec(usage.Stime)
if ns < 0 {
return 0, false
}
return float64(ns) / 1e9, true
}

View file

@ -0,0 +1,21 @@
//go:build windows
package main
import "golang.org/x/sys/windows"
func processCPUSeconds() (float64, bool) {
handle, err := windows.GetCurrentProcess()
if err != nil {
return 0, false
}
var creation, exit, kernel, user windows.Filetime
if err := windows.GetProcessTimes(handle, &creation, &exit, &kernel, &user); err != nil {
return 0, false
}
ns := kernel.Nanoseconds() + user.Nanoseconds()
if ns < 0 {
return 0, false
}
return float64(ns) / 1e9, true
}