778 lines
26 KiB
Go
778 lines
26 KiB
Go
package loadharness
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/iamxvbaba/td/tg"
|
|
"github.com/iamxvbaba/td/tgerr"
|
|
)
|
|
|
|
const OfflineMutationVersion = 1
|
|
|
|
type OfflineMutationChannelPlan struct {
|
|
GroupPosition int
|
|
Messages int
|
|
}
|
|
|
|
type OfflineMutationChannelState struct {
|
|
GroupIndex int `json:"group_index"`
|
|
MessageIDs []int `json:"message_ids"`
|
|
LatestPts int `json:"latest_pts,omitempty"`
|
|
EditPending bool `json:"edit_pending,omitempty"`
|
|
EditDone bool `json:"edit_done,omitempty"`
|
|
DeletePending bool `json:"delete_pending,omitempty"`
|
|
DeleteDone bool `json:"delete_done,omitempty"`
|
|
PinPending bool `json:"pin_pending,omitempty"`
|
|
PinDone bool `json:"pin_done,omitempty"`
|
|
}
|
|
|
|
type OfflineMutationState struct {
|
|
Version int `json:"version"`
|
|
DatasetSHA256 string `json:"dataset_sha256"`
|
|
SeedIdentitySHA string `json:"seed_identity_sha256"`
|
|
BaselineStateSHA string `json:"baseline_state_sha256"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
PrivateMessageIDs []int `json:"private_message_ids"`
|
|
AccountObservedPts []int `json:"account_observed_pts"`
|
|
Channels []OfflineMutationChannelState `json:"channels"`
|
|
}
|
|
|
|
type MutateOfflineConfig struct {
|
|
ManifestPath string
|
|
SessionKeyPath string
|
|
RSAKeyOverride string
|
|
DatasetPath string
|
|
SeedStatePath string
|
|
ClientStatePath string
|
|
MutationStatePath string
|
|
Concurrency int
|
|
OperationTimeout time.Duration
|
|
}
|
|
|
|
type MutationEvent struct {
|
|
Phase string
|
|
Completed int
|
|
Total int
|
|
Account int
|
|
Err error
|
|
}
|
|
|
|
type MutationResult struct {
|
|
PrivateMessages int
|
|
ChannelMessages int
|
|
DirtyChannels int
|
|
Edited int
|
|
Deleted int
|
|
Pinned int
|
|
}
|
|
|
|
func (c MutateOfflineConfig) validate() error {
|
|
if c.ManifestPath == "" || c.SessionKeyPath == "" || c.DatasetPath == "" || c.SeedStatePath == "" || c.ClientStatePath == "" || c.MutationStatePath == "" {
|
|
return errors.New("manifest, session-key, dataset, seed-state, client-state and mutation-state paths are required")
|
|
}
|
|
if c.Concurrency <= 0 || c.Concurrency > 64 {
|
|
return errors.New("offline mutation concurrency must be between 1 and 64")
|
|
}
|
|
if c.OperationTimeout <= 0 {
|
|
return errors.New("offline mutation operation timeout must be positive")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MutateOffline creates gaps only after a complete baseline has been locked.
|
|
// Message sends use stable random_id values. Mutable channel operations use a
|
|
// pending journal and public read-back reconciliation before a resumed run can
|
|
// declare them complete.
|
|
func MutateOffline(ctx context.Context, cfg MutateOfflineConfig, progress func(MutationEvent)) (*MutationResult, error) {
|
|
if err := cfg.validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
manifest, err := LoadManifest(cfg.ManifestPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dataset, err := LoadDataset(cfg.DatasetPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
targets, err := seedPrimaryTargets(manifest, dataset.Config.Accounts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
seedState, err := LoadDatasetSeedState(cfg.SeedStatePath, dataset)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
seedJournal := &seedJournal{dataset: dataset, state: seedState}
|
|
if err := seedJournal.assertComplete(); err != nil {
|
|
return nil, fmt.Errorf("offline mutation requires a complete seed: %w", err)
|
|
}
|
|
clientState, err := LoadClientState(cfg.ClientStatePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := clientState.Validate(dataset, seedState, targets); err != nil {
|
|
return nil, err
|
|
}
|
|
baselineSHA, err := fileSHA256(cfg.ClientStatePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
seedIdentity, err := seedIdentitySHA256(seedState)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
plan := planOfflineMutation(dataset)
|
|
state, err := loadOrCreateOfflineMutationState(cfg.MutationStatePath, dataset, seedIdentity, baselineSHA, plan)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
journal := &mutationJournal{path: cfg.MutationStatePath, dataset: dataset, plan: plan, state: state}
|
|
if err := journal.persist(); err != nil {
|
|
return nil, err
|
|
}
|
|
key, err := LoadSessionKey(cfg.SessionKeyPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
publicKey, err := loadManifestPublicKey(cfg.ManifestPath, manifest.Endpoint, cfg.RSAKeyOverride)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
accounts := make([]int, dataset.Config.Accounts)
|
|
for account := range accounts {
|
|
accounts[account] = account
|
|
}
|
|
|
|
if err := runSeedAccountPhase(ctx, "mutate-private", accounts, cfg.Concurrency, mutationProgressAdapter("private", progress), func(ctx context.Context, account int) error {
|
|
if journal.privateMessageID(account) != 0 {
|
|
return nil
|
|
}
|
|
return withAuthorizedSeedSession(ctx, SeedConfig{ManifestPath: cfg.ManifestPath, OperationTimeout: cfg.OperationTimeout}, manifest, targets[account], key, publicKey, func(ctx context.Context, raw *tg.Client) error {
|
|
recipient := (account + 1) % dataset.Config.Accounts
|
|
marker := offlinePrivateMarker(dataset, account, recipient)
|
|
updates, err := rpcWithFloodWaitRetry(ctx, cfg.OperationTimeout, func(rpcCtx context.Context) (tg.UpdatesClass, error) {
|
|
return raw.MessagesSendMessage(rpcCtx, &tg.MessagesSendMessageRequest{
|
|
Peer: &tg.InputPeerUser{UserID: targets[recipient].UserID, AccessHash: targets[recipient].AccessHash},
|
|
Message: marker, RandomID: stableDatasetID(dataset.Config.Seed, "offline-private", dataset.Config.Accounts, account, recipient),
|
|
})
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("messages.sendMessage: %w", err)
|
|
}
|
|
observation, err := sentMessageObservation(updates, clientPeerKey{typ: "user", id: targets[recipient].UserID}, marker)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return journal.commitPrivate(account, observation.ID, observation.Pts)
|
|
})
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
channelTasks := offlineChannelTasks(dataset, plan)
|
|
channelAccounts := make([]int, 0, len(channelTasks))
|
|
for account := range channelTasks {
|
|
channelAccounts = append(channelAccounts, account)
|
|
}
|
|
sort.Ints(channelAccounts)
|
|
if err := runSeedAccountPhase(ctx, "mutate-channel", channelAccounts, cfg.Concurrency, mutationProgressAdapter("channel", progress), func(ctx context.Context, account int) error {
|
|
return withAuthorizedSeedSession(ctx, SeedConfig{ManifestPath: cfg.ManifestPath, OperationTimeout: cfg.OperationTimeout}, manifest, targets[account], key, publicKey, func(ctx context.Context, raw *tg.Client) error {
|
|
for _, task := range channelTasks[account] {
|
|
if journal.channelMessageID(task.PlanPosition, task.MessageIndex) != 0 {
|
|
continue
|
|
}
|
|
channelPlan := plan[task.PlanPosition]
|
|
group := dataset.Groups[channelPlan.GroupPosition]
|
|
channel := seedState.Groups[channelPlan.GroupPosition]
|
|
marker := offlineChannelMarker(dataset, group, task.MessageIndex)
|
|
updates, err := rpcWithFloodWaitRetry(ctx, cfg.OperationTimeout, func(rpcCtx context.Context) (tg.UpdatesClass, error) {
|
|
return raw.MessagesSendMessage(rpcCtx, &tg.MessagesSendMessageRequest{
|
|
Peer: &tg.InputPeerChannel{ChannelID: channel.ChannelID, AccessHash: channel.AccessHash},
|
|
Message: marker, RandomID: stableDatasetID(dataset.Config.Seed, "offline-channel", dataset.Config.Accounts, group.Index, task.MessageIndex),
|
|
})
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("group %d message %d: %w", group.Index, task.MessageIndex, err)
|
|
}
|
|
observation, err := sentMessageObservation(updates, clientPeerKey{typ: "channel", id: channel.ChannelID}, marker)
|
|
if err != nil {
|
|
return fmt.Errorf("group %d message %d: %w", group.Index, task.MessageIndex, err)
|
|
}
|
|
if err := journal.commitChannelMessage(task.PlanPosition, task.MessageIndex, observation.ID, observation.Pts); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// The first dirty channel deliberately exceeds the page limit and owns the
|
|
// edit, delete and pin events. The creator authors the edited message and can
|
|
// administratively delete/pin the two newest messages in one session.
|
|
if len(plan) == 0 || plan[0].Messages < 120 {
|
|
return nil, errors.New("offline mutation plan has no multi-page channel")
|
|
}
|
|
actionGroup := dataset.Groups[plan[0].GroupPosition]
|
|
if err := runSeedAccountPhase(ctx, "mutate-actions", []int{actionGroup.CreatorAccount}, 1, mutationProgressAdapter("actions", progress), func(ctx context.Context, account int) error {
|
|
return withAuthorizedSeedSession(ctx, SeedConfig{ManifestPath: cfg.ManifestPath, OperationTimeout: cfg.OperationTimeout}, manifest, targets[account], key, publicKey, func(ctx context.Context, raw *tg.Client) error {
|
|
return applyOfflineChannelActions(ctx, cfg.OperationTimeout, dataset, seedState, plan, journal, raw)
|
|
})
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := journal.assertComplete(); err != nil {
|
|
return nil, err
|
|
}
|
|
return offlineMutationResult(plan, state), nil
|
|
}
|
|
|
|
func planOfflineMutation(dataset *Dataset) []OfflineMutationChannelPlan {
|
|
plan := make([]OfflineMutationChannelPlan, 0, 40)
|
|
counts := map[string]int{"hot": 10, "medium": 10, "small": 10, "heavy": 10}
|
|
messages := map[string]int{"hot": 3, "medium": 2, "small": 1, "heavy": 3}
|
|
seen := make(map[string]int)
|
|
for position, group := range dataset.Groups {
|
|
if seen[group.Tier] >= counts[group.Tier] {
|
|
continue
|
|
}
|
|
count := messages[group.Tier]
|
|
switch len(plan) {
|
|
case 0:
|
|
count = 120
|
|
case 1:
|
|
// Exactly one full page complements the first channel's >limit
|
|
// channelDifferenceTooLong snapshot path.
|
|
count = 100
|
|
}
|
|
plan = append(plan, OfflineMutationChannelPlan{GroupPosition: position, Messages: count})
|
|
seen[group.Tier]++
|
|
}
|
|
if len(plan) == 0 && len(dataset.Groups) != 0 {
|
|
plan = append(plan, OfflineMutationChannelPlan{GroupPosition: 0, Messages: 120})
|
|
}
|
|
return plan
|
|
}
|
|
|
|
type offlineChannelTask struct {
|
|
PlanPosition int
|
|
MessageIndex int
|
|
}
|
|
|
|
func offlineChannelTasks(dataset *Dataset, plan []OfflineMutationChannelPlan) map[int][]offlineChannelTask {
|
|
tasks := make(map[int][]offlineChannelTask)
|
|
for planPosition, channelPlan := range plan {
|
|
group := dataset.Groups[channelPlan.GroupPosition]
|
|
for message := 0; message < channelPlan.Messages; message++ {
|
|
account := offlineMutationSender(group, message)
|
|
tasks[account] = append(tasks[account], offlineChannelTask{PlanPosition: planPosition, MessageIndex: message})
|
|
}
|
|
}
|
|
return tasks
|
|
}
|
|
|
|
func offlineMutationSender(group DatasetGroup, message int) int {
|
|
if message < 3 {
|
|
return group.CreatorAccount
|
|
}
|
|
return group.MemberAccounts[message%len(group.MemberAccounts)]
|
|
}
|
|
|
|
func offlinePrivateMarker(dataset *Dataset, sender, recipient int) string {
|
|
return fmt.Sprintf("[%s offline private %04d->%04d]", dataset.RunID, sender, recipient)
|
|
}
|
|
|
|
func offlineChannelMarker(dataset *Dataset, group DatasetGroup, message int) string {
|
|
return fmt.Sprintf("[%s offline channel %04d message %04d]", dataset.RunID, group.Index, message+1)
|
|
}
|
|
|
|
type messageObservation struct {
|
|
ID int
|
|
Pts int
|
|
}
|
|
|
|
func sentMessageObservation(updates tg.UpdatesClass, peer clientPeerKey, marker string) (messageObservation, error) {
|
|
switch value := updates.(type) {
|
|
case *tg.UpdateShortSentMessage:
|
|
if value.ID > 0 {
|
|
return messageObservation{ID: value.ID, Pts: value.Pts}, nil
|
|
}
|
|
case *tg.UpdateShortMessage:
|
|
if value.ID > 0 && value.Message == marker {
|
|
return messageObservation{ID: value.ID, Pts: value.Pts}, nil
|
|
}
|
|
case *tg.Updates:
|
|
return sentMessageObservationFromUpdates(value.Updates, peer, marker)
|
|
case *tg.UpdatesCombined:
|
|
return sentMessageObservationFromUpdates(value.Updates, peer, marker)
|
|
}
|
|
return messageObservation{}, fmt.Errorf("messages.sendMessage returned %T without marker", updates)
|
|
}
|
|
|
|
func sentMessageObservationFromUpdates(updates []tg.UpdateClass, peer clientPeerKey, marker string) (messageObservation, error) {
|
|
for _, update := range updates {
|
|
var message tg.MessageClass
|
|
pts := 0
|
|
switch value := update.(type) {
|
|
case *tg.UpdateNewMessage:
|
|
message, pts = value.Message, value.Pts
|
|
case *tg.UpdateNewChannelMessage:
|
|
message, pts = value.Message, value.Pts
|
|
default:
|
|
continue
|
|
}
|
|
full, ok := message.(*tg.Message)
|
|
if !ok || full.Message != marker {
|
|
continue
|
|
}
|
|
messagePeer, ok := clientPeerFromTG(full.PeerID)
|
|
if ok && messagePeer == peer && full.ID > 0 {
|
|
return messageObservation{ID: full.ID, Pts: pts}, nil
|
|
}
|
|
}
|
|
return messageObservation{}, errors.New("messages.sendMessage updates omitted expected marker")
|
|
}
|
|
|
|
func applyOfflineChannelActions(
|
|
ctx context.Context,
|
|
timeout time.Duration,
|
|
dataset *Dataset,
|
|
seedState *DatasetSeedState,
|
|
plan []OfflineMutationChannelPlan,
|
|
journal *mutationJournal,
|
|
raw *tg.Client,
|
|
) error {
|
|
channelPlan := plan[0]
|
|
group := dataset.Groups[channelPlan.GroupPosition]
|
|
channel := seedState.Groups[channelPlan.GroupPosition]
|
|
state := journal.channel(0)
|
|
deleteIndex, pinIndex := channelPlan.Messages-2, channelPlan.Messages-1
|
|
if state.MessageIDs[0] == 0 || state.MessageIDs[deleteIndex] == 0 || state.MessageIDs[pinIndex] == 0 {
|
|
return errors.New("channel action messages are incomplete")
|
|
}
|
|
peer := &tg.InputPeerChannel{ChannelID: channel.ChannelID, AccessHash: channel.AccessHash}
|
|
inputChannel := &tg.InputChannel{ChannelID: channel.ChannelID, AccessHash: channel.AccessHash}
|
|
|
|
if !state.EditDone {
|
|
if !state.EditPending {
|
|
if err := journal.beginAction(0, "edit"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
editedMarker := offlineChannelMarker(dataset, group, 0) + " edited"
|
|
updates, err := rpcWithFloodWaitRetry(ctx, timeout, func(rpcCtx context.Context) (tg.UpdatesClass, error) {
|
|
return raw.MessagesEditMessage(rpcCtx, &tg.MessagesEditMessageRequest{Peer: peer, ID: state.MessageIDs[0], Message: editedMarker})
|
|
})
|
|
pts := maxPtsFromUpdates(updates)
|
|
if err != nil {
|
|
if !tgerr.Is(err, "MESSAGE_NOT_MODIFIED") {
|
|
return fmt.Errorf("messages.editMessage pending reconciliation: %w", err)
|
|
}
|
|
matches, verifyErr := channelMessageMatches(ctx, timeout, raw, inputChannel, state.MessageIDs[0], editedMarker)
|
|
if verifyErr != nil || !matches {
|
|
return fmt.Errorf("reconcile messages.editMessage: matched=%v err=%w", matches, verifyErr)
|
|
}
|
|
}
|
|
if err := journal.commitAction(0, "edit", pts); err != nil {
|
|
return err
|
|
}
|
|
state = journal.channel(0)
|
|
}
|
|
if !state.DeleteDone {
|
|
if !state.DeletePending {
|
|
if err := journal.beginAction(0, "delete"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
affected, err := rpcWithFloodWaitRetry(ctx, timeout, func(rpcCtx context.Context) (*tg.MessagesAffectedMessages, error) {
|
|
return raw.ChannelsDeleteMessages(rpcCtx, &tg.ChannelsDeleteMessagesRequest{Channel: inputChannel, ID: []int{state.MessageIDs[deleteIndex]}})
|
|
})
|
|
pts := 0
|
|
if affected != nil {
|
|
pts = affected.Pts
|
|
}
|
|
if err != nil {
|
|
deleted, verifyErr := channelMessageDeleted(ctx, timeout, raw, inputChannel, state.MessageIDs[deleteIndex])
|
|
if verifyErr != nil || !deleted {
|
|
return fmt.Errorf("reconcile channels.deleteMessages: deleted=%v err=%w (rpc %v)", deleted, verifyErr, err)
|
|
}
|
|
}
|
|
if err := journal.commitAction(0, "delete", pts); err != nil {
|
|
return err
|
|
}
|
|
state = journal.channel(0)
|
|
}
|
|
if !state.PinDone {
|
|
if !state.PinPending {
|
|
if err := journal.beginAction(0, "pin"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
updates, err := rpcWithFloodWaitRetry(ctx, timeout, func(rpcCtx context.Context) (tg.UpdatesClass, error) {
|
|
return raw.MessagesUpdatePinnedMessage(rpcCtx, &tg.MessagesUpdatePinnedMessageRequest{Silent: true, Peer: peer, ID: state.MessageIDs[pinIndex]})
|
|
})
|
|
pts := maxPtsFromUpdates(updates)
|
|
if err != nil {
|
|
pinned, verifyErr := channelMessagePinned(ctx, timeout, raw, inputChannel, state.MessageIDs[pinIndex])
|
|
if verifyErr != nil || !pinned {
|
|
return fmt.Errorf("reconcile messages.updatePinnedMessage: pinned=%v err=%w (rpc %v)", pinned, verifyErr, err)
|
|
}
|
|
}
|
|
if err := journal.commitAction(0, "pin", pts); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func channelMessageMatches(ctx context.Context, timeout time.Duration, raw *tg.Client, channel *tg.InputChannel, messageID int, text string) (bool, error) {
|
|
messages, err := getChannelMessages(ctx, timeout, raw, channel, messageID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
for _, message := range messages {
|
|
if full, ok := message.(*tg.Message); ok && full.ID == messageID {
|
|
return full.Message == text, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
func channelMessageDeleted(ctx context.Context, timeout time.Duration, raw *tg.Client, channel *tg.InputChannel, messageID int) (bool, error) {
|
|
messages, err := getChannelMessages(ctx, timeout, raw, channel, messageID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
for _, message := range messages {
|
|
if full, ok := message.(*tg.Message); ok && full.ID == messageID {
|
|
return false, nil
|
|
}
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func getChannelMessages(ctx context.Context, timeout time.Duration, raw *tg.Client, channel *tg.InputChannel, messageID int) ([]tg.MessageClass, error) {
|
|
rpcCtx, cancel := context.WithTimeout(ctx, timeout)
|
|
response, err := raw.ChannelsGetMessages(rpcCtx, &tg.ChannelsGetMessagesRequest{
|
|
Channel: channel, ID: []tg.InputMessageClass{&tg.InputMessageID{ID: messageID}},
|
|
})
|
|
cancel()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
modified, ok := response.AsModified()
|
|
if !ok {
|
|
return nil, fmt.Errorf("channels.getMessages returned %T", response)
|
|
}
|
|
return modified.GetMessages(), nil
|
|
}
|
|
|
|
func channelMessagePinned(ctx context.Context, timeout time.Duration, raw *tg.Client, channel *tg.InputChannel, messageID int) (bool, error) {
|
|
rpcCtx, cancel := context.WithTimeout(ctx, timeout)
|
|
response, err := raw.ChannelsGetFullChannel(rpcCtx, channel)
|
|
cancel()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
full, ok := response.FullChat.(*tg.ChannelFull)
|
|
if !ok {
|
|
return false, fmt.Errorf("channels.getFullChannel returned %T", response.FullChat)
|
|
}
|
|
pinned, ok := full.GetPinnedMsgID()
|
|
return ok && pinned == messageID, nil
|
|
}
|
|
|
|
func maxPtsFromUpdates(updates tg.UpdatesClass) int {
|
|
if updates == nil {
|
|
return 0
|
|
}
|
|
maxPts := 0
|
|
var classes []tg.UpdateClass
|
|
switch value := updates.(type) {
|
|
case *tg.UpdateShortSentMessage:
|
|
return value.Pts
|
|
case *tg.UpdateShortMessage:
|
|
return value.Pts
|
|
case *tg.Updates:
|
|
classes = value.Updates
|
|
case *tg.UpdatesCombined:
|
|
classes = value.Updates
|
|
}
|
|
for _, class := range classes {
|
|
switch value := class.(type) {
|
|
case *tg.UpdateNewMessage:
|
|
maxPts = max(maxPts, value.Pts)
|
|
case *tg.UpdateNewChannelMessage:
|
|
maxPts = max(maxPts, value.Pts)
|
|
case *tg.UpdateEditChannelMessage:
|
|
maxPts = max(maxPts, value.Pts)
|
|
case *tg.UpdateDeleteChannelMessages:
|
|
maxPts = max(maxPts, value.Pts)
|
|
case *tg.UpdatePinnedChannelMessages:
|
|
maxPts = max(maxPts, value.Pts)
|
|
}
|
|
}
|
|
return maxPts
|
|
}
|
|
|
|
func mutationProgressAdapter(phase string, progress func(MutationEvent)) func(SeedEvent) {
|
|
if progress == nil {
|
|
return nil
|
|
}
|
|
return func(event SeedEvent) {
|
|
progress(MutationEvent{Phase: phase, Completed: event.Completed, Total: event.Total, Account: event.Account, Err: event.Err})
|
|
}
|
|
}
|
|
|
|
func fileSHA256(path string) (string, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
sum := sha256.Sum256(data)
|
|
return fmt.Sprintf("%x", sum[:]), nil
|
|
}
|
|
|
|
func loadOrCreateOfflineMutationState(
|
|
path string,
|
|
dataset *Dataset,
|
|
seedIdentity, baselineSHA string,
|
|
plan []OfflineMutationChannelPlan,
|
|
) (*OfflineMutationState, error) {
|
|
data, err := os.ReadFile(path)
|
|
if os.IsNotExist(err) {
|
|
state := &OfflineMutationState{
|
|
Version: OfflineMutationVersion, DatasetSHA256: dataset.PlanSHA256,
|
|
SeedIdentitySHA: seedIdentity, BaselineStateSHA: baselineSHA,
|
|
PrivateMessageIDs: make([]int, dataset.Config.Accounts), AccountObservedPts: make([]int, dataset.Config.Accounts),
|
|
Channels: make([]OfflineMutationChannelState, len(plan)),
|
|
}
|
|
for i, channelPlan := range plan {
|
|
state.Channels[i] = OfflineMutationChannelState{
|
|
GroupIndex: dataset.Groups[channelPlan.GroupPosition].Index,
|
|
MessageIDs: make([]int, channelPlan.Messages),
|
|
}
|
|
}
|
|
return state, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var state OfflineMutationState
|
|
decoder := json.NewDecoder(strings.NewReader(string(data)))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&state); err != nil {
|
|
return nil, fmt.Errorf("decode offline mutation state: %w", err)
|
|
}
|
|
if err := state.Validate(dataset, seedIdentity, baselineSHA, plan); err != nil {
|
|
return nil, err
|
|
}
|
|
return &state, nil
|
|
}
|
|
|
|
func (s *OfflineMutationState) Validate(dataset *Dataset, seedIdentity, baselineSHA string, plan []OfflineMutationChannelPlan) error {
|
|
if s == nil || s.Version != OfflineMutationVersion || s.DatasetSHA256 != dataset.PlanSHA256 || s.SeedIdentitySHA != seedIdentity || s.BaselineStateSHA != baselineSHA {
|
|
return errors.New("offline mutation state does not match baseline dataset")
|
|
}
|
|
if len(s.PrivateMessageIDs) != dataset.Config.Accounts || len(s.AccountObservedPts) != dataset.Config.Accounts || len(s.Channels) != len(plan) {
|
|
return errors.New("offline mutation state dimensions do not match plan")
|
|
}
|
|
for account := range s.PrivateMessageIDs {
|
|
if s.PrivateMessageIDs[account] < 0 || s.AccountObservedPts[account] < 0 {
|
|
return fmt.Errorf("invalid offline private mutation account %d", account)
|
|
}
|
|
}
|
|
for i, channel := range s.Channels {
|
|
if channel.GroupIndex != dataset.Groups[plan[i].GroupPosition].Index || len(channel.MessageIDs) != plan[i].Messages || channel.LatestPts < 0 {
|
|
return fmt.Errorf("invalid offline mutation channel %d", channel.GroupIndex)
|
|
}
|
|
for _, messageID := range channel.MessageIDs {
|
|
if messageID < 0 {
|
|
return fmt.Errorf("offline mutation channel %d has invalid message id", channel.GroupIndex)
|
|
}
|
|
}
|
|
if (channel.EditDone && channel.EditPending) || (channel.DeleteDone && channel.DeletePending) || (channel.PinDone && channel.PinPending) {
|
|
return fmt.Errorf("offline mutation channel %d has completed pending action", channel.GroupIndex)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type mutationJournal struct {
|
|
mu sync.Mutex
|
|
path string
|
|
dataset *Dataset
|
|
plan []OfflineMutationChannelPlan
|
|
state *OfflineMutationState
|
|
}
|
|
|
|
func (j *mutationJournal) persistLocked() error {
|
|
if err := j.state.Validate(j.dataset, j.state.SeedIdentitySHA, j.state.BaselineStateSHA, j.plan); err != nil {
|
|
return err
|
|
}
|
|
j.state.UpdatedAt = time.Now().UTC()
|
|
data, err := json.MarshalIndent(j.state, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeFileAtomic(j.path, append(data, '\n'), 0o600)
|
|
}
|
|
|
|
func (j *mutationJournal) persist() error {
|
|
j.mu.Lock()
|
|
defer j.mu.Unlock()
|
|
return j.persistLocked()
|
|
}
|
|
|
|
func (j *mutationJournal) privateMessageID(account int) int {
|
|
j.mu.Lock()
|
|
defer j.mu.Unlock()
|
|
return j.state.PrivateMessageIDs[account]
|
|
}
|
|
|
|
func (j *mutationJournal) channelMessageID(planPosition, message int) int {
|
|
j.mu.Lock()
|
|
defer j.mu.Unlock()
|
|
return j.state.Channels[planPosition].MessageIDs[message]
|
|
}
|
|
|
|
func (j *mutationJournal) channel(planPosition int) OfflineMutationChannelState {
|
|
j.mu.Lock()
|
|
defer j.mu.Unlock()
|
|
state := j.state.Channels[planPosition]
|
|
state.MessageIDs = append([]int(nil), state.MessageIDs...)
|
|
return state
|
|
}
|
|
|
|
func (j *mutationJournal) commitPrivate(account, messageID, pts int) error {
|
|
if messageID <= 0 || pts < 0 {
|
|
return errors.New("invalid private mutation observation")
|
|
}
|
|
j.mu.Lock()
|
|
defer j.mu.Unlock()
|
|
oldID, oldPts := j.state.PrivateMessageIDs[account], j.state.AccountObservedPts[account]
|
|
j.state.PrivateMessageIDs[account] = messageID
|
|
j.state.AccountObservedPts[account] = max(oldPts, pts)
|
|
if err := j.persistLocked(); err != nil {
|
|
j.state.PrivateMessageIDs[account], j.state.AccountObservedPts[account] = oldID, oldPts
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (j *mutationJournal) commitChannelMessage(planPosition, message, messageID, pts int) error {
|
|
if messageID <= 0 || pts <= 0 {
|
|
return errors.New("invalid channel mutation observation")
|
|
}
|
|
j.mu.Lock()
|
|
defer j.mu.Unlock()
|
|
channel := &j.state.Channels[planPosition]
|
|
oldID, oldPts := channel.MessageIDs[message], channel.LatestPts
|
|
channel.MessageIDs[message] = messageID
|
|
channel.LatestPts = max(channel.LatestPts, pts)
|
|
if err := j.persistLocked(); err != nil {
|
|
channel.MessageIDs[message], channel.LatestPts = oldID, oldPts
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (j *mutationJournal) beginAction(planPosition int, action string) error {
|
|
j.mu.Lock()
|
|
defer j.mu.Unlock()
|
|
channel := &j.state.Channels[planPosition]
|
|
old := *channel
|
|
switch action {
|
|
case "edit":
|
|
channel.EditPending = true
|
|
case "delete":
|
|
channel.DeletePending = true
|
|
case "pin":
|
|
channel.PinPending = true
|
|
default:
|
|
return fmt.Errorf("unknown mutation action %q", action)
|
|
}
|
|
if err := j.persistLocked(); err != nil {
|
|
*channel = old
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (j *mutationJournal) commitAction(planPosition int, action string, pts int) error {
|
|
j.mu.Lock()
|
|
defer j.mu.Unlock()
|
|
channel := &j.state.Channels[planPosition]
|
|
old := *channel
|
|
switch action {
|
|
case "edit":
|
|
channel.EditPending, channel.EditDone = false, true
|
|
case "delete":
|
|
channel.DeletePending, channel.DeleteDone = false, true
|
|
case "pin":
|
|
channel.PinPending, channel.PinDone = false, true
|
|
default:
|
|
return fmt.Errorf("unknown mutation action %q", action)
|
|
}
|
|
channel.LatestPts = max(channel.LatestPts, pts)
|
|
if err := j.persistLocked(); err != nil {
|
|
*channel = old
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (j *mutationJournal) assertComplete() error {
|
|
j.mu.Lock()
|
|
defer j.mu.Unlock()
|
|
if err := j.state.Validate(j.dataset, j.state.SeedIdentitySHA, j.state.BaselineStateSHA, j.plan); err != nil {
|
|
return err
|
|
}
|
|
for account, messageID := range j.state.PrivateMessageIDs {
|
|
if messageID <= 0 {
|
|
return fmt.Errorf("offline private mutation account %d is incomplete", account)
|
|
}
|
|
}
|
|
for i, channel := range j.state.Channels {
|
|
for message, messageID := range channel.MessageIDs {
|
|
if messageID <= 0 {
|
|
return fmt.Errorf("offline channel %d message %d is incomplete", channel.GroupIndex, message)
|
|
}
|
|
}
|
|
if channel.LatestPts <= 0 {
|
|
return fmt.Errorf("offline channel %d has no observed pts", channel.GroupIndex)
|
|
}
|
|
if i == 0 && (!channel.EditDone || !channel.DeleteDone || !channel.PinDone) {
|
|
return fmt.Errorf("offline channel %d actions are incomplete", channel.GroupIndex)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func offlineMutationResult(plan []OfflineMutationChannelPlan, state *OfflineMutationState) *MutationResult {
|
|
result := &MutationResult{PrivateMessages: len(state.PrivateMessageIDs), DirtyChannels: len(plan)}
|
|
for _, channel := range state.Channels {
|
|
result.ChannelMessages += len(channel.MessageIDs)
|
|
if channel.EditDone {
|
|
result.Edited++
|
|
}
|
|
if channel.DeleteDone {
|
|
result.Deleted++
|
|
}
|
|
if channel.PinDone {
|
|
result.Pinned++
|
|
}
|
|
}
|
|
return result
|
|
}
|