perf: sync protocol and core hardening updates

This commit is contained in:
A 2026-07-11 19:48:26 +08:00
parent 152fed3b87
commit 4390ebf5a9
283 changed files with 29231 additions and 2295 deletions

View file

@ -0,0 +1,68 @@
package memory
import (
"bytes"
"context"
"telesrv/internal/domain"
)
type albumGroupKey struct {
senderUserID int64
peerType domain.PeerType
peerID int64
randomID int64
}
type albumGroupRecord struct {
groupedID int64
intentHash [32]byte
}
// ReserveAlbumGroup 在 MessageStore 的同一把互斥锁下完成读旧组、选胜者与补齐绑定,
// 因而并发重叠批次不会产生拆组。
func (s *MessageStore) ReserveAlbumGroup(_ context.Context, req domain.AlbumGroupReservationRequest) (int64, error) {
if err := req.Validate(); err != nil {
return 0, err
}
s.mu.Lock()
defer s.mu.Unlock()
if s.albumGroups == nil {
s.albumGroups = make(map[albumGroupKey]albumGroupRecord)
}
groupedID := int64(0)
type pendingBinding struct {
key albumGroupKey
intentHash [32]byte
}
bindings := make([]pendingBinding, 0, len(req.Items))
for _, item := range req.Items {
key := albumGroupKey{
senderUserID: req.SenderUserID,
peerType: req.Peer.Type,
peerID: req.Peer.ID,
randomID: item.RandomID,
}
var intentHash [32]byte
copy(intentHash[:], item.IntentHash)
bindings = append(bindings, pendingBinding{key: key, intentHash: intentHash})
if existing, exists := s.albumGroups[key]; exists {
if !bytes.Equal(existing.intentHash[:], item.IntentHash) {
return 0, domain.ErrMessageRandomIDDuplicate
}
if groupedID != 0 && groupedID != existing.groupedID {
return 0, domain.ErrMessageRandomIDDuplicate
}
groupedID = existing.groupedID
}
}
if groupedID == 0 {
groupedID = req.ProposedGroupedID
}
for _, binding := range bindings {
s.albumGroups[binding.key] = albumGroupRecord{groupedID: groupedID, intentHash: binding.intentHash}
}
return groupedID, nil
}

View file

@ -0,0 +1,131 @@
package memory
import (
"context"
"crypto/sha256"
"errors"
"sync"
"testing"
"telesrv/internal/domain"
)
func albumIntent(label string) []byte {
sum := sha256.Sum256([]byte(label))
return sum[:]
}
func albumReq(sender int64, peer domain.Peer, groupedID int64, items ...domain.AlbumGroupReservationItem) domain.AlbumGroupReservationRequest {
return domain.AlbumGroupReservationRequest{
SenderUserID: sender,
Peer: peer,
Items: items,
ProposedGroupedID: groupedID,
}
}
func albumItem(randomID int64, label string) domain.AlbumGroupReservationItem {
return domain.AlbumGroupReservationItem{RandomID: randomID, IntentHash: albumIntent(label)}
}
func TestAlbumGroupReservationFullThenSubsetAndIntentConflict(t *testing.T) {
ctx := context.Background()
messages := NewMessageStore()
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 2002}
full := []domain.AlbumGroupReservationItem{albumItem(1, "one"), albumItem(2, "two"), albumItem(3, "three")}
groupedID, err := messages.ReserveAlbumGroup(ctx, albumReq(1001, peer, 101, full...))
if err != nil || groupedID != 101 {
t.Fatalf("reserve full = %d err=%v, want 101", groupedID, err)
}
replayed, err := messages.ReserveAlbumGroup(ctx, albumReq(1001, peer, 202, full[1:]...))
if err != nil || replayed != groupedID {
t.Fatalf("reserve subset = %d err=%v, want original %d", replayed, err, groupedID)
}
for _, item := range full {
got, err := messages.ReserveAlbumGroup(ctx, albumReq(1001, peer, 303, item))
if err != nil || got != groupedID {
t.Fatalf("single random_id %d = %d err=%v, want %d", item.RandomID, got, err, groupedID)
}
}
changed := albumItem(2, "changed payload")
if _, err := messages.ReserveAlbumGroup(ctx, albumReq(1001, peer, 404, changed)); !errors.Is(err, domain.ErrMessageRandomIDDuplicate) {
t.Fatalf("changed intent err=%v, want ErrMessageRandomIDDuplicate", err)
}
}
func TestAlbumGroupReservationConcurrentOverlapConverges(t *testing.T) {
ctx := context.Background()
messages := NewMessageStore()
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: 9001}
requests := []domain.AlbumGroupReservationRequest{
albumReq(1001, peer, 111, albumItem(11, "one"), albumItem(12, "shared")),
albumReq(1001, peer, 222, albumItem(12, "shared"), albumItem(13, "three")),
}
results := make([]int64, 2)
errs := make([]error, 2)
start := make(chan struct{})
var wg sync.WaitGroup
for i := range requests {
wg.Add(1)
go func(i int) {
defer wg.Done()
<-start
results[i], errs[i] = messages.ReserveAlbumGroup(ctx, requests[i])
}(i)
}
close(start)
wg.Wait()
if errs[0] != nil || errs[1] != nil || results[0] == 0 || results[0] != results[1] {
t.Fatalf("concurrent results=%v errs=%v, want same non-zero group", results, errs)
}
for _, item := range []domain.AlbumGroupReservationItem{albumItem(11, "one"), albumItem(12, "shared"), albumItem(13, "three")} {
got, err := messages.ReserveAlbumGroup(ctx, albumReq(1001, peer, 333, item))
if err != nil || got != results[0] {
t.Fatalf("converged random_id %d = %d err=%v, want %d", item.RandomID, got, err, results[0])
}
}
}
func TestAlbumGroupReservationRejectsMixedOldGroupsAtomically(t *testing.T) {
ctx := context.Background()
messages := NewMessageStore()
peer := domain.Peer{Type: domain.PeerTypeUser, ID: 2002}
one := albumItem(21, "one")
two := albumItem(22, "two")
three := albumItem(23, "three")
if _, err := messages.ReserveAlbumGroup(ctx, albumReq(1001, peer, 121, one)); err != nil {
t.Fatal(err)
}
if _, err := messages.ReserveAlbumGroup(ctx, albumReq(1001, peer, 122, two)); err != nil {
t.Fatal(err)
}
if _, err := messages.ReserveAlbumGroup(ctx, albumReq(1001, peer, 123, one, two, three)); !errors.Is(err, domain.ErrMessageRandomIDDuplicate) {
t.Fatalf("mixed old groups err=%v, want ErrMessageRandomIDDuplicate", err)
}
// 失败批次不能把尚未存在的 random_id 23 偷绑到任一旧组。
got, err := messages.ReserveAlbumGroup(ctx, albumReq(1001, peer, 124, three))
if err != nil || got != 124 {
t.Fatalf("post-conflict unbound item = %d err=%v, want fresh 124", got, err)
}
}
func TestAlbumGroupReservationScopeIncludesPeer(t *testing.T) {
ctx := context.Background()
messages := NewMessageStore()
item := albumItem(31, "same intent")
tests := []struct {
peer domain.Peer
group int64
}{
{peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2002}, group: 131},
{peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2003}, group: 132},
{peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 2002}, group: 133},
}
for _, tc := range tests {
got, err := messages.ReserveAlbumGroup(ctx, albumReq(1001, tc.peer, tc.group, item))
if err != nil || got != tc.group {
t.Fatalf("peer %+v = %d err=%v, want isolated %d", tc.peer, got, err, tc.group)
}
}
}

View file

@ -276,6 +276,11 @@ func NewCodeStore() *CodeStore {
}
func (s *CodeStore) Set(_ context.Context, hash string, code store.PhoneCode, ttl time.Duration) error {
revision, err := store.NewPhoneCodeRevisionToken()
if err != nil {
return err
}
code.Revision = revision
s.mu.Lock()
scope := code.Scope()
if scope.Valid() {
@ -303,6 +308,11 @@ func (s *CodeStore) Get(_ context.Context, hash string) (store.PhoneCode, bool,
}
func (s *CodeStore) Update(_ context.Context, hash string, code store.PhoneCode) error {
revision, err := store.NewPhoneCodeRevisionToken()
if err != nil {
return err
}
code.Revision = revision
s.mu.Lock()
defer s.mu.Unlock()
e, ok := s.m[hash]
@ -343,7 +353,13 @@ func (s *CodeStore) ConsumeScoped(_ context.Context, hash string, scope store.Ph
}
return store.PhoneCode{}, false, nil
}
if e.code.Scope() != scope {
if e.code.Version != store.PhoneCodeVersionCurrent || e.code.Scope() != scope {
delete(s.m, hash)
delete(s.scopes, scope)
actualScope := e.code.Scope()
if actualScope.Valid() && s.scopes[actualScope] == hash {
delete(s.scopes, actualScope)
}
return store.PhoneCode{}, false, nil
}
s.deleteCodeLocked(hash, e.code)

View file

@ -67,10 +67,15 @@ func (s *BootstrapUpdateJobStore) MarkReadyForSession(_ context.Context, userID
s.mu.Lock()
defer s.mu.Unlock()
for id, job := range s.jobs {
if job.UserID != userID || job.AuthKeyID != authKeyID || job.SessionID != sessionID || job.Status != domain.BootstrapUpdateJobPending {
if job.UserID != userID || job.AuthKeyID != authKeyID || job.Status != domain.BootstrapUpdateJobPending {
continue
}
job.Status = domain.BootstrapUpdateJobReady
// A reconnect on the same physical/business auth key is the same
// verified device. Transfer the pending baseline fence to the session
// that actually completed getState/getDifference instead of orphaning
// the job on the disconnected signup session.
job.SessionID = sessionID
job.ReadyAt = now
job.UpdatedAt = now
s.jobs[id] = job

View file

@ -319,14 +319,8 @@ func (s *ChannelStore) ListDirtyActiveChannelsForUser(_ context.Context, userID
if !ok || member.Status != domain.ChannelMemberActive {
continue
}
dirty := false
for _, event := range s.events[channelID] {
if event.Date > sinceDate {
dirty = true
break
}
}
if dirty {
checkpoint := s.channelUpdateCheckpointLocked(channelID, channel)
if checkpoint.LatestEventDate > sinceDate {
out = append(out, domain.DirtyChannel{ChannelID: channelID, Pts: channel.Pts})
}
}

View file

@ -240,7 +240,16 @@ func (s *ChannelStore) deleteChannelMessagesLocked(channel domain.Channel, membe
MessageIDs: append([]int(nil), deleted...),
SenderUserID: actorUserID,
}
s.events[channel.ID] = append(s.events[channel.ID], event)
s.appendChannelEventLocked(event)
for _, id := range deleted {
msg, ok := s.findMessageLocked(channel.ID, id)
if !ok || msg.RandomID == 0 || msg.SenderUserID == 0 {
continue
}
key := channelMessageReplayKey{channelID: channel.ID, messageID: msg.ID}
cloned := cloneChannelEvent(event)
s.deleteReceipts[key] = &cloned
}
return deleted, event, channel, nil
}

View file

@ -58,7 +58,7 @@ func (s *ChannelStore) EditChannelMessage(_ context.Context, req domain.EditChan
Message: cloneChannelMessage(msg),
SenderUserID: req.UserID,
}
s.events[req.ChannelID] = append(s.events[req.ChannelID], event)
s.appendChannelEventLocked(event)
return domain.EditChannelMessageResult{
Channel: channel,
Message: cloneChannelMessage(msg),
@ -108,7 +108,7 @@ func (s *ChannelStore) EditChannelMessage(_ context.Context, req domain.EditChan
Message: cloneChannelMessage(msg),
SenderUserID: req.UserID,
}
s.events[req.ChannelID] = append(s.events[req.ChannelID], event)
s.appendChannelEventLocked(event)
s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{
ChannelID: req.ChannelID,
UserID: req.UserID,
@ -150,7 +150,7 @@ func (s *ChannelStore) EditChannelMessage(_ context.Context, req domain.EditChan
SenderUserID: req.UserID,
}
s.messages[req.ChannelID] = append(s.messages[req.ChannelID], serviceMsg)
s.events[req.ChannelID] = append(s.events[req.ChannelID], serviceEvent)
s.appendChannelEventLocked(serviceEvent)
s.updateForumTopicTopMessageLocked(req.ChannelID, serviceMsg)
channel.TopMessageID = serviceMsg.ID
channel.Pts = servicePts
@ -303,7 +303,7 @@ func (s *ChannelStore) UpdatePinnedMessage(_ context.Context, req domain.UpdateC
SenderUserID: req.UserID,
Pinned: req.Pinned,
}
s.events[req.ChannelID] = append(s.events[req.ChannelID], event)
s.appendChannelEventLocked(event)
logMsg := msg
logMsg.Pinned = req.Pinned
s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{
@ -372,7 +372,7 @@ func (s *ChannelStore) UnpinAllChannelMessages(_ context.Context, req domain.Unp
SenderUserID: req.UserID,
Pinned: false,
}
s.events[req.ChannelID] = append(s.events[req.ChannelID], event)
s.appendChannelEventLocked(event)
return domain.UpdateChannelPinnedMessageResult{
Channel: channel,
Event: cloneChannelEvent(event),

View file

@ -2,8 +2,10 @@ package memory
import (
"context"
"fmt"
"strings"
"telesrv/internal/domain"
"telesrv/internal/store"
"time"
)
@ -14,8 +16,27 @@ func (s *ChannelStore) SendChannelMessage(_ context.Context, req domain.SendChan
if strings.TrimSpace(req.Message) == "" && req.Action == nil && req.Media.IsZero() && req.RichMessage.IsZero() {
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
}
var fingerprint []byte
var err error
if req.RandomID != 0 {
fingerprint, err = store.ChannelSendFingerprint(req)
if err != nil {
return domain.SendChannelMessageResult{}, err
}
req.IdempotencyFingerprint = fingerprint
}
s.mu.Lock()
defer s.mu.Unlock()
if req.RandomID != 0 {
if replay, found, replayErr := s.lookupChannelSendReplayLocked(domain.ChannelSendReplayRequest{
ChannelID: req.ChannelID,
SenderUserID: req.UserID,
RandomID: req.RandomID,
IdempotencyFingerprint: fingerprint,
}); replayErr != nil || found {
return replay, replayErr
}
}
channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID)
if err != nil {
return domain.SendChannelMessageResult{}, err
@ -34,23 +55,6 @@ func (s *ChannelStore) SendChannelMessage(_ context.Context, req domain.SendChan
if !canSendChannelMessageWithBoost(channel, member, fromBoostsApplied) {
return domain.SendChannelMessageResult{}, domain.ErrChannelWriteForbidden
}
if req.RandomID != 0 {
if id, ok := s.randomToID[channelRandomKey{channelID: req.ChannelID, userID: req.UserID, randomID: req.RandomID}]; ok {
msg, ok := s.findMessageLocked(req.ChannelID, id)
if ok {
event := s.eventForMessageLocked(req.ChannelID, id)
if event.Message.ID != 0 {
msg = event.Message
}
return domain.SendChannelMessageResult{
Channel: channel,
Message: cloneChannelMessage(msg),
Event: event,
Duplicate: true,
}, nil
}
}
}
if wait := channelSlowModeWait(channel, member, req.Date); wait > 0 {
return domain.SendChannelMessageResult{}, domain.NewSlowModeWaitError(wait)
}
@ -99,7 +103,7 @@ func (s *ChannelStore) SendChannelMessage(_ context.Context, req domain.SendChan
Message: cloneChannelMessage(discussionMsg),
}
s.messages[linked.ID] = append(s.messages[linked.ID], discussionMsg)
s.events[linked.ID] = append(s.events[linked.ID], discussionEvent)
s.appendChannelEventLocked(discussionEvent)
linked.TopMessageID = discussionMsgID
linked.Pts = discussionPts
s.channels[linked.ID] = linked
@ -145,6 +149,13 @@ func (s *ChannelStore) SendChannelMessage(_ context.Context, req domain.SendChan
Pts: pts,
}
msg.Replies = s.channelMessageRepliesLocked(req.UserID, req.ChannelID, msg)
var sendSnapshot []byte
if req.RandomID != 0 {
sendSnapshot, err = store.EncodeChannelSendSnapshot(msg)
if err != nil {
return domain.SendChannelMessageResult{}, err
}
}
event := domain.ChannelUpdateEvent{
ChannelID: req.ChannelID,
Type: domain.ChannelUpdateNewMessage,
@ -155,7 +166,7 @@ func (s *ChannelStore) SendChannelMessage(_ context.Context, req domain.SendChan
SenderUserID: req.UserID,
}
s.messages[req.ChannelID] = append(s.messages[req.ChannelID], msg)
s.events[req.ChannelID] = append(s.events[req.ChannelID], event)
s.appendChannelEventLocked(event)
if !channel.Broadcast || channel.Megagroup {
mentionTargets := req.MentionUserIDs
if msg.ReplyTo != nil && msg.ReplyTo.MessageID > 0 {
@ -178,7 +189,11 @@ func (s *ChannelStore) SendChannelMessage(_ context.Context, req domain.SendChan
})
}
if req.RandomID != 0 {
s.randomToID[channelRandomKey{channelID: req.ChannelID, userID: req.UserID, randomID: req.RandomID}] = msg.ID
key := channelRandomKey{channelID: req.ChannelID, userID: req.UserID, randomID: req.RandomID}
s.randomToID[key] = msg.ID
replayKey := channelMessageReplayKey{channelID: req.ChannelID, messageID: msg.ID}
s.sendSnapshots[replayKey] = sendSnapshot
s.sendFingerprints[replayKey] = append([]byte(nil), fingerprint...)
}
channel.TopMessageID = msg.ID
channel.Pts = pts
@ -209,6 +224,85 @@ func (s *ChannelStore) SendChannelMessage(_ context.Context, req domain.SendChan
}, nil
}
// LookupChannelSendReplay returns a committed regular-channel or monoforum send receipt without
// evaluating current membership, write permissions, slow mode or any message allocation path.
func (s *ChannelStore) LookupChannelSendReplay(_ context.Context, req domain.ChannelSendReplayRequest) (domain.SendChannelMessageResult, bool, error) {
if req.ChannelID == 0 || req.SenderUserID == 0 || req.RandomID == 0 {
return domain.SendChannelMessageResult{}, false, fmt.Errorf("memory channel send replay: invalid scope")
}
if req.SavedPeer.ID == 0 {
if req.SavedPeer.Type != "" {
return domain.SendChannelMessageResult{}, false, fmt.Errorf("memory channel send replay: incomplete saved peer scope")
}
} else if req.SavedPeer.Type != domain.PeerTypeUser {
return domain.SendChannelMessageResult{}, false, fmt.Errorf("memory channel send replay: invalid saved peer scope")
}
if err := store.ValidateSendFingerprint(req.IdempotencyFingerprint, "channel send replay"); err != nil {
return domain.SendChannelMessageResult{}, false, err
}
s.mu.RLock()
defer s.mu.RUnlock()
return s.lookupChannelSendReplayLocked(req)
}
func (s *ChannelStore) lookupChannelSendReplayLocked(req domain.ChannelSendReplayRequest) (domain.SendChannelMessageResult, bool, error) {
var id int
if req.SavedPeer.ID == 0 {
var found bool
id, found = s.randomToID[channelRandomKey{channelID: req.ChannelID, userID: req.SenderUserID, randomID: req.RandomID}]
if !found {
return domain.SendChannelMessageResult{}, false, nil
}
} else {
msg, found := s.findMonoforumDuplicateLocked(req.ChannelID, req.SenderUserID, req.SavedPeer, req.RandomID)
if !found {
return domain.SendChannelMessageResult{}, false, nil
}
id = msg.ID
}
replayKey := channelMessageReplayKey{channelID: req.ChannelID, messageID: id}
if !store.SameSendFingerprint(s.sendFingerprints[replayKey], req.IdempotencyFingerprint) {
return domain.SendChannelMessageResult{}, false, domain.ErrMessageRandomIDDuplicate
}
first, err := store.DecodeChannelSendSnapshot(s.sendSnapshots[replayKey])
if err != nil {
return domain.SendChannelMessageResult{}, false, fmt.Errorf("memory duplicate channel message snapshot: %w", err)
}
if first.ID != id || first.ChannelID != req.ChannelID || first.SenderUserID != req.SenderUserID || first.RandomID != req.RandomID || first.SavedPeer != req.SavedPeer {
return domain.SendChannelMessageResult{}, false, fmt.Errorf("memory duplicate channel message snapshot disagrees with random_id receipt")
}
replay := first
var replayDelete *domain.ChannelUpdateEvent
if current, found := s.findMessageLocked(req.ChannelID, id); found && !current.Deleted {
replay = cloneChannelMessage(current)
} else if receipt := s.deleteReceipts[replayKey]; receipt != nil {
cloned := cloneChannelEvent(*receipt)
replayDelete = &cloned
} else {
return domain.SendChannelMessageResult{}, false, fmt.Errorf("memory duplicate channel message %d is absent without a durable delete receipt", id)
}
channel, ok := s.channels[req.ChannelID]
if !ok {
return domain.SendChannelMessageResult{}, false, fmt.Errorf("memory duplicate channel message %d has no channel", id)
}
event := domain.ChannelUpdateEvent{
ChannelID: first.ChannelID,
Type: domain.ChannelUpdateNewMessage,
Pts: first.Pts,
PtsCount: 1,
Date: first.Date,
Message: cloneChannelMessage(replay),
SenderUserID: first.SenderUserID,
}
return domain.SendChannelMessageResult{
Channel: cloneChannel(channel),
Message: cloneChannelMessage(replay),
Event: event,
Duplicate: true,
ReplayDeleteEvent: replayDelete,
}, true, nil
}
func channelDeliverySkipSet(ids []int64) map[int64]struct{} {
if len(ids) == 0 {
return nil
@ -267,7 +361,7 @@ func (s *ChannelStore) appendChannelServiceMessageLocked(channelID, senderUserID
UserIDs: append([]int64(nil), action.UserIDs...),
}
s.messages[channelID] = append(s.messages[channelID], msg)
s.events[channelID] = append(s.events[channelID], event)
s.appendChannelEventLocked(event)
return msg, event
}

View file

@ -7,6 +7,7 @@ import (
"time"
"telesrv/internal/domain"
"telesrv/internal/store"
)
// SendMonoforumMessage 向 monoforum(频道私信)虚拟频道发一条消息,按 saved_peer 分订阅者子会话。
@ -16,8 +17,28 @@ func (s *ChannelStore) SendMonoforumMessage(_ context.Context, req domain.SendMo
req.SavedPeer.Type != domain.PeerTypeUser || strings.TrimSpace(req.Message) == "" {
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
}
var fingerprint []byte
var err error
if req.RandomID != 0 {
fingerprint, err = store.MonoforumSendFingerprint(req)
if err != nil {
return domain.SendChannelMessageResult{}, err
}
req.IdempotencyFingerprint = fingerprint
}
s.mu.Lock()
defer s.mu.Unlock()
if req.RandomID != 0 {
if replay, found, replayErr := s.lookupChannelSendReplayLocked(domain.ChannelSendReplayRequest{
ChannelID: req.MonoforumID,
SenderUserID: req.SenderUserID,
SavedPeer: req.SavedPeer,
RandomID: req.RandomID,
IdempotencyFingerprint: fingerprint,
}); replayErr != nil || found {
return replay, replayErr
}
}
channel, ok := s.channels[req.MonoforumID]
if !ok || channel.Deleted || !channel.Monoforum {
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
@ -25,17 +46,6 @@ func (s *ChannelStore) SendMonoforumMessage(_ context.Context, req domain.SendMo
if req.Date == 0 {
req.Date = int(time.Now().Unix())
}
if req.RandomID != 0 {
// 去重维度 = (sender, saved_peer, random_id),与 postgres 迁移 0022 的唯一索引一致;
// 不复用账号级 randomToID(其按 channel+sender+random_id 三元组,会被跨子会话同 random_id 互相覆盖)。
if dup, ok := s.findMonoforumDuplicateLocked(req.MonoforumID, req.SenderUserID, req.SavedPeer, req.RandomID); ok {
event := s.eventForMessageLocked(req.MonoforumID, dup.ID)
if event.Message.ID != 0 {
dup = event.Message
}
return domain.SendChannelMessageResult{Channel: cloneChannel(channel), Message: cloneChannelMessage(dup), Event: event, Duplicate: true}, nil
}
}
pts := s.nextChannelPtsLocked(req.MonoforumID)
msgID := s.nextChannelMessageIDLocked(req.MonoforumID)
msg := domain.ChannelMessage{
@ -50,6 +60,14 @@ func (s *ChannelStore) SendMonoforumMessage(_ context.Context, req domain.SendMo
Entities: append([]domain.MessageEntity(nil), req.Entities...),
Pts: pts,
}
var sendSnapshot []byte
if req.RandomID != 0 {
var snapshotErr error
sendSnapshot, snapshotErr = store.EncodeChannelSendSnapshot(msg)
if snapshotErr != nil {
return domain.SendChannelMessageResult{}, snapshotErr
}
}
event := domain.ChannelUpdateEvent{
ChannelID: req.MonoforumID,
Type: domain.ChannelUpdateNewMessage,
@ -60,7 +78,12 @@ func (s *ChannelStore) SendMonoforumMessage(_ context.Context, req domain.SendMo
SenderUserID: req.SenderUserID,
}
s.messages[req.MonoforumID] = append(s.messages[req.MonoforumID], msg)
s.events[req.MonoforumID] = append(s.events[req.MonoforumID], event)
if req.RandomID != 0 {
replayKey := channelMessageReplayKey{channelID: req.MonoforumID, messageID: msg.ID}
s.sendSnapshots[replayKey] = sendSnapshot
s.sendFingerprints[replayKey] = append([]byte(nil), fingerprint...)
}
s.appendChannelEventLocked(event)
channel.TopMessageID = msgID
channel.Pts = pts
s.channels[req.MonoforumID] = channel
@ -75,7 +98,7 @@ func (s *ChannelStore) findMonoforumDuplicateLocked(monoforumID, senderUserID in
msgs := s.messages[monoforumID]
for i := len(msgs) - 1; i >= 0; i-- {
m := msgs[i]
if !m.Deleted && m.RandomID == randomID && m.SenderUserID == senderUserID && m.SavedPeer == savedPeer {
if m.RandomID == randomID && m.SenderUserID == senderUserID && m.SavedPeer == savedPeer {
return m, true
}
}

View file

@ -2,6 +2,7 @@ package memory
import (
"context"
"errors"
"testing"
"telesrv/internal/domain"
@ -69,6 +70,9 @@ func TestSendMonoforumMessageAndHistory(t *testing.T) {
if !dup.Duplicate || dup.Message.ID != m1.Message.ID {
t.Fatalf("dup = %+v, want duplicate of m1 id %d", dup.Message, m1.Message.ID)
}
if _, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 42, SavedPeer: sub, RandomID: 111, Message: "changed", Date: 1_700_001_004}); !errors.Is(err, domain.ErrMessageRandomIDDuplicate) {
t.Fatalf("conflicting monoforum replay err=%v, want ErrMessageRandomIDDuplicate", err)
}
hist, err := store.ListMonoforumHistory(ctx, domain.MonoforumHistoryFilter{MonoforumID: monoID, SavedPeer: sub, Limit: 10})
if err != nil {
@ -131,4 +135,21 @@ func TestSendMonoforumMessageAndHistory(t *testing.T) {
if dialogs.Dialogs[1].SavedPeer != sub || dialogs.Dialogs[1].TopMessageID == 0 {
t.Fatalf("dialogs[1] = %+v, want sub with top message", dialogs.Dialogs[1])
}
store.mu.Lock()
_, deleteEvent, _, err := store.deleteChannelMessagesLocked(store.channels[monoID], domain.ChannelMember{ChannelID: monoID, UserID: 1, Role: domain.ChannelRoleCreator, Status: domain.ChannelMemberActive}, []int{a.Message.ID}, 1, 1_700_001_013)
store.mu.Unlock()
if err != nil {
t.Fatalf("delete monoforum message: %v", err)
}
ptsBeforeReplay, eventsBeforeReplay := store.ptsSeq[monoID], len(store.events[monoID])
deletedReplay, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 1, SavedPeer: sub, RandomID: 9001, Message: "to sub", Date: 1_700_001_014})
if err != nil {
t.Fatalf("replay deleted monoforum message: %v", err)
}
if !deletedReplay.Duplicate || deletedReplay.Message.ID != a.Message.ID || deletedReplay.Message.Body != "to sub" || deletedReplay.ReplayDeleteEvent == nil || deletedReplay.ReplayDeleteEvent.Pts != deleteEvent.Pts {
t.Fatalf("deleted monoforum replay = %+v, want first snapshot + durable delete %+v", deletedReplay, deleteEvent)
}
if store.ptsSeq[monoID] != ptsBeforeReplay || len(store.events[monoID]) != eventsBeforeReplay {
t.Fatalf("deleted monoforum replay mutated pts/events = %d/%d, want %d/%d", store.ptsSeq[monoID], len(store.events[monoID]), ptsBeforeReplay, eventsBeforeReplay)
}
}

View file

@ -0,0 +1,22 @@
package memory
import (
"context"
"testing"
)
func TestMaxChannelPtsBatchUsesOneSnapshotAndOmitsMissing(t *testing.T) {
channels := NewChannelStore()
channels.mu.Lock()
channels.ptsSeq[10] = 7
channels.ptsSeq[20] = 11
channels.mu.Unlock()
got, err := channels.MaxChannelPtsBatch(context.Background(), []int64{20, 999, 10, 20})
if err != nil {
t.Fatalf("MaxChannelPtsBatch: %v", err)
}
if len(got) != 2 || got[10] != 7 || got[20] != 11 {
t.Fatalf("batch pts = %v, want map[10:7 20:11] with missing id omitted", got)
}
}

View file

@ -0,0 +1,76 @@
package memory
import (
"context"
"errors"
"testing"
"telesrv/internal/domain"
)
func TestChannelRandomIDReplayUsesCurrentSnapshotAndDurableDeleteMemory(t *testing.T) {
ctx := context.Background()
channels := NewChannelStore()
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: 1,
Title: "replay convergence",
Megagroup: true,
Date: 1_700_001_000,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
req := domain.SendChannelMessageRequest{
UserID: 1, ChannelID: created.Channel.ID, RandomID: 77001,
Message: "original", Date: 1_700_001_001,
}
first, err := channels.SendChannelMessage(ctx, req)
if err != nil {
t.Fatalf("send channel message: %v", err)
}
conflict := req
conflict.Message = "same random id, different intent"
if _, err := channels.SendChannelMessage(ctx, conflict); !errors.Is(err, domain.ErrMessageRandomIDDuplicate) {
t.Fatalf("conflicting channel replay err=%v, want ErrMessageRandomIDDuplicate", err)
}
edited, err := channels.EditChannelMessage(ctx, domain.EditChannelMessageRequest{
UserID: 1, ChannelID: created.Channel.ID, ID: first.Message.ID,
Message: "edited", EditDate: 1_700_001_002,
})
if err != nil {
t.Fatalf("edit channel message: %v", err)
}
ptsBeforeReplay := channels.ptsSeq[created.Channel.ID]
eventsBeforeReplay := len(channels.events[created.Channel.ID])
replay, err := channels.SendChannelMessage(ctx, req)
if err != nil {
t.Fatalf("replay after edit: %v", err)
}
if !replay.Duplicate || replay.Message.Body != "edited" || replay.Message.Pts != edited.Message.Pts || replay.Event.Pts != first.Event.Pts || replay.ReplayDeleteEvent != nil {
t.Fatalf("replay after edit = %+v, want current snapshot with first-send pts", replay)
}
if channels.ptsSeq[created.Channel.ID] != ptsBeforeReplay || len(channels.events[created.Channel.ID]) != eventsBeforeReplay {
t.Fatalf("edit replay mutated channel pts/events = %d/%d, want %d/%d", channels.ptsSeq[created.Channel.ID], len(channels.events[created.Channel.ID]), ptsBeforeReplay, eventsBeforeReplay)
}
deleted, err := channels.DeleteChannelMessages(ctx, domain.DeleteChannelMessagesRequest{
UserID: 1, ChannelID: created.Channel.ID, IDs: []int{first.Message.ID}, Date: 1_700_001_003,
})
if err != nil {
t.Fatalf("delete channel message: %v", err)
}
ptsBeforeReplay = channels.ptsSeq[created.Channel.ID]
eventsBeforeReplay = len(channels.events[created.Channel.ID])
replay, err = channels.SendChannelMessage(ctx, req)
if err != nil {
t.Fatalf("replay after delete: %v", err)
}
if !replay.Duplicate || replay.Message.Body != "original" || replay.Message.Pts != first.Message.Pts || replay.Event.Pts != first.Event.Pts {
t.Fatalf("replay after delete = %+v, want immutable first snapshot", replay)
}
if replay.ReplayDeleteEvent == nil || replay.ReplayDeleteEvent.Pts != deleted.Event.Pts || len(replay.ReplayDeleteEvent.MessageIDs) != 1 || replay.ReplayDeleteEvent.MessageIDs[0] != first.Message.ID {
t.Fatalf("replay delete = %+v, want durable event %+v", replay.ReplayDeleteEvent, deleted.Event)
}
if channels.ptsSeq[created.Channel.ID] != ptsBeforeReplay || len(channels.events[created.Channel.ID]) != eventsBeforeReplay {
t.Fatalf("delete replay mutated channel pts/events = %d/%d, want %d/%d", channels.ptsSeq[created.Channel.ID], len(channels.events[created.Channel.ID]), ptsBeforeReplay, eventsBeforeReplay)
}
}

View file

@ -13,6 +13,11 @@ type channelRandomKey struct {
randomID int64
}
type channelMessageReplayKey struct {
channelID int64
messageID int
}
type boostSlotKey struct {
userID int64
slot int
@ -58,33 +63,37 @@ func (w channelReadWatermark) advance(userID int64, maxID int) channelReadWaterm
// ChannelStore is an in-memory channel/supergroup store for tests and local development.
type ChannelStore struct {
mu sync.RWMutex
nextID int64
nextHash int64
channels map[int64]domain.Channel
members map[int64]map[int64]domain.ChannelMember
dialogs map[int64]map[int64]domain.ChannelDialog
topics map[int64]map[int]domain.ChannelForumTopic
messages map[int64][]domain.ChannelMessage
reactions map[int64]map[int]map[int64][]domain.ChannelMessagePeerReaction
mu sync.RWMutex
nextID int64
nextHash int64
channels map[int64]domain.Channel
members map[int64]map[int64]domain.ChannelMember
dialogs map[int64]map[int64]domain.ChannelDialog
topics map[int64]map[int]domain.ChannelForumTopic
messages map[int64][]domain.ChannelMessage
reactions map[int64]map[int]map[int64][]domain.ChannelMessagePeerReaction
// paidReactions 是 per-(channel,message,user) 付费 reaction 累计星数 + 匿名标志。
paidReactions map[int64]map[int]map[int64]memoryPaidReaction
top map[int64]map[string]domain.TopMessageReaction
recent map[int64]map[string]domain.RecentMessageReaction
savedTags map[int64]map[string]domain.SavedReactionTag
mentions map[int64]map[int64]map[int]memoryMention
msgViews map[int64]map[int]int
msgViewers map[int64]map[int]map[int64]struct{}
events map[int64][]domain.ChannelUpdateEvent
adminLogs map[int64][]domain.ChannelAdminLogEvent
invites map[string]domain.ChannelInvite
importers map[int64]map[int64]domain.ChannelInviteImporter
msgSeq map[int64]int
ptsSeq map[int64]int
logSeq map[int64]int64
randomToID map[channelRandomKey]int
boostSlots map[boostSlotKey]domain.PremiumBoostSlot
readMarks map[int64]channelReadWatermark
paidReactions map[int64]map[int]map[int64]memoryPaidReaction
top map[int64]map[string]domain.TopMessageReaction
recent map[int64]map[string]domain.RecentMessageReaction
savedTags map[int64]map[string]domain.SavedReactionTag
mentions map[int64]map[int64]map[int]memoryMention
msgViews map[int64]map[int]int
msgViewers map[int64]map[int]map[int64]struct{}
events map[int64][]domain.ChannelUpdateEvent
retention map[int64]domain.ChannelUpdateRetentionCheckpoint
adminLogs map[int64][]domain.ChannelAdminLogEvent
invites map[string]domain.ChannelInvite
importers map[int64]map[int64]domain.ChannelInviteImporter
msgSeq map[int64]int
ptsSeq map[int64]int
logSeq map[int64]int64
randomToID map[channelRandomKey]int
sendSnapshots map[channelMessageReplayKey][]byte
sendFingerprints map[channelMessageReplayKey][]byte
deleteReceipts map[channelMessageReplayKey]*domain.ChannelUpdateEvent
boostSlots map[boostSlotKey]domain.PremiumBoostSlot
readMarks map[int64]channelReadWatermark
// topicReads 是 per-(channel,user,topic) 已读水位(forum 话题独立已读,不碰频道级 member 水位)。
topicReads map[int64]map[int64]map[int]memoryTopicRead
// polls 是共享 poll 权威(与 MessageStore 同一实例);nil 时 poll 链路按未接入处理。
@ -99,31 +108,35 @@ func (s *ChannelStore) AttachPollStore(polls *PollStore) {
// NewChannelStore creates an in-memory ChannelStore.
func NewChannelStore() *ChannelStore {
return &ChannelStore{
nextID: firstMemoryChannelID,
nextHash: 900000000000,
channels: make(map[int64]domain.Channel),
members: make(map[int64]map[int64]domain.ChannelMember),
dialogs: make(map[int64]map[int64]domain.ChannelDialog),
topics: make(map[int64]map[int]domain.ChannelForumTopic),
messages: make(map[int64][]domain.ChannelMessage),
reactions: make(map[int64]map[int]map[int64][]domain.ChannelMessagePeerReaction),
paidReactions: make(map[int64]map[int]map[int64]memoryPaidReaction),
top: make(map[int64]map[string]domain.TopMessageReaction),
recent: make(map[int64]map[string]domain.RecentMessageReaction),
savedTags: make(map[int64]map[string]domain.SavedReactionTag),
mentions: make(map[int64]map[int64]map[int]memoryMention),
msgViews: make(map[int64]map[int]int),
msgViewers: make(map[int64]map[int]map[int64]struct{}),
events: make(map[int64][]domain.ChannelUpdateEvent),
adminLogs: make(map[int64][]domain.ChannelAdminLogEvent),
invites: make(map[string]domain.ChannelInvite),
importers: make(map[int64]map[int64]domain.ChannelInviteImporter),
msgSeq: make(map[int64]int),
ptsSeq: make(map[int64]int),
logSeq: make(map[int64]int64),
randomToID: make(map[channelRandomKey]int),
boostSlots: make(map[boostSlotKey]domain.PremiumBoostSlot),
readMarks: make(map[int64]channelReadWatermark),
topicReads: make(map[int64]map[int64]map[int]memoryTopicRead),
nextID: firstMemoryChannelID,
nextHash: 900000000000,
channels: make(map[int64]domain.Channel),
members: make(map[int64]map[int64]domain.ChannelMember),
dialogs: make(map[int64]map[int64]domain.ChannelDialog),
topics: make(map[int64]map[int]domain.ChannelForumTopic),
messages: make(map[int64][]domain.ChannelMessage),
reactions: make(map[int64]map[int]map[int64][]domain.ChannelMessagePeerReaction),
paidReactions: make(map[int64]map[int]map[int64]memoryPaidReaction),
top: make(map[int64]map[string]domain.TopMessageReaction),
recent: make(map[int64]map[string]domain.RecentMessageReaction),
savedTags: make(map[int64]map[string]domain.SavedReactionTag),
mentions: make(map[int64]map[int64]map[int]memoryMention),
msgViews: make(map[int64]map[int]int),
msgViewers: make(map[int64]map[int]map[int64]struct{}),
events: make(map[int64][]domain.ChannelUpdateEvent),
retention: make(map[int64]domain.ChannelUpdateRetentionCheckpoint),
adminLogs: make(map[int64][]domain.ChannelAdminLogEvent),
invites: make(map[string]domain.ChannelInvite),
importers: make(map[int64]map[int64]domain.ChannelInviteImporter),
msgSeq: make(map[int64]int),
ptsSeq: make(map[int64]int),
logSeq: make(map[int64]int64),
randomToID: make(map[channelRandomKey]int),
sendSnapshots: make(map[channelMessageReplayKey][]byte),
sendFingerprints: make(map[channelMessageReplayKey][]byte),
deleteReceipts: make(map[channelMessageReplayKey]*domain.ChannelUpdateEvent),
boostSlots: make(map[boostSlotKey]domain.PremiumBoostSlot),
readMarks: make(map[int64]channelReadWatermark),
topicReads: make(map[int64]map[int64]map[int]memoryTopicRead),
}
}

View file

@ -1093,8 +1093,12 @@ func TestChannelMessageReplyMarkupSurvivesReadPaths(t *testing.T) {
UserID: 1,
ChannelID: created.Channel.ID,
RandomID: 38_001,
Message: "duplicate must not replace",
Date: 1_700_000_382,
Message: "via inline keyboard",
ViaBotID: 99,
ReplyMarkup: &domain.MessageReplyMarkup{Inline: [][]domain.MarkupButton{{
{Type: domain.MarkupButtonCallback, Text: "Open", Data: []byte{0x00, 0xff, 0x42}},
}}},
Date: 1_700_000_382,
})
if err != nil {
t.Fatalf("duplicate send: %v", err)

View file

@ -0,0 +1,150 @@
package memory
import (
"context"
"strings"
"testing"
"time"
"telesrv/internal/domain"
)
func TestChannelUpdateRetentionFloorDifferenceAndDirtyCheckpointMemory(t *testing.T) {
ctx := context.Background()
store := NewChannelStore()
const ownerID int64 = 701
created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: ownerID,
Title: "retention memory",
Megagroup: true,
Date: 1_700_010_000,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
channelID := created.Channel.ID
sent := make([]domain.SendChannelMessageResult, 0, 3)
for i := 1; i <= 3; i++ {
result, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
UserID: ownerID,
ChannelID: channelID,
RandomID: int64(9000 + i),
Message: "retention",
Date: 1_700_010_000 + i,
})
if err != nil {
t.Fatalf("send message %d: %v", i, err)
}
sent = append(sent, result)
}
// Delete create + first two messages. The third message remains as the normal incremental page.
pruned, err := store.PruneChannelUpdateEvents(ctx, channelID, sent[1].Event.Pts, 100)
if err != nil {
t.Fatalf("prune channel updates: %v", err)
}
if pruned.Deleted != 3 || pruned.Checkpoint.RetainedThroughPts != sent[1].Event.Pts {
t.Fatalf("prune result = %+v, want deleted=3 floor=%d", pruned, sent[1].Event.Pts)
}
if pruned.Checkpoint.LatestPts != sent[2].Event.Pts || pruned.Checkpoint.LatestEventDate != sent[2].Event.Date {
t.Fatalf("checkpoint latest = %+v, want pts/date %d/%d", pruned.Checkpoint, sent[2].Event.Pts, sent[2].Event.Date)
}
below, err := store.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{
UserID: ownerID, ChannelID: channelID, Pts: pruned.Checkpoint.RetainedThroughPts - 1, Limit: 100,
})
if err != nil {
t.Fatalf("difference below retained floor: %v", err)
}
if !below.TooLong || below.Pts != sent[2].Event.Pts || below.Dialog.ChannelID != channelID {
t.Fatalf("difference below floor = %+v, want complete too-long snapshot at pts %d", below, sent[2].Event.Pts)
}
atFloor, err := store.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{
UserID: ownerID, ChannelID: channelID, Pts: pruned.Checkpoint.RetainedThroughPts, Limit: 100,
})
if err != nil {
t.Fatalf("difference at retained floor: %v", err)
}
if atFloor.TooLong || len(atFloor.Events) != 1 || atFloor.Events[0].Pts != sent[2].Event.Pts {
t.Fatalf("difference at floor = %+v, want one normal incremental event at pts %d", atFloor, sent[2].Event.Pts)
}
// Remove the remaining row. Dirty-channel recovery must still use checkpoint.latest_event_date.
allPruned, err := store.PruneChannelUpdateEvents(ctx, channelID, sent[2].Event.Pts, 100)
if err != nil {
t.Fatalf("prune remaining channel updates: %v", err)
}
if allPruned.Deleted != 1 || len(store.events[channelID]) != 0 {
t.Fatalf("remaining prune = %+v events=%v, want empty event log", allPruned, store.events[channelID])
}
dirty, err := store.ListDirtyActiveChannelsForUser(ctx, ownerID, sent[2].Event.Date-1, 0, 10)
if err != nil {
t.Fatalf("list dirty channels after prune: %v", err)
}
if len(dirty) != 1 || dirty[0].ChannelID != channelID || dirty[0].Pts != sent[2].Event.Pts {
t.Fatalf("dirty channels after prune = %+v, want channel %d pts %d", dirty, channelID, sent[2].Event.Pts)
}
}
func TestPruneChannelUpdateEventsRejectsInvalidPtsCountMemory(t *testing.T) {
ctx := context.Background()
store := NewChannelStore()
created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: 901,
Title: "invalid retention event",
Megagroup: true,
Date: 1_700_020_000,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
channelID := created.Channel.ID
channel := store.channels[channelID]
channel.Pts++
store.channels[channelID] = channel
store.ptsSeq[channelID] = channel.Pts
store.appendChannelEventLocked(domain.ChannelUpdateEvent{
ChannelID: channelID,
Type: domain.ChannelUpdateNoop,
Pts: channel.Pts,
PtsCount: 0,
Date: 1_700_020_001,
})
_, err = store.PruneChannelUpdateEvents(ctx, channelID, channel.Pts, 100)
if err == nil || !strings.Contains(err.Error(), "invalid pts_count=0") {
t.Fatalf("prune invalid event err = %v, want fail-fast pts_count error", err)
}
}
func TestDeleteExpiredChannelUpdateEventsIsBoundedMemory(t *testing.T) {
ctx := context.Background()
store := NewChannelStore()
created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: 801,
Title: "expired retention memory",
Megagroup: true,
Date: 1_600_000_000,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
for i := 1; i <= 3; i++ {
if _, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
UserID: 801, ChannelID: created.Channel.ID, RandomID: int64(8000 + i), Message: "old", Date: 1_600_000_000 + i,
}); err != nil {
t.Fatalf("send old message %d: %v", i, err)
}
}
deleted, err := store.DeleteExpiredChannelUpdateEvents(ctx, time.Hour, 2)
if err != nil {
t.Fatalf("delete expired channel updates: %v", err)
}
if deleted != 2 {
t.Fatalf("deleted = %d, want bounded batch 2", deleted)
}
checkpoint := store.retention[created.Channel.ID]
if checkpoint.RetainedThroughPts != 2 || len(store.events[created.Channel.ID]) != 2 {
t.Fatalf("after bounded prune checkpoint=%+v events=%d, want floor=2 and 2 rows", checkpoint, len(store.events[created.Channel.ID]))
}
}

View file

@ -2,7 +2,11 @@ package memory
import (
"context"
"fmt"
"sort"
"strings"
"time"
"telesrv/internal/domain"
)
@ -37,7 +41,8 @@ func (s *ChannelStore) ListChannelDifference(_ context.Context, req domain.Chann
Dialog: dialog,
}, nil
}
if channel.Pts-req.Pts > limit {
checkpoint := s.channelUpdateCheckpointLocked(req.ChannelID, channel)
if req.Pts < checkpoint.RetainedThroughPts || channel.Pts-req.Pts > limit {
messages := make([]domain.ChannelMessage, 0, domain.MaxChannelDifferenceTooLongMessages)
for i := len(s.messages[req.ChannelID]) - 1; i >= 0 && len(messages) < domain.MaxChannelDifferenceTooLongMessages; i-- {
msg := s.messages[req.ChannelID][i]
@ -122,6 +127,170 @@ func (s *ChannelStore) MaxChannelPts(_ context.Context, channelID int64) (int, e
return s.ptsSeq[channelID], nil
}
func (s *ChannelStore) MaxChannelPtsBatch(_ context.Context, channelIDs []int64) (map[int64]int, error) {
out := make(map[int64]int, len(channelIDs))
s.mu.RLock()
for _, channelID := range channelIDs {
if pts, ok := s.ptsSeq[channelID]; ok {
out[channelID] = pts
}
}
s.mu.RUnlock()
return out, nil
}
// appendChannelEventLocked is the only memory-store append boundary for channel-scoped durable
// events. Keeping the checkpoint current here mirrors the PostgreSQL event+checkpoint transaction.
func (s *ChannelStore) appendChannelEventLocked(event domain.ChannelUpdateEvent) {
s.events[event.ChannelID] = append(s.events[event.ChannelID], event)
checkpoint := s.retention[event.ChannelID]
checkpoint.ChannelID = event.ChannelID
if event.Pts > checkpoint.LatestPts {
checkpoint.LatestPts = event.Pts
}
if event.Date > checkpoint.LatestEventDate {
checkpoint.LatestEventDate = event.Date
}
s.retention[event.ChannelID] = checkpoint
}
func (s *ChannelStore) channelUpdateCheckpointLocked(channelID int64, channel domain.Channel) domain.ChannelUpdateRetentionCheckpoint {
checkpoint := s.retention[channelID]
checkpoint.ChannelID = channelID
if channel.Pts > checkpoint.LatestPts {
checkpoint.LatestPts = channel.Pts
}
for _, event := range s.events[channelID] {
if event.Pts > checkpoint.LatestPts {
checkpoint.LatestPts = event.Pts
}
if event.Date > checkpoint.LatestEventDate {
checkpoint.LatestEventDate = event.Date
}
}
return checkpoint
}
// PruneChannelUpdateEvents removes a bounded contiguous prefix and advances the retained floor in
// the same memory-store critical section. throughPts may land inside a pts_count interval; that row
// is retained because channel event rows are indivisible.
func (s *ChannelStore) PruneChannelUpdateEvents(_ context.Context, channelID int64, throughPts, limit int) (domain.ChannelUpdateRetentionResult, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.pruneChannelUpdateEventsLocked(channelID, throughPts, 0, limit)
}
// DeleteExpiredChannelUpdateEvents uses the oldest retained event of each channel as an indexed-seek
// analogue, then prunes candidates oldest-first. There is no offset scan and total deleted rows never
// exceeds limit.
func (s *ChannelStore) DeleteExpiredChannelUpdateEvents(_ context.Context, olderThan time.Duration, limit int) (int, error) {
if olderThan <= 0 {
return 0, nil
}
limit = normalizeChannelRetentionLimit(limit)
cutoff := int(time.Now().Add(-olderThan).Unix())
s.mu.Lock()
defer s.mu.Unlock()
type candidate struct {
channelID int64
date int
}
candidates := make([]candidate, 0)
for channelID, channel := range s.channels {
checkpoint := s.channelUpdateCheckpointLocked(channelID, channel)
for _, event := range s.events[channelID] {
if event.Pts <= checkpoint.RetainedThroughPts {
continue
}
if event.Date < cutoff {
candidates = append(candidates, candidate{channelID: channelID, date: event.Date})
}
break
}
}
sort.Slice(candidates, func(i, j int) bool {
if candidates[i].date == candidates[j].date {
return candidates[i].channelID < candidates[j].channelID
}
return candidates[i].date < candidates[j].date
})
deleted := 0
for _, item := range candidates {
if deleted >= limit {
break
}
channel := s.channels[item.channelID]
result, err := s.pruneChannelUpdateEventsLocked(item.channelID, channel.Pts, cutoff, limit-deleted)
if err != nil {
return deleted, err
}
deleted += result.Deleted
}
return deleted, nil
}
func (s *ChannelStore) pruneChannelUpdateEventsLocked(channelID int64, throughPts, beforeDate, limit int) (domain.ChannelUpdateRetentionResult, error) {
channel, ok := s.channels[channelID]
if !ok || channelID == 0 || throughPts < 0 {
return domain.ChannelUpdateRetentionResult{}, domain.ErrChannelInvalid
}
limit = normalizeChannelRetentionLimit(limit)
checkpoint := s.channelUpdateCheckpointLocked(channelID, channel)
if throughPts > checkpoint.LatestPts {
throughPts = checkpoint.LatestPts
}
if throughPts <= checkpoint.RetainedThroughPts {
s.retention[channelID] = checkpoint
return domain.ChannelUpdateRetentionResult{Checkpoint: checkpoint}, nil
}
cursor := checkpoint.RetainedThroughPts
deleted := 0
keep := make([]domain.ChannelUpdateEvent, 0, len(s.events[channelID]))
canPrune := true
for _, event := range s.events[channelID] {
if event.Pts <= checkpoint.RetainedThroughPts {
keep = append(keep, event)
continue
}
if !canPrune || deleted >= limit || event.Pts > throughPts || (beforeDate > 0 && event.Date >= beforeDate) {
canPrune = false
keep = append(keep, event)
continue
}
ptsCount := event.PtsCount
if ptsCount <= 0 {
return domain.ChannelUpdateRetentionResult{}, fmt.Errorf(
"prune channel update events: channel %d has invalid pts_count=%d at pts=%d",
channelID, ptsCount, event.Pts,
)
}
if event.Pts != cursor+ptsCount {
return domain.ChannelUpdateRetentionResult{}, fmt.Errorf(
"prune channel update events: channel %d has gap after pts %d: event pts=%d pts_count=%d",
channelID, cursor, event.Pts, ptsCount,
)
}
cursor = event.Pts
deleted++
}
if deleted > 0 {
s.events[channelID] = keep
checkpoint.RetainedThroughPts = cursor
}
s.retention[channelID] = checkpoint
return domain.ChannelUpdateRetentionResult{Checkpoint: checkpoint, Deleted: deleted}, nil
}
func normalizeChannelRetentionLimit(limit int) int {
if limit <= 0 || limit > domain.MaxChannelUpdateRetentionBatch {
return domain.MaxChannelUpdateRetentionBatch
}
return limit
}
func (s *ChannelStore) nextChannelPtsLocked(channelID int64) int {
s.ptsSeq[channelID]++
return s.ptsSeq[channelID]

View file

@ -0,0 +1,70 @@
package memory
import (
"context"
"telesrv/internal/store"
)
func (s *CodeStore) GetSnapshot(_ context.Context, hash string) (store.PhoneCodeSnapshot, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
entry, found := s.liveCodeLocked(hash)
if !found {
return store.PhoneCodeSnapshot{}, false, nil
}
if entry.code.Version != store.PhoneCodeVersionCurrent || entry.code.Revision == "" {
s.deleteCodeLocked(hash, entry.code)
return store.PhoneCodeSnapshot{}, false, nil
}
return store.PhoneCodeSnapshot{Record: entry.code, Revision: entry.code.Revision}, true, nil
}
func (s *CodeStore) CompareAndUpdate(_ context.Context, hash, expectedRevision string, next store.PhoneCode) (bool, error) {
if expectedRevision == "" || next.Version != store.PhoneCodeVersionCurrent || next.Purpose != "" {
return false, nil
}
revision, err := store.NewPhoneCodeRevisionToken()
if err != nil {
return false, err
}
next.Revision = revision
s.mu.Lock()
defer s.mu.Unlock()
entry, found := s.liveCodeLocked(hash)
if !found {
return false, nil
}
if entry.code.Version != store.PhoneCodeVersionCurrent || entry.code.Revision == "" {
s.deleteCodeLocked(hash, entry.code)
return false, nil
}
if entry.code.Purpose != "" || entry.code.Revision != expectedRevision {
return false, nil
}
entry.code = next
s.m[hash] = entry
return true, nil
}
func (s *CodeStore) CompareAndDelete(_ context.Context, hash, expectedRevision string) (bool, error) {
if expectedRevision == "" {
return false, nil
}
s.mu.Lock()
defer s.mu.Unlock()
entry, found := s.liveCodeLocked(hash)
if !found {
return false, nil
}
if entry.code.Version != store.PhoneCodeVersionCurrent || entry.code.Revision == "" {
s.deleteCodeLocked(hash, entry.code)
return false, nil
}
if entry.code.Purpose != "" || entry.code.Revision != expectedRevision {
return false, nil
}
s.deleteCodeLocked(hash, entry.code)
return true, nil
}

View file

@ -0,0 +1,213 @@
package memory
import (
"context"
"fmt"
"sync"
"testing"
"time"
"telesrv/internal/store"
)
func TestCodeStoreRevisionCAS(t *testing.T) {
ctx := context.Background()
codes := NewCodeStore()
record := store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
Phone: "15550016201",
Code: "111111",
Channel: "email_setup",
PendingEmail: "first@example.test",
MaxAttempts: 5,
}
if err := codes.Set(ctx, "email-fixed", record, time.Minute); err != nil {
t.Fatal(err)
}
snapshot, found, err := codes.GetSnapshot(ctx, "email-fixed")
if err != nil || !found || snapshot.Revision == "" || snapshot.Record.Revision != snapshot.Revision {
t.Fatalf("snapshot=%+v found=%v err=%v", snapshot, found, err)
}
originalExpiry := codes.m["email-fixed"].expires
next := snapshot.Record
next.Code = "222222"
next.Attempts = 1
if applied, err := codes.CompareAndUpdate(ctx, "email-fixed", "stale-token", next); err != nil || applied {
t.Fatalf("wrong-token update applied=%v err=%v", applied, err)
}
unchanged, found, err := codes.GetSnapshot(ctx, "email-fixed")
if err != nil || !found || unchanged.Revision != snapshot.Revision || unchanged.Record.Code != record.Code {
t.Fatalf("after wrong-token snapshot=%+v found=%v err=%v", unchanged, found, err)
}
if applied, err := codes.CompareAndUpdate(ctx, "email-fixed", snapshot.Revision, next); err != nil || !applied {
t.Fatalf("current-token update applied=%v err=%v", applied, err)
}
updated, found, err := codes.GetSnapshot(ctx, "email-fixed")
if err != nil || !found || updated.Record.Code != next.Code || updated.Record.Attempts != 1 || updated.Revision == snapshot.Revision {
t.Fatalf("updated snapshot=%+v found=%v err=%v", updated, found, err)
}
if expiry := codes.m["email-fixed"].expires; !expiry.Equal(originalExpiry) {
t.Fatalf("CAS update expiry=%v, want unchanged %v", expiry, originalExpiry)
}
if applied, err := codes.CompareAndDelete(ctx, "email-fixed", snapshot.Revision); err != nil || applied {
t.Fatalf("stale delete applied=%v err=%v", applied, err)
}
if applied, err := codes.CompareAndDelete(ctx, "email-fixed", updated.Revision); err != nil || !applied {
t.Fatalf("current delete applied=%v err=%v", applied, err)
}
if _, found, _ := codes.GetSnapshot(ctx, "email-fixed"); found {
t.Fatal("CAS-deleted code remains")
}
}
func TestCodeStoreRevisionCASFailClosedAndScopeIsolation(t *testing.T) {
ctx := context.Background()
codes := NewCodeStore()
now := time.Now().Add(time.Minute)
codes.m["legacy"] = codeEntry{
code: store.PhoneCode{Version: 0, Phone: "15550016202", Code: "12345"},
expires: now,
}
if _, found, err := codes.GetSnapshot(ctx, "legacy"); err != nil || found {
t.Fatalf("legacy snapshot found=%v err=%v", found, err)
}
if _, found := codes.m["legacy"]; found {
t.Fatal("legacy snapshot record was not deleted")
}
codes.m["no-revision"] = codeEntry{
code: store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
Phone: "15550016202",
Code: "12345",
},
expires: now,
}
if _, found, err := codes.GetSnapshot(ctx, "no-revision"); err != nil || found {
t.Fatalf("revisionless snapshot found=%v err=%v", found, err)
}
if _, found := codes.m["no-revision"]; found {
t.Fatal("revisionless snapshot record was not deleted")
}
scoped := store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
Phone: "15550016203",
Code: "12345",
Purpose: store.PhoneCodePurposeChangePhone,
UserID: 42,
AuthKeyID: [8]byte{1},
}
if err := codes.Set(ctx, "scoped-cas", scoped, time.Minute); err != nil {
t.Fatal(err)
}
snapshot, found, err := codes.GetSnapshot(ctx, "scoped-cas")
if err != nil || !found {
t.Fatalf("scoped snapshot found=%v err=%v", found, err)
}
if applied, err := codes.CompareAndUpdate(ctx, "scoped-cas", snapshot.Revision, snapshot.Record); err != nil || applied {
t.Fatalf("scoped update applied=%v err=%v", applied, err)
}
if applied, err := codes.CompareAndDelete(ctx, "scoped-cas", snapshot.Revision); err != nil || applied {
t.Fatalf("scoped delete applied=%v err=%v", applied, err)
}
if _, found, _ := codes.Get(ctx, "scoped-cas"); !found {
t.Fatal("generic CAS mutated scoped code")
}
}
func TestCodeStoreRevisionCASPreventsABAAndHasSingleWinner(t *testing.T) {
ctx := context.Background()
codes := NewCodeStore()
record := store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
Phone: "15550016204",
Code: "123456",
Channel: "email_change",
}
if err := codes.Set(ctx, "aba", record, time.Minute); err != nil {
t.Fatal(err)
}
old, found, err := codes.GetSnapshot(ctx, "aba")
if err != nil || !found {
t.Fatalf("old snapshot found=%v err=%v", found, err)
}
if err := codes.Set(ctx, "aba", record, time.Minute); err != nil {
t.Fatal(err)
}
current, found, err := codes.GetSnapshot(ctx, "aba")
if err != nil || !found || current.Revision == old.Revision {
t.Fatalf("replacement snapshot=%+v old=%+v found=%v err=%v", current, old, found, err)
}
if applied, err := codes.CompareAndDelete(ctx, "aba", old.Revision); err != nil || applied {
t.Fatalf("ABA stale delete applied=%v err=%v", applied, err)
}
const workers = 64
results := make(chan bool, workers)
errs := make(chan error, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func(index int) {
defer wg.Done()
next := current.Record
next.Code = fmt.Sprintf("%06d", index)
applied, err := codes.CompareAndUpdate(ctx, "aba", current.Revision, next)
if err != nil {
errs <- err
return
}
results <- applied
}(i)
}
wg.Wait()
close(results)
close(errs)
for err := range errs {
t.Fatalf("concurrent CAS update: %v", err)
}
winners := 0
for applied := range results {
if applied {
winners++
}
}
if winners != 1 {
t.Fatalf("concurrent CAS update winners=%d, want 1", winners)
}
winner, found, err := codes.GetSnapshot(ctx, "aba")
if err != nil || !found || winner.Revision == current.Revision {
t.Fatalf("winner snapshot=%+v found=%v err=%v", winner, found, err)
}
results = make(chan bool, workers)
errs = make(chan error, workers)
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
applied, err := codes.CompareAndDelete(ctx, "aba", winner.Revision)
if err != nil {
errs <- err
return
}
results <- applied
}()
}
wg.Wait()
close(results)
close(errs)
for err := range errs {
t.Fatalf("concurrent CAS delete: %v", err)
}
winners = 0
for applied := range results {
if applied {
winners++
}
}
if winners != 1 {
t.Fatalf("concurrent CAS delete winners=%d, want 1", winners)
}
}

View file

@ -13,6 +13,7 @@ func TestCodeStoreScopedRotationAndSingleConsume(t *testing.T) {
ctx := context.Background()
codes := NewCodeStore()
rec := store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
Phone: "15550015001",
Code: "12345",
Purpose: store.PhoneCodePurposeChangePhone,
@ -68,7 +69,7 @@ func TestCodeStoreScopedRotationAndSingleConsume(t *testing.T) {
func TestCodeStoreScopedIsolation(t *testing.T) {
ctx := context.Background()
codes := NewCodeStore()
a := store.PhoneCode{Phone: "15550015002", Code: "12345", Purpose: store.PhoneCodePurposeChangePhone, UserID: 42, AuthKeyID: [8]byte{1}}
a := store.PhoneCode{Version: store.PhoneCodeVersionCurrent, Phone: "15550015002", Code: "12345", Purpose: store.PhoneCodePurposeChangePhone, UserID: 42, AuthKeyID: [8]byte{1}}
b := a
b.AuthKeyID = [8]byte{2}
if err := codes.Set(ctx, "hash-a", a, time.Minute); err != nil {
@ -90,3 +91,24 @@ func TestCodeStoreScopedIsolation(t *testing.T) {
t.Fatal("other scope was removed")
}
}
func TestCodeStoreConsumeScopedRejectsAndDeletesLegacyVersion(t *testing.T) {
ctx := context.Background()
codes := NewCodeStore()
rec := store.PhoneCode{
Version: 0, Phone: "15550015003", Code: "12345",
Purpose: store.PhoneCodePurposeChangePhone, UserID: 43, AuthKeyID: [8]byte{3},
}
if err := codes.Set(ctx, "legacy-scope", rec, time.Minute); err != nil {
t.Fatal(err)
}
if _, found, err := codes.ConsumeScoped(ctx, "legacy-scope", rec.Scope()); err != nil || found {
t.Fatalf("legacy scoped consume found=%v err=%v, want false/nil", found, err)
}
if _, found, _ := codes.Get(ctx, "legacy-scope"); found {
t.Fatal("legacy scoped code remains after fail-closed consume")
}
if _, ok := codes.scopes[rec.Scope()]; ok {
t.Fatal("legacy scoped index remains after fail-closed consume")
}
}

View file

@ -0,0 +1,211 @@
package memory
import (
"context"
"crypto/subtle"
"time"
"telesrv/internal/store"
)
func (s *CodeStore) VerifyLogin(_ context.Context, hash, phone, code string, keepForSignUp bool, defaultMaxAttempts int) (store.LoginCodeVerifyResult, error) {
s.mu.Lock()
defer s.mu.Unlock()
entry, ok := s.liveCodeLocked(hash)
if !ok {
return store.LoginCodeVerifyResult{Status: store.LoginCodeVerifyMissing}, nil
}
record := entry.code
if record.Version != store.PhoneCodeVersionCurrent {
s.deleteCodeLocked(hash, record)
return store.LoginCodeVerifyResult{Status: store.LoginCodeVerifyMissing}, nil
}
// Sign-up verification is a terminal state for VerifyLogin. Keep the marker
// for the one caller that already received signUpRequired, but do not report
// a second Accepted result or let later wrong-code calls exhaust it.
if record.SignUpVerified {
return store.LoginCodeVerifyResult{Status: store.LoginCodeVerifyMissing}, nil
}
if record.Purpose != "" || record.Phone != phone || !loginCodeVerifiable(record) || record.Code == "" || code == "" {
return store.LoginCodeVerifyResult{Status: store.LoginCodeVerifyInvalid, Record: record}, nil
}
if subtle.ConstantTimeCompare([]byte(record.Code), []byte(code)) != 1 {
revision, err := store.NewPhoneCodeRevisionToken()
if err != nil {
return store.LoginCodeVerifyResult{}, err
}
record.Attempts++
record.Revision = revision
entry.code = record
maxAttempts := record.MaxAttempts
if maxAttempts <= 0 {
maxAttempts = defaultMaxAttempts
}
if maxAttempts <= 0 {
maxAttempts = 1
}
if record.Attempts >= maxAttempts {
s.deleteCodeLocked(hash, record)
} else {
s.m[hash] = entry
}
return store.LoginCodeVerifyResult{Status: store.LoginCodeVerifyInvalid, Record: record}, nil
}
if keepForSignUp {
if record.IssuedUserID != 0 {
return store.LoginCodeVerifyResult{Status: store.LoginCodeVerifyInvalid, Record: record}, nil
}
revision, err := store.NewPhoneCodeRevisionToken()
if err != nil {
return store.LoginCodeVerifyResult{}, err
}
record.SignUpVerified = true
record.Revision = revision
entry.code = record
s.m[hash] = entry
} else {
s.deleteCodeLocked(hash, record)
}
return store.LoginCodeVerifyResult{Status: store.LoginCodeVerifyAccepted, Record: record}, nil
}
func (s *CodeStore) VerifyScoped(_ context.Context, hash string, scope store.PhoneCodeScope, code string, defaultMaxAttempts int) (store.LoginCodeVerifyResult, error) {
s.mu.Lock()
defer s.mu.Unlock()
if !scope.Valid() || s.scopes[scope] != hash {
return store.LoginCodeVerifyResult{Status: store.LoginCodeVerifyMissing}, nil
}
entry, ok := s.liveCodeLocked(hash)
if !ok {
// liveCodeLocked removes the index when it can decode the stored scope;
// also close the stale-index-only case.
if s.scopes[scope] == hash {
delete(s.scopes, scope)
}
return store.LoginCodeVerifyResult{Status: store.LoginCodeVerifyMissing}, nil
}
record := entry.code
if record.Version != store.PhoneCodeVersionCurrent || record.Scope() != scope || record.SignUpVerified || record.Code == "" {
// Fail closed on a legacy or internally inconsistent record. Clean both
// the scope encoded in the record and the scope that selected this hash.
s.deleteCodeLocked(hash, record)
if s.scopes[scope] == hash {
delete(s.scopes, scope)
}
return store.LoginCodeVerifyResult{Status: store.LoginCodeVerifyMissing}, nil
}
if code == "" {
return store.LoginCodeVerifyResult{Status: store.LoginCodeVerifyInvalid, Record: record}, nil
}
if subtle.ConstantTimeCompare([]byte(record.Code), []byte(code)) != 1 {
revision, err := store.NewPhoneCodeRevisionToken()
if err != nil {
return store.LoginCodeVerifyResult{}, err
}
record.Attempts++
record.Revision = revision
entry.code = record
maxAttempts := record.MaxAttempts
if maxAttempts <= 0 {
maxAttempts = defaultMaxAttempts
}
if maxAttempts <= 0 {
maxAttempts = 1
}
if record.Attempts >= maxAttempts {
s.deleteCodeLocked(hash, record)
} else {
s.m[hash] = entry
}
return store.LoginCodeVerifyResult{Status: store.LoginCodeVerifyInvalid, Record: record}, nil
}
s.deleteCodeLocked(hash, record)
return store.LoginCodeVerifyResult{Status: store.LoginCodeVerifyAccepted, Record: record}, nil
}
func (s *CodeStore) ConsumeSignUpVerified(_ context.Context, hash, phone string) (store.PhoneCode, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
entry, ok := s.liveCodeLocked(hash)
if !ok {
return store.PhoneCode{}, false, nil
}
record := entry.code
if record.Version != store.PhoneCodeVersionCurrent {
s.deleteCodeLocked(hash, record)
return store.PhoneCode{}, false, nil
}
if record.Purpose != "" || record.Phone != phone || !loginCodeVerifiable(record) || record.IssuedUserID != 0 || !record.SignUpVerified {
return store.PhoneCode{}, false, nil
}
s.deleteCodeLocked(hash, record)
return record, true, nil
}
func (s *CodeStore) TakeLoginCode(_ context.Context, hash, phone string) (store.PhoneCode, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
entry, ok := s.liveCodeLocked(hash)
if !ok {
return store.PhoneCode{}, false, nil
}
record := entry.code
if record.Version != store.PhoneCodeVersionCurrent {
s.deleteCodeLocked(hash, record)
return store.PhoneCode{}, false, nil
}
if record.SignUpVerified {
return store.PhoneCode{}, false, nil
}
if record.Purpose != "" || record.Phone != phone || !loginCodeTakeable(record) {
return store.PhoneCode{}, false, nil
}
s.deleteCodeLocked(hash, record)
return record, true, nil
}
func (s *CodeStore) InvalidateLoginCode(_ context.Context, hash, phone string) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
entry, ok := s.liveCodeLocked(hash)
if !ok {
return false, nil
}
record := entry.code
if record.Version != store.PhoneCodeVersionCurrent {
s.deleteCodeLocked(hash, record)
return false, nil
}
if record.Purpose != "" || record.Phone != phone || !loginCodeTakeable(record) {
return false, nil
}
s.deleteCodeLocked(hash, record)
return true, nil
}
func loginCodeVerifiable(record store.PhoneCode) bool {
return record.Channel == store.PhoneCodeChannelPhone || record.Channel == store.PhoneCodeChannelEmailLogin
}
func loginCodeTakeable(record store.PhoneCode) bool {
return loginCodeVerifiable(record) || record.Channel == store.PhoneCodeChannelEmailSetupRequired
}
func (s *CodeStore) liveCodeLocked(hash string) (codeEntry, bool) {
entry, ok := s.m[hash]
if !ok {
return codeEntry{}, false
}
if time.Now().After(entry.expires) {
s.deleteCodeLocked(hash, entry.code)
return codeEntry{}, false
}
return entry, true
}

View file

@ -0,0 +1,129 @@
package memory
import (
"context"
"fmt"
"time"
"telesrv/internal/domain"
"telesrv/internal/store"
)
type loginCodeDeliveryRecord struct {
userID int64
codeFingerprint [32]byte
privateMessageID int64
messageBoxID int
pts int
messageDate int
}
// LoginCodeDeliveryStore composes the message projection with the same
// durable update-event store consumed by updates.getDifference. Keeping this
// as an explicit dependency prevents tests (and future in-memory runtimes)
// from accidentally creating a visible message without its pts event.
type LoginCodeDeliveryStore struct {
messages *MessageStore
events *UpdateEventStore
}
func NewLoginCodeDeliveryStore(messages *MessageStore, events *UpdateEventStore) *LoginCodeDeliveryStore {
return &LoginCodeDeliveryStore{messages: messages, events: events}
}
// DeliverLoginCodeMessage is the in-memory equivalent of the PostgreSQL
// transaction. Message, dialog, pts, durable event and immutable receipt are
// published under the two backing stores' locks; a repeated phone_code_hash
// returns the first snapshot.
func (s *LoginCodeDeliveryStore) DeliverLoginCodeMessage(_ context.Context, req domain.LoginCodeDeliveryRequest) (domain.LoginCodeDeliveryResult, error) {
if s == nil || s.messages == nil || s.events == nil {
return domain.LoginCodeDeliveryResult{}, fmt.Errorf("memory login code delivery: %w: message and update stores are required", domain.ErrLoginCodeDeliveryInvalid)
}
deliveryKey, err := store.LoginCodeDeliveryKey(req.PhoneCodeHash)
if err != nil {
return domain.LoginCodeDeliveryResult{}, err
}
codeFingerprint, err := store.LoginCodeFingerprint(req.PhoneCodeHash, req.Code)
if err != nil {
return domain.LoginCodeDeliveryResult{}, err
}
if req.Date == 0 {
req.Date = int(time.Now().Unix())
}
if req.ExpiresAt <= int64(req.Date) {
return domain.LoginCodeDeliveryResult{}, fmt.Errorf("memory login code receipt expiry: %w: date=%d expires_at=%d", domain.ErrLoginCodeDeliveryInvalid, req.Date, req.ExpiresAt)
}
base, err := domain.OfficialLoginCodeMessage(req.UserID, req.Code, req.Date)
if err != nil {
return domain.LoginCodeDeliveryResult{}, err
}
// Lock ordering is local and fixed: MessageStore -> UpdateEventStore ->
// DialogStore. No other memory operation takes the first two together.
s.messages.mu.Lock()
defer s.messages.mu.Unlock()
s.events.mu.Lock()
defer s.events.mu.Unlock()
if receipt, ok := s.messages.loginCodeDeliveries[deliveryKey]; ok {
if receipt.userID != req.UserID || !store.SameLoginCodeFingerprint(receipt.codeFingerprint[:], codeFingerprint) {
return domain.LoginCodeDeliveryResult{}, fmt.Errorf("memory login code delivery: %w", domain.ErrLoginCodeDeliveryConflict)
}
msg, err := store.RestoreLoginCodeDeliveryMessage(
receipt.userID,
req.Code,
receipt.messageDate,
receipt.privateMessageID,
receipt.messageBoxID,
receipt.pts,
)
if err != nil {
return domain.LoginCodeDeliveryResult{}, fmt.Errorf("memory login code delivery replay: %w", err)
}
return domain.LoginCodeDeliveryResult{Message: msg, Created: false}, nil
}
currentEventPts := 0
for _, event := range s.events.events[req.UserID] {
if event.Pts > currentEventPts {
currentEventPts = event.Pts
}
}
if messagePts := s.messages.nextPts[req.UserID]; messagePts > currentEventPts {
return domain.LoginCodeDeliveryResult{}, fmt.Errorf("memory login code delivery: %w: message pts %d exceeds durable event pts %d", domain.ErrLoginCodeDeliveryInvalid, messagePts, currentEventPts)
}
base.ID = s.messages.nextBoxIDLocked(req.UserID)
base.UID = s.messages.nextUID
s.messages.nextUID++
base.Pts = currentEventPts + 1
s.messages.nextPts[req.UserID] = base.Pts
s.messages.m[req.UserID] = append(s.messages.m[req.UserID], cloneMessage(base))
if s.messages.dialogs != nil {
s.messages.dialogs.mu.Lock()
list := s.messages.dialogs.m[req.UserID]
list = upsertMemoryDialog(list, domain.Dialog{
Peer: base.Peer,
TopMessage: base.ID,
TopMessageDate: base.Date,
UnreadCount: s.messages.privateUnreadCountLocked(req.UserID, base.Peer),
})
if !hasUser(list.Users, domain.OfficialSystemUserID) {
list.Users = append(list.Users, domain.OfficialSystemUser())
}
list.Messages = append(list.Messages, cloneMessage(base))
s.messages.dialogs.m[req.UserID] = list
s.messages.dialogs.mu.Unlock()
}
event := newMessageEvent(base)
s.events.events[req.UserID] = append(s.events.events[req.UserID], cloneUpdateEvent(event))
s.messages.loginCodeDeliveries[deliveryKey] = loginCodeDeliveryRecord{
userID: req.UserID,
codeFingerprint: codeFingerprint,
privateMessageID: base.UID,
messageBoxID: base.ID,
pts: base.Pts,
messageDate: base.Date,
}
return domain.LoginCodeDeliveryResult{Message: cloneMessage(base), Created: true}, nil
}

View file

@ -0,0 +1,165 @@
package memory
import (
"context"
"errors"
"reflect"
"sync"
"sync/atomic"
"testing"
"telesrv/internal/domain"
)
func TestLoginCodeDeliveryStoreCommitsMessageEventDialogAndReplay(t *testing.T) {
ctx := context.Background()
const userID int64 = 1000000001
dialogs := NewDialogStore()
messages := NewMessageStore(dialogs)
events := NewUpdateEventStore()
deliveries := NewLoginCodeDeliveryStore(messages, events)
req := domain.LoginCodeDeliveryRequest{
UserID: userID,
PhoneCodeHash: "phone-code-hash-one",
Code: "12345",
Date: 1700000000,
ExpiresAt: 1700000300,
}
first, err := deliveries.DeliverLoginCodeMessage(ctx, req)
if err != nil {
t.Fatalf("DeliverLoginCodeMessage: %v", err)
}
if !first.Created || first.Message.ID != 1 || first.Message.UID != 1 || first.Message.Pts != 1 || first.Message.Out ||
first.Message.OwnerUserID != userID || first.Message.Peer.ID != domain.OfficialSystemUserID || first.Message.From.ID != domain.OfficialSystemUserID {
t.Fatalf("first delivery = %+v, want first incoming 777000 message", first)
}
if len(messages.m[userID]) != 1 || !reflect.DeepEqual(messages.m[userID][0], first.Message) {
t.Fatalf("message projection = %+v, want committed message", messages.m[userID])
}
if len(events.events[userID]) != 1 {
t.Fatalf("durable events = %+v, want one new_message", events.events[userID])
}
event := events.events[userID][0]
if event.Type != domain.UpdateEventNewMessage || event.Pts != first.Message.Pts || event.PtsCount != 1 || !reflect.DeepEqual(event.Message, first.Message) {
t.Fatalf("event = %+v, want message-identical new_message", event)
}
list := dialogs.m[userID]
if len(list.Dialogs) != 1 || list.Dialogs[0].Peer.ID != domain.OfficialSystemUserID || list.Dialogs[0].TopMessage != first.Message.ID || list.Dialogs[0].UnreadCount != 1 {
t.Fatalf("dialog projection = %+v, want unread 777000 dialog", list.Dialogs)
}
if len(list.Users) != 1 || list.Users[0].ID != domain.OfficialSystemUserID {
t.Fatalf("dialog users = %+v, want official system user", list.Users)
}
replayReq := req
replayReq.Date++
replay, err := deliveries.DeliverLoginCodeMessage(ctx, replayReq)
if err != nil {
t.Fatalf("replay DeliverLoginCodeMessage: %v", err)
}
if replay.Created || !reflect.DeepEqual(replay.Message, first.Message) {
t.Fatalf("replay = %+v, want immutable first result %+v", replay, first)
}
if len(messages.m[userID]) != 1 || len(events.events[userID]) != 1 || len(messages.loginCodeDeliveries) != 1 {
t.Fatalf("replay created facts: messages=%d events=%d receipts=%d", len(messages.m[userID]), len(events.events[userID]), len(messages.loginCodeDeliveries))
}
second, err := deliveries.DeliverLoginCodeMessage(ctx, domain.LoginCodeDeliveryRequest{
UserID: userID,
PhoneCodeHash: "phone-code-hash-two",
Code: "67890",
Date: 1700000010,
ExpiresAt: 1700000310,
})
if err != nil {
t.Fatalf("second distinct delivery: %v", err)
}
if !second.Created || second.Message.ID != 2 || second.Message.UID != 2 || second.Message.Pts != 2 || len(events.events[userID]) != 2 {
t.Fatalf("second delivery = %+v events=%+v, want contiguous allocations", second, events.events[userID])
}
if got := dialogs.m[userID].Dialogs[0].UnreadCount; got != 2 {
t.Fatalf("dialog unread = %d, want 2", got)
}
}
func TestLoginCodeDeliveryStoreConcurrentReplayAndConflict(t *testing.T) {
ctx := context.Background()
const userID int64 = 1000000002
messages := NewMessageStore(NewDialogStore())
events := NewUpdateEventStore()
deliveries := NewLoginCodeDeliveryStore(messages, events)
req := domain.LoginCodeDeliveryRequest{
UserID: userID,
PhoneCodeHash: "concurrent-phone-code-hash",
Code: "24680",
Date: 1700000100,
ExpiresAt: 1700000400,
}
const workers = 32
var created atomic.Int32
results := make(chan domain.LoginCodeDeliveryResult, workers)
errs := make(chan error, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
got, err := deliveries.DeliverLoginCodeMessage(ctx, req)
if err != nil {
errs <- err
return
}
if got.Created {
created.Add(1)
}
results <- got
}()
}
wg.Wait()
close(errs)
close(results)
for err := range errs {
t.Fatalf("concurrent delivery: %v", err)
}
if created.Load() != 1 {
t.Fatalf("created calls = %d, want exactly 1", created.Load())
}
for got := range results {
if got.Message.ID != 1 || got.Message.UID != 1 || got.Message.Pts != 1 {
t.Fatalf("concurrent result = %+v, want the same first allocation", got)
}
}
if len(messages.m[userID]) != 1 || len(events.events[userID]) != 1 || len(messages.loginCodeDeliveries) != 1 {
t.Fatalf("concurrent facts: messages=%d events=%d receipts=%d", len(messages.m[userID]), len(events.events[userID]), len(messages.loginCodeDeliveries))
}
changedCode := req
changedCode.Code = "13579"
if _, err := deliveries.DeliverLoginCodeMessage(ctx, changedCode); !errors.Is(err, domain.ErrLoginCodeDeliveryConflict) {
t.Fatalf("changed-code replay err = %v, want ErrLoginCodeDeliveryConflict", err)
}
changedUser := req
changedUser.UserID++
if _, err := deliveries.DeliverLoginCodeMessage(ctx, changedUser); !errors.Is(err, domain.ErrLoginCodeDeliveryConflict) {
t.Fatalf("changed-user replay err = %v, want ErrLoginCodeDeliveryConflict", err)
}
if len(messages.m[userID]) != 1 || len(events.events[userID]) != 1 {
t.Fatal("conflicting replay changed committed facts")
}
}
func TestLoginCodeDeliveryStoreRequiresSharedEventStore(t *testing.T) {
messages := NewMessageStore()
_, err := NewLoginCodeDeliveryStore(messages, nil).DeliverLoginCodeMessage(context.Background(), domain.LoginCodeDeliveryRequest{
UserID: 1000000003,
PhoneCodeHash: "missing-event-store",
Code: "12345",
Date: 1700000200,
ExpiresAt: 1700000500,
})
if !errors.Is(err, domain.ErrLoginCodeDeliveryInvalid) {
t.Fatalf("missing event store err = %v, want ErrLoginCodeDeliveryInvalid", err)
}
}

View file

@ -0,0 +1,106 @@
package memory
import (
"context"
"sync"
"testing"
"time"
"telesrv/internal/store"
)
func TestCodeStoreAtomicLoginInvalidation(t *testing.T) {
ctx := context.Background()
const phone = "15550016011"
newRecord := func() store.PhoneCode {
return store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
Phone: phone,
Code: "12345",
Channel: store.PhoneCodeChannelPhone,
MaxAttempts: 5,
}
}
t.Run("owner cleanup may delete a terminal sign-up marker", func(t *testing.T) {
codes := NewCodeStore()
if err := codes.Set(ctx, "invalidate-marker", newRecord(), time.Minute); err != nil {
t.Fatal(err)
}
verified, err := codes.VerifyLogin(ctx, "invalidate-marker", phone, "12345", true, 5)
if err != nil || verified.Status != store.LoginCodeVerifyAccepted || !verified.Record.SignUpVerified {
t.Fatalf("mark sign-up = %+v err=%v", verified, err)
}
if removed, err := codes.InvalidateLoginCode(ctx, "invalidate-marker", "15550016999"); err != nil || removed {
t.Fatalf("cross-phone invalidate removed=%v err=%v", removed, err)
}
if _, found, err := codes.ConsumeSignUpVerified(ctx, "invalidate-marker", "15550016999"); err != nil || found {
t.Fatalf("cross-phone consume found=%v err=%v", found, err)
}
if removed, err := codes.InvalidateLoginCode(ctx, "invalidate-marker", phone); err != nil || !removed {
t.Fatalf("owner invalidate removed=%v err=%v", removed, err)
}
if _, found, err := codes.ConsumeSignUpVerified(ctx, "invalidate-marker", phone); err != nil || found {
t.Fatalf("consume after invalidate found=%v err=%v", found, err)
}
})
t.Run("legacy records fail closed", func(t *testing.T) {
codes := NewCodeStore()
legacy := newRecord()
legacy.Version = 0
if err := codes.Set(ctx, "invalidate-legacy", legacy, time.Minute); err != nil {
t.Fatal(err)
}
if removed, err := codes.InvalidateLoginCode(ctx, "invalidate-legacy", phone); err != nil || removed {
t.Fatalf("legacy invalidate removed=%v err=%v, want false", removed, err)
}
if _, found, err := codes.Get(ctx, "invalidate-legacy"); err != nil || found {
t.Fatalf("legacy record found=%v err=%v after fail-closed invalidate", found, err)
}
})
t.Run("invalidate and sign-up consume have one winner", func(t *testing.T) {
codes := NewCodeStore()
if err := codes.Set(ctx, "invalidate-race", newRecord(), time.Minute); err != nil {
t.Fatal(err)
}
if verified, err := codes.VerifyLogin(ctx, "invalidate-race", phone, "12345", true, 5); err != nil || verified.Status != store.LoginCodeVerifyAccepted {
t.Fatalf("mark sign-up = %+v err=%v", verified, err)
}
const workers = 64
results := make(chan bool, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func(invalidate bool) {
defer wg.Done()
if invalidate {
removed, err := codes.InvalidateLoginCode(ctx, "invalidate-race", phone)
if err != nil {
t.Errorf("InvalidateLoginCode: %v", err)
}
results <- removed
return
}
_, found, err := codes.ConsumeSignUpVerified(ctx, "invalidate-race", phone)
if err != nil {
t.Errorf("ConsumeSignUpVerified: %v", err)
}
results <- found
}(i%2 == 0)
}
wg.Wait()
close(results)
winners := 0
for won := range results {
if won {
winners++
}
}
if winners != 1 {
t.Fatalf("invalidate/consume winners=%d, want 1", winners)
}
})
}

View file

@ -0,0 +1,447 @@
package memory
import (
"context"
"sync"
"testing"
"time"
"telesrv/internal/store"
)
func TestCodeStoreAtomicLoginStateMachine(t *testing.T) {
ctx := context.Background()
const phone = "15550016001"
newRecord := func() store.PhoneCode {
return store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
IssuedUserID: 1000000001,
Phone: phone,
Code: "12345",
Channel: "phone",
MaxAttempts: 2,
}
}
t.Run("version mismatch fails closed for every atomic entry", func(t *testing.T) {
codes := NewCodeStore()
legacy := newRecord()
legacy.Version = 0
if err := codes.Set(ctx, "legacy-verify", legacy, time.Minute); err != nil {
t.Fatal(err)
}
result, err := codes.VerifyLogin(ctx, "legacy-verify", phone, legacy.Code, false, 5)
if err != nil || result.Status != store.LoginCodeVerifyMissing {
t.Fatalf("legacy VerifyLogin = %+v err=%v, want Missing", result, err)
}
if _, found, _ := codes.Get(ctx, "legacy-verify"); found {
t.Fatal("legacy VerifyLogin record was not deleted")
}
unknown := newRecord()
unknown.Version = store.PhoneCodeVersionCurrent + 1
if err := codes.Set(ctx, "unknown-take", unknown, time.Minute); err != nil {
t.Fatal(err)
}
if _, found, err := codes.TakeLoginCode(ctx, "unknown-take", phone); err != nil || found {
t.Fatalf("unknown TakeLoginCode found=%v err=%v, want false", found, err)
}
if _, found, _ := codes.Get(ctx, "unknown-take"); found {
t.Fatal("unknown TakeLoginCode record was not deleted")
}
legacy.SignUpVerified = true
if err := codes.Set(ctx, "legacy-signup", legacy, time.Minute); err != nil {
t.Fatal(err)
}
if _, found, err := codes.ConsumeSignUpVerified(ctx, "legacy-signup", phone); err != nil || found {
t.Fatalf("legacy ConsumeSignUpVerified found=%v err=%v, want false", found, err)
}
if _, found, _ := codes.Get(ctx, "legacy-signup"); found {
t.Fatal("legacy sign-up marker was not deleted")
}
})
t.Run("scope mismatch does not burn victim attempts", func(t *testing.T) {
codes := NewCodeStore()
record := newRecord()
if err := codes.Set(ctx, "scope", record, time.Minute); err != nil {
t.Fatal(err)
}
result, err := codes.VerifyLogin(ctx, "scope", "15550016999", record.Code, false, 5)
if err != nil || result.Status != store.LoginCodeVerifyInvalid || result.Record.Attempts != 0 {
t.Fatalf("wrong-phone VerifyLogin = %+v err=%v", result, err)
}
stored, found, err := codes.Get(ctx, "scope")
if err != nil || !found || stored.Attempts != 0 {
t.Fatalf("wrong-phone stored=%+v found=%v err=%v", stored, found, err)
}
if _, found, err := codes.TakeLoginCode(ctx, "scope", "15550016999"); err != nil || found {
t.Fatalf("cross-phone TakeLoginCode found=%v err=%v", found, err)
}
scoped := newRecord()
scoped.Purpose = store.PhoneCodePurposeChangePhone
scoped.UserID = 42
scoped.AuthKeyID = [8]byte{1}
if err := codes.Set(ctx, "scoped", scoped, time.Minute); err != nil {
t.Fatal(err)
}
result, err = codes.VerifyLogin(ctx, "scoped", phone, scoped.Code, false, 5)
if err != nil || result.Status != store.LoginCodeVerifyInvalid {
t.Fatalf("scoped VerifyLogin = %+v err=%v, want Invalid", result, err)
}
if _, found, err := codes.TakeLoginCode(ctx, "scoped", phone); err != nil || found {
t.Fatalf("scoped TakeLoginCode found=%v err=%v", found, err)
}
if _, found, _ := codes.Get(ctx, "scoped"); !found {
t.Fatal("login operations deleted a scoped change-phone code")
}
})
t.Run("wrong code increments atomically and threshold deletes", func(t *testing.T) {
codes := NewCodeStore()
record := newRecord()
if err := codes.Set(ctx, "wrong", record, time.Minute); err != nil {
t.Fatal(err)
}
first, err := codes.VerifyLogin(ctx, "wrong", phone, "00000", false, 9)
if err != nil || first.Status != store.LoginCodeVerifyInvalid || first.Record.Attempts != 1 {
t.Fatalf("first wrong code = %+v err=%v", first, err)
}
stored, found, err := codes.Get(ctx, "wrong")
if err != nil || !found || stored.Attempts != 1 {
t.Fatalf("stored after first wrong = %+v found=%v err=%v", stored, found, err)
}
second, err := codes.VerifyLogin(ctx, "wrong", phone, "00000", false, 9)
if err != nil || second.Status != store.LoginCodeVerifyInvalid || second.Record.Attempts != 2 {
t.Fatalf("threshold wrong code = %+v err=%v", second, err)
}
if _, found, _ := codes.Get(ctx, "wrong"); found {
t.Fatal("threshold-exhausted code remains")
}
after, err := codes.VerifyLogin(ctx, "wrong", phone, record.Code, false, 9)
if err != nil || after.Status != store.LoginCodeVerifyMissing {
t.Fatalf("verify after exhaustion = %+v err=%v, want Missing", after, err)
}
fallback := newRecord()
fallback.MaxAttempts = 0
if err := codes.Set(ctx, "fallback", fallback, time.Minute); err != nil {
t.Fatal(err)
}
if got, err := codes.VerifyLogin(ctx, "fallback", phone, "bad", false, 1); err != nil || got.Status != store.LoginCodeVerifyInvalid {
t.Fatalf("default threshold verify = %+v err=%v", got, err)
}
if _, found, _ := codes.Get(ctx, "fallback"); found {
t.Fatal("default threshold did not delete code")
}
})
t.Run("accepted consume and sign-up marker are terminal", func(t *testing.T) {
codes := NewCodeStore()
record := newRecord()
if err := codes.Set(ctx, "consume", record, time.Minute); err != nil {
t.Fatal(err)
}
accepted, err := codes.VerifyLogin(ctx, "consume", phone, record.Code, false, 5)
if err != nil || accepted.Status != store.LoginCodeVerifyAccepted || accepted.Record.SignUpVerified {
t.Fatalf("consume verify = %+v err=%v", accepted, err)
}
if _, found, _ := codes.Get(ctx, "consume"); found {
t.Fatal("accepted existing-user code remains")
}
if err := codes.Set(ctx, "issued-existing", record, time.Minute); err != nil {
t.Fatal(err)
}
wrongScope, err := codes.VerifyLogin(ctx, "issued-existing", phone, record.Code, true, 5)
if err != nil || wrongScope.Status != store.LoginCodeVerifyInvalid {
t.Fatalf("existing-issued keep-for-signup = %+v err=%v, want Invalid", wrongScope, err)
}
if _, found, err := codes.ConsumeSignUpVerified(ctx, "issued-existing", phone); err != nil || found {
t.Fatalf("existing-issued sign-up consume found=%v err=%v", found, err)
}
signUpRecord := record
signUpRecord.IssuedUserID = 0
if err := codes.Set(ctx, "signup", signUpRecord, time.Minute); err != nil {
t.Fatal(err)
}
expires := codes.m["signup"].expires
marked, err := codes.VerifyLogin(ctx, "signup", phone, record.Code, true, 5)
if err != nil || marked.Status != store.LoginCodeVerifyAccepted || !marked.Record.SignUpVerified {
t.Fatalf("sign-up verify = %+v err=%v", marked, err)
}
if got := codes.m["signup"]; !got.code.SignUpVerified || !got.expires.Equal(expires) {
t.Fatalf("sign-up marker=%+v expiry=%v, want marker with unchanged %v", got.code, got.expires, expires)
}
repeated, err := codes.VerifyLogin(ctx, "signup", phone, record.Code, true, 5)
if err != nil || repeated.Status != store.LoginCodeVerifyMissing {
t.Fatalf("repeated sign-up verify = %+v err=%v, want terminal Missing", repeated, err)
}
if _, found, err := codes.ConsumeSignUpVerified(ctx, "signup", "15550016999"); err != nil || found {
t.Fatalf("cross-phone sign-up consume found=%v err=%v", found, err)
}
if _, found, err := codes.TakeLoginCode(ctx, "signup", phone); err != nil || found {
t.Fatalf("terminal marker take found=%v err=%v, want false", found, err)
}
consumed, found, err := codes.ConsumeSignUpVerified(ctx, "signup", phone)
if err != nil || !found || !consumed.SignUpVerified || consumed.Code != signUpRecord.Code || consumed.IssuedUserID != 0 {
t.Fatalf("sign-up consume = %+v found=%v err=%v", consumed, found, err)
}
if _, found, err := codes.ConsumeSignUpVerified(ctx, "signup", phone); err != nil || found {
t.Fatalf("second sign-up consume found=%v err=%v", found, err)
}
})
t.Run("take returns the removed record exactly once", func(t *testing.T) {
codes := NewCodeStore()
record := newRecord()
if err := codes.Set(ctx, "take", record, time.Minute); err != nil {
t.Fatal(err)
}
expected, found, err := codes.Get(ctx, "take")
if err != nil || !found {
t.Fatalf("load take record found=%v err=%v", found, err)
}
if _, found, err := codes.TakeLoginCode(ctx, "take", "15550016999"); err != nil || found {
t.Fatalf("cross-phone take found=%v err=%v", found, err)
}
taken, found, err := codes.TakeLoginCode(ctx, "take", phone)
if err != nil || !found || taken != expected {
t.Fatalf("take = %+v found=%v err=%v, want %+v", taken, found, err, expected)
}
if _, found, err := codes.TakeLoginCode(ctx, "take", phone); err != nil || found {
t.Fatalf("second take found=%v err=%v", found, err)
}
})
}
func TestCodeStoreAtomicLoginConcurrency(t *testing.T) {
ctx := context.Background()
const (
phone = "15550016002"
workers = 64
)
newRecord := func() store.PhoneCode {
return store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
Phone: phone,
Code: "12345",
Channel: "phone",
MaxAttempts: 7,
}
}
t.Run("consume verify has one accepted", func(t *testing.T) {
codes := NewCodeStore()
if err := codes.Set(ctx, "verify-race", newRecord(), time.Minute); err != nil {
t.Fatal(err)
}
statuses := concurrentMemoryVerify(t, codes, "verify-race", phone, "12345", false, workers)
if statuses[store.LoginCodeVerifyAccepted] != 1 || statuses[store.LoginCodeVerifyMissing] != workers-1 || statuses[store.LoginCodeVerifyInvalid] != 0 {
t.Fatalf("verify race statuses = %+v", statuses)
}
})
t.Run("mark and consume each have one winner", func(t *testing.T) {
codes := NewCodeStore()
if err := codes.Set(ctx, "signup-race", newRecord(), time.Minute); err != nil {
t.Fatal(err)
}
statuses := concurrentMemoryVerify(t, codes, "signup-race", phone, "12345", true, workers)
if statuses[store.LoginCodeVerifyAccepted] != 1 || statuses[store.LoginCodeVerifyMissing] != workers-1 {
t.Fatalf("sign-up verify race statuses = %+v", statuses)
}
found := concurrentMemoryConsumeSignUp(t, codes, "signup-race", phone, workers)
if found != 1 {
t.Fatalf("sign-up consumes = %d, want 1", found)
}
})
t.Run("take has one winner", func(t *testing.T) {
codes := NewCodeStore()
if err := codes.Set(ctx, "take-race", newRecord(), time.Minute); err != nil {
t.Fatal(err)
}
found := concurrentMemoryTake(t, codes, "take-race", phone, workers)
if found != 1 {
t.Fatalf("takes = %d, want 1", found)
}
})
t.Run("wrong attempts cannot be lost", func(t *testing.T) {
codes := NewCodeStore()
if err := codes.Set(ctx, "wrong-race", newRecord(), time.Minute); err != nil {
t.Fatal(err)
}
statuses := concurrentMemoryVerify(t, codes, "wrong-race", phone, "00000", false, workers)
if statuses[store.LoginCodeVerifyInvalid] != 7 || statuses[store.LoginCodeVerifyMissing] != workers-7 {
t.Fatalf("wrong-code race statuses = %+v, want 7 Invalid then Missing", statuses)
}
if _, found, _ := codes.Get(ctx, "wrong-race"); found {
t.Fatal("wrong-code race left an exhausted code")
}
})
t.Run("verify and cancel-resend take share one winner", func(t *testing.T) {
codes := NewCodeStore()
if err := codes.Set(ctx, "mixed-race", newRecord(), time.Minute); err != nil {
t.Fatal(err)
}
results := make(chan bool, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func(take bool) {
defer wg.Done()
if take {
_, found, err := codes.TakeLoginCode(ctx, "mixed-race", phone)
if err != nil {
t.Errorf("TakeLoginCode: %v", err)
}
results <- found
return
}
verified, err := codes.VerifyLogin(ctx, "mixed-race", phone, "12345", false, 5)
if err != nil {
t.Errorf("VerifyLogin: %v", err)
}
results <- verified.Status == store.LoginCodeVerifyAccepted
}(i%2 == 0)
}
wg.Wait()
close(results)
winners := 0
for won := range results {
if won {
winners++
}
}
if winners != 1 {
t.Fatalf("mixed verify/take winners = %d, want 1", winners)
}
})
t.Run("sign-up mark and take share one winner", func(t *testing.T) {
codes := NewCodeStore()
if err := codes.Set(ctx, "mixed-signup-race", newRecord(), time.Minute); err != nil {
t.Fatal(err)
}
results := make(chan bool, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func(take bool) {
defer wg.Done()
if take {
_, found, err := codes.TakeLoginCode(ctx, "mixed-signup-race", phone)
if err != nil {
t.Errorf("TakeLoginCode: %v", err)
}
results <- found
return
}
verified, err := codes.VerifyLogin(ctx, "mixed-signup-race", phone, "12345", true, 5)
if err != nil {
t.Errorf("VerifyLogin: %v", err)
}
results <- verified.Status == store.LoginCodeVerifyAccepted
}(i%2 == 0)
}
wg.Wait()
close(results)
winners := 0
for won := range results {
if won {
winners++
}
}
if winners != 1 {
t.Fatalf("mixed sign-up/take winners = %d, want 1", winners)
}
})
}
func concurrentMemoryVerify(t *testing.T, codes *CodeStore, hash, phone, code string, keep bool, workers int) map[store.LoginCodeVerifyStatus]int {
t.Helper()
ctx := context.Background()
results := make(chan store.LoginCodeVerifyStatus, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
result, err := codes.VerifyLogin(ctx, hash, phone, code, keep, 5)
if err != nil {
t.Errorf("VerifyLogin: %v", err)
return
}
results <- result.Status
}()
}
wg.Wait()
close(results)
counts := make(map[store.LoginCodeVerifyStatus]int)
for status := range results {
counts[status]++
}
return counts
}
func concurrentMemoryTake(t *testing.T, codes *CodeStore, hash, phone string, workers int) int {
t.Helper()
ctx := context.Background()
results := make(chan bool, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_, found, err := codes.TakeLoginCode(ctx, hash, phone)
if err != nil {
t.Errorf("TakeLoginCode: %v", err)
return
}
results <- found
}()
}
wg.Wait()
close(results)
foundCount := 0
for found := range results {
if found {
foundCount++
}
}
return foundCount
}
func concurrentMemoryConsumeSignUp(t *testing.T, codes *CodeStore, hash, phone string, workers int) int {
t.Helper()
ctx := context.Background()
results := make(chan bool, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_, found, err := codes.ConsumeSignUpVerified(ctx, hash, phone)
if err != nil {
t.Errorf("ConsumeSignUpVerified: %v", err)
return
}
results <- found
}()
}
wg.Wait()
close(results)
foundCount := 0
for found := range results {
if found {
foundCount++
}
}
return foundCount
}

View file

@ -37,9 +37,12 @@ func (s *MessageStore) DeleteMessages(_ context.Context, req domain.DeleteMessag
}
type deletedMemoryMessage struct {
userID int64
peer domain.Peer
id int
userID int64
peer domain.Peer
id int
privateMessageID int64
messageSenderID int64
randomID int64
}
func (s *MessageStore) finishMemoryDeleteLocked(res domain.DeleteMessagesResult, deleted []deletedMemoryMessage, date int, preserveEmptyDialogs bool) domain.DeleteMessagesResult {
@ -83,6 +86,19 @@ func (s *MessageStore) finishMemoryDeleteLocked(res domain.DeleteMessagesResult,
Date: date,
MessageIDs: ids,
}
for _, row := range deleted {
if row.userID != userID || row.messageSenderID != userID || row.randomID == 0 || row.privateMessageID == 0 {
continue
}
key := privateSendDedupKey{senderUserID: userID, randomID: row.randomID}
record, ok := s.privateSendDedup[key]
if !ok {
continue
}
cloned := cloneUpdateEvent(event)
record.senderDeleteEvent = &cloned
s.privateSendDedup[key] = record
}
res.Deleted = append(res.Deleted, domain.DeletedMessagesForUser{
UserID: userID,
MessageIDs: ids,

View file

@ -21,7 +21,14 @@ func (s *MessageStore) deleteMemoryMessagesLocked(userID int64, limit int, match
more = true
continue
}
deleted = append(deleted, deletedMemoryMessage{userID: userID, peer: msg.Peer, id: msg.ID})
deleted = append(deleted, deletedMemoryMessage{
userID: userID,
peer: msg.Peer,
id: msg.ID,
privateMessageID: msg.UID,
messageSenderID: msg.From.ID,
randomID: msg.RandomID,
})
if msg.UID != 0 {
revokeUIDs[msg.UID] = struct{}{}
}
@ -45,7 +52,14 @@ func (s *MessageStore) deleteMemoryMessagesByUIDLocked(uids map[int64]struct{},
kept := messages[:0]
for _, msg := range messages {
if _, ok := uids[msg.UID]; ok {
deleted = append(deleted, deletedMemoryMessage{userID: userID, peer: msg.Peer, id: msg.ID})
deleted = append(deleted, deletedMemoryMessage{
userID: userID,
peer: msg.Peer,
id: msg.ID,
privateMessageID: msg.UID,
messageSenderID: msg.From.ID,
randomID: msg.RandomID,
})
continue
}
kept = append(kept, msg)

View file

@ -0,0 +1,164 @@
package memory
import (
"context"
"errors"
"testing"
"telesrv/internal/domain"
)
func TestMessageStorePrivateRandomIDConflictAndReplayFacts(t *testing.T) {
ctx := context.Background()
messages := NewMessageStore()
base := domain.SendPrivateTextRequest{
SenderUserID: 1001, RecipientUserID: 1002, RandomID: 501,
Message: "immutable", Date: 1700000000,
}
first, err := messages.SendPrivateText(ctx, base)
if err != nil {
t.Fatalf("first send: %v", err)
}
replay := base
replay.Date++
replay.OriginSessionID = 77
replay.RecipientBlocked = true
duplicate, err := messages.SendPrivateText(ctx, replay)
if err != nil {
t.Fatalf("exact replay: %v", err)
}
if !duplicate.Duplicate || duplicate.SenderMessage.ID != first.SenderMessage.ID || duplicate.RecipientMessage.ID != first.RecipientMessage.ID {
t.Fatalf("exact replay = %+v, want original delivered boxes", duplicate)
}
tests := []struct {
name string
mutate func(*domain.SendPrivateTextRequest)
}{
{name: "peer", mutate: func(req *domain.SendPrivateTextRequest) { req.RecipientUserID = 1003 }},
{name: "body", mutate: func(req *domain.SendPrivateTextRequest) { req.Message = "changed" }},
{name: "media", mutate: func(req *domain.SendPrivateTextRequest) {
req.Media = &domain.MessageMedia{
Kind: domain.MessageMediaKindContact,
Contact: &domain.MessageContact{
PhoneNumber: "+10000000000",
FirstName: "Changed",
},
}
}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
req := base
tc.mutate(&req)
if _, err := messages.SendPrivateText(ctx, req); !errors.Is(err, domain.ErrMessageRandomIDDuplicate) {
t.Fatalf("conflicting replay err = %v, want ErrMessageRandomIDDuplicate", err)
}
})
}
}
func TestMessageStorePrivateRandomIDSelfAndBlockedReplay(t *testing.T) {
ctx := context.Background()
messages := NewMessageStore()
selfReq := domain.SendPrivateTextRequest{
SenderUserID: 2001, RecipientUserID: 2001, RandomID: 601,
Message: "saved note", Date: 1700000100,
}
self, err := messages.SendPrivateText(ctx, selfReq)
if err != nil {
t.Fatalf("self first: %v", err)
}
selfReq.Date++
selfReplay, err := messages.SendPrivateText(ctx, selfReq)
if err != nil {
t.Fatalf("self replay: %v", err)
}
if !selfReplay.Duplicate || selfReplay.SenderMessage.ID != self.SenderMessage.ID || selfReplay.RecipientMessage.ID != self.SenderMessage.ID {
t.Fatalf("self replay = %+v, want original single box", selfReplay)
}
blockedReq := domain.SendPrivateTextRequest{
SenderUserID: 2002, RecipientUserID: 2003, RandomID: 602,
Message: "blocked", Date: 1700000110, RecipientBlocked: true,
}
blocked, err := messages.SendPrivateText(ctx, blockedReq)
if err != nil {
t.Fatalf("blocked first: %v", err)
}
if blocked.RecipientMessage.ID != 0 {
t.Fatalf("blocked recipient = %+v, want empty", blocked.RecipientMessage)
}
blockedReq.Date++
blockedReq.RecipientBlocked = false
blockedReplay, err := messages.SendPrivateText(ctx, blockedReq)
if err != nil {
t.Fatalf("blocked replay: %v", err)
}
if !blockedReplay.Duplicate || blockedReplay.SenderMessage.ID != blocked.SenderMessage.ID || blockedReplay.RecipientMessage.ID != 0 {
t.Fatalf("blocked replay = %+v, want original sender-only result", blockedReplay)
}
}
func TestMessageStorePrivateRandomIDReplayUsesCurrentSnapshotAndDurableDeleteMemory(t *testing.T) {
ctx := context.Background()
messages := NewMessageStore()
req := domain.SendPrivateTextRequest{
SenderUserID: 3001, RecipientUserID: 3002, RandomID: 701,
Message: "original", Date: 1700000200,
}
first, err := messages.SendPrivateText(ctx, req)
if err != nil {
t.Fatalf("first send: %v", err)
}
edited, err := messages.EditMessage(ctx, domain.EditMessageRequest{
OwnerUserID: req.SenderUserID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: req.RecipientUserID},
ID: first.SenderMessage.ID,
Message: "edited projection",
EditDate: 1700000201,
})
if err != nil {
t.Fatalf("edit message: %v", err)
}
senderPtsBeforeReplay := messages.nextPts[req.SenderUserID]
recipientPtsBeforeReplay := messages.nextPts[req.RecipientUserID]
replay, err := messages.SendPrivateText(ctx, req)
if err != nil {
t.Fatalf("replay after edit: %v", err)
}
if replay.SenderMessage.ID != first.SenderMessage.ID || replay.SenderMessage.Pts != edited.Self().Message.Pts || replay.SenderMessage.Body != "edited projection" {
t.Fatalf("replay after edit = %+v, want current visible snapshot", replay.SenderMessage)
}
if replay.SenderEvent.Pts != first.SenderEvent.Pts || replay.ReplayDeleteEvent != nil {
t.Fatalf("replay after edit event = %+v delete=%+v, want original send pts and no delete", replay.SenderEvent, replay.ReplayDeleteEvent)
}
if messages.nextPts[req.SenderUserID] != senderPtsBeforeReplay || messages.nextPts[req.RecipientUserID] != recipientPtsBeforeReplay {
t.Fatalf("edit replay advanced pts sender/recipient = %d/%d, want %d/%d", messages.nextPts[req.SenderUserID], messages.nextPts[req.RecipientUserID], senderPtsBeforeReplay, recipientPtsBeforeReplay)
}
deleted, err := messages.DeleteMessages(ctx, domain.DeleteMessagesRequest{
OwnerUserID: req.SenderUserID,
IDs: []int{first.SenderMessage.ID},
Revoke: true,
Date: 1700000202,
})
if err != nil {
t.Fatalf("delete message: %v", err)
}
senderPtsBeforeReplay = messages.nextPts[req.SenderUserID]
recipientPtsBeforeReplay = messages.nextPts[req.RecipientUserID]
replay, err = messages.SendPrivateText(ctx, req)
if err != nil {
t.Fatalf("replay after delete: %v", err)
}
if replay.SenderMessage.ID != first.SenderMessage.ID || replay.SenderMessage.Pts != first.SenderMessage.Pts || replay.SenderMessage.Body != "original" {
t.Fatalf("replay after delete = %+v, want immutable first snapshot", replay.SenderMessage)
}
if replay.ReplayDeleteEvent == nil || replay.ReplayDeleteEvent.Pts != deleted.Self().Event.Pts ||
len(replay.ReplayDeleteEvent.MessageIDs) != 1 || replay.ReplayDeleteEvent.MessageIDs[0] != first.SenderMessage.ID {
t.Fatalf("replay delete event = %+v, want durable delete %+v", replay.ReplayDeleteEvent, deleted.Self().Event)
}
if messages.nextPts[req.SenderUserID] != senderPtsBeforeReplay || messages.nextPts[req.RecipientUserID] != recipientPtsBeforeReplay {
t.Fatalf("delete replay advanced pts sender/recipient = %d/%d, want %d/%d", messages.nextPts[req.SenderUserID], messages.nextPts[req.RecipientUserID], senderPtsBeforeReplay, recipientPtsBeforeReplay)
}
}

View file

@ -2,10 +2,25 @@ package memory
import (
"context"
"fmt"
"telesrv/internal/domain"
"telesrv/internal/store"
"time"
)
type privateSendDedupKey struct {
senderUserID int64
randomID int64
}
type privateSendDedupRecord struct {
recipientUserID int64
senderSnapshot []byte
recipientMessage domain.Message
fingerprint []byte
senderDeleteEvent *domain.UpdateEvent
}
func (s *MessageStore) Create(_ context.Context, msg domain.Message) (domain.Message, error) {
s.mu.Lock()
defer s.mu.Unlock()
@ -30,29 +45,19 @@ func (s *MessageStore) Create(_ context.Context, msg domain.Message) (domain.Mes
}
func (s *MessageStore) SendPrivateText(_ context.Context, req domain.SendPrivateTextRequest) (domain.SendPrivateTextResult, error) {
fingerprint, err := store.PrivateSendFingerprint(req)
if err != nil {
return domain.SendPrivateTextResult{}, err
}
s.mu.Lock()
defer s.mu.Unlock()
for _, msg := range s.m[req.SenderUserID] {
if msg.RandomID != 0 && msg.RandomID == req.RandomID {
recipient := domain.Message{}
if req.SenderUserID != req.RecipientUserID {
for _, peerMsg := range s.m[req.RecipientUserID] {
if peerMsg.UID == msg.UID {
recipient = peerMsg
break
}
}
} else {
recipient = msg
}
return domain.SendPrivateTextResult{
SenderMessage: cloneMessage(msg),
RecipientMessage: cloneMessage(recipient),
SenderEvent: newMessageEvent(msg),
RecipientEvent: newMessageEvent(recipient),
Duplicate: true,
}, nil
}
if replay, found, err := s.lookupPrivateSendReplayLocked(domain.PrivateSendReplayRequest{
SenderUserID: req.SenderUserID,
RecipientUserID: req.RecipientUserID,
RandomID: req.RandomID,
IdempotencyFingerprint: fingerprint,
}); err != nil || found {
return replay, err
}
if req.Date == 0 {
req.Date = int(time.Now().Unix())
@ -109,10 +114,25 @@ func (s *MessageStore) SendPrivateText(_ context.Context, req domain.SendPrivate
recipient.Pts = s.nextPtsLocked(req.RecipientUserID)
recipient.MediaUnread = req.Media.HasUnreadPayload()
}
var senderSnapshot []byte
if req.RandomID != 0 {
senderSnapshot, err = store.EncodePrivateSendSnapshot(sender)
if err != nil {
return domain.SendPrivateTextResult{}, err
}
}
s.m[req.SenderUserID] = append(s.m[req.SenderUserID], sender)
if req.SenderUserID != req.RecipientUserID && !req.RecipientBlocked {
s.m[req.RecipientUserID] = append(s.m[req.RecipientUserID], recipient)
}
if req.RandomID != 0 {
s.privateSendDedup[privateSendDedupKey{senderUserID: req.SenderUserID, randomID: req.RandomID}] = privateSendDedupRecord{
recipientUserID: req.RecipientUserID,
senderSnapshot: senderSnapshot,
recipientMessage: immutablePrivateSendReceipt(recipient),
fingerprint: append([]byte(nil), fingerprint...),
}
}
if s.dialogs != nil {
if recipient.ID != 0 {
s.upsertMemoryDialogsLocked(sender, recipient)
@ -128,6 +148,81 @@ func (s *MessageStore) SendPrivateText(_ context.Context, req domain.SendPrivate
}, nil
}
// LookupPrivateSendReplay returns an existing immutable/current replay receipt without running
// any send permission, reply resolution or allocation path.
func (s *MessageStore) LookupPrivateSendReplay(_ context.Context, req domain.PrivateSendReplayRequest) (domain.SendPrivateTextResult, bool, error) {
if req.SenderUserID == 0 || req.RecipientUserID == 0 || req.RandomID == 0 {
return domain.SendPrivateTextResult{}, false, fmt.Errorf("memory private send replay: invalid scope")
}
if err := store.ValidateSendFingerprint(req.IdempotencyFingerprint, "private send replay"); err != nil {
return domain.SendPrivateTextResult{}, false, err
}
s.mu.RLock()
defer s.mu.RUnlock()
return s.lookupPrivateSendReplayLocked(req)
}
func (s *MessageStore) lookupPrivateSendReplayLocked(req domain.PrivateSendReplayRequest) (domain.SendPrivateTextResult, bool, error) {
record, ok := s.privateSendDedup[privateSendDedupKey{senderUserID: req.SenderUserID, randomID: req.RandomID}]
if !ok {
return domain.SendPrivateTextResult{}, false, nil
}
if record.recipientUserID != req.RecipientUserID || !store.SameSendFingerprint(record.fingerprint, req.IdempotencyFingerprint) {
return domain.SendPrivateTextResult{}, false, domain.ErrMessageRandomIDDuplicate
}
firstSender, err := store.DecodePrivateSendSnapshot(record.senderSnapshot)
if err != nil {
return domain.SendPrivateTextResult{}, false, fmt.Errorf("memory duplicate private message snapshot: %w", err)
}
sender := firstSender
visible := false
for _, current := range s.m[req.SenderUserID] {
if current.UID == firstSender.UID && current.ID == firstSender.ID {
sender = cloneMessage(current)
sender.RandomID = firstSender.RandomID
visible = true
break
}
}
if !visible && record.senderDeleteEvent == nil {
return domain.SendPrivateTextResult{}, false, fmt.Errorf("memory duplicate private message %d is absent without a durable sender delete receipt", firstSender.UID)
}
recipient := cloneMessage(record.recipientMessage)
var replayDelete *domain.UpdateEvent
if record.senderDeleteEvent != nil {
cloned := cloneUpdateEvent(*record.senderDeleteEvent)
replayDelete = &cloned
}
return domain.SendPrivateTextResult{
SenderMessage: sender,
RecipientMessage: recipient,
SenderEvent: newMessageEvent(firstSender),
RecipientEvent: newMessageEvent(recipient),
Duplicate: true,
ReplayDeleteEvent: replayDelete,
}, true, nil
}
// immutablePrivateSendReceipt keeps the recipient allocation facts used by
// store-level idempotency tests. The sender response snapshot is stored as a
// versioned JSON value above so all nested media/reply graphs are immutable.
func immutablePrivateSendReceipt(msg domain.Message) domain.Message {
if msg.ID == 0 {
return domain.Message{}
}
return domain.Message{
ID: msg.ID,
UID: msg.UID,
RandomID: msg.RandomID,
OwnerUserID: msg.OwnerUserID,
Peer: msg.Peer,
From: msg.From,
Date: msg.Date,
Out: msg.Out,
Pts: msg.Pts,
}
}
func (s *MessageStore) resolveMemoryReplyLocked(req domain.SendPrivateTextRequest) (*domain.MessageReply, *domain.MessageReply, error) {
if req.ReplyTo == nil {
return nil, nil, nil

View file

@ -7,14 +7,17 @@ import (
// MessageStore 是 store.MessageStore 的内存实现。
type MessageStore struct {
mu sync.RWMutex
m map[int64][]domain.Message
nextUID int64
nextBox map[int64]int
nextPts map[int64]int
readOutboxDates map[readOutboxDateKey]int
privateReactions map[int64]map[int64][]domain.ChannelMessagePeerReaction
dialogs *DialogStore
mu sync.RWMutex
m map[int64][]domain.Message
nextUID int64
nextBox map[int64]int
nextPts map[int64]int
readOutboxDates map[readOutboxDateKey]int
privateReactions map[int64]map[int64][]domain.ChannelMessagePeerReaction
privateSendDedup map[privateSendDedupKey]privateSendDedupRecord
loginCodeDeliveries map[[32]byte]loginCodeDeliveryRecord
albumGroups map[albumGroupKey]albumGroupRecord
dialogs *DialogStore
// polls 是共享 poll 权威(投票校验与读路径 enrichment);nil 时 poll 链路按未接入处理。
polls *PollStore
// savedPins 是收藏夹子会话置顶顺序(下标即 pinned_order,越小越前)。
@ -35,13 +38,16 @@ type readOutboxDateKey struct {
// NewMessageStore 创建内存 MessageStore。
func NewMessageStore(dialogs ...*DialogStore) *MessageStore {
s := &MessageStore{
m: make(map[int64][]domain.Message),
nextUID: 1,
nextBox: make(map[int64]int),
nextPts: make(map[int64]int),
readOutboxDates: make(map[readOutboxDateKey]int),
privateReactions: make(map[int64]map[int64][]domain.ChannelMessagePeerReaction),
savedPins: make(map[int64][]domain.Peer),
m: make(map[int64][]domain.Message),
nextUID: 1,
nextBox: make(map[int64]int),
nextPts: make(map[int64]int),
readOutboxDates: make(map[readOutboxDateKey]int),
privateReactions: make(map[int64]map[int64][]domain.ChannelMessagePeerReaction),
privateSendDedup: make(map[privateSendDedupKey]privateSendDedupRecord),
loginCodeDeliveries: make(map[[32]byte]loginCodeDeliveryRecord),
albumGroups: make(map[albumGroupKey]albumGroupRecord),
savedPins: make(map[int64][]domain.Peer),
}
if len(dialogs) > 0 {
s.dialogs = dialogs[0]

View file

@ -162,7 +162,7 @@ func TestMessageStoreWebViewDataServiceActionRoundTrip(t *testing.T) {
assertWebViewData("sender", got.SenderMessage)
assertWebViewData("recipient", got.RecipientMessage)
dup, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
_, err = messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: req.SenderUserID,
RecipientUserID: req.RecipientUserID,
RandomID: req.RandomID,
@ -178,13 +178,9 @@ func TestMessageStoreWebViewDataServiceActionRoundTrip(t *testing.T) {
},
},
})
if err != nil {
t.Fatalf("SendPrivateText duplicate: %v", err)
if !errors.Is(err, domain.ErrMessageRandomIDDuplicate) {
t.Fatalf("changed-media duplicate err = %v, want ErrMessageRandomIDDuplicate", err)
}
if !dup.Duplicate || dup.SenderMessage.ID != got.SenderMessage.ID || dup.RecipientMessage.ID != got.RecipientMessage.ID {
t.Fatalf("duplicate = %+v, want original boxes", dup)
}
assertWebViewData("duplicate sender", dup.SenderMessage)
recipientHistory, err := messages.ListByUser(ctx, req.RecipientUserID, domain.MessageFilter{
HasPeer: true,

View file

@ -0,0 +1,240 @@
package memory
import (
"context"
"sync"
"testing"
"time"
"telesrv/internal/store"
)
func TestCodeStoreAtomicScopedVerification(t *testing.T) {
ctx := context.Background()
newRecord := func() store.PhoneCode {
return store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
Phone: "15550016021",
Code: "12345",
Channel: store.PhoneCodeChannelPhone,
Purpose: store.PhoneCodePurposeChangePhone,
UserID: 420021,
AuthKeyID: [8]byte{1, 2, 3, 4},
MaxAttempts: 2,
}
}
t.Run("only the active hash and exact scope can mutate", func(t *testing.T) {
codes := NewCodeStore()
record := newRecord()
if err := codes.Set(ctx, "scoped-old", record, time.Minute); err != nil {
t.Fatal(err)
}
if err := codes.Set(ctx, "scoped-current", record, time.Minute); err != nil {
t.Fatal(err)
}
if result, err := codes.VerifyScoped(ctx, "scoped-old", record.Scope(), record.Code, 5); err != nil || result.Status != store.LoginCodeVerifyMissing {
t.Fatalf("old-hash verify=%+v err=%v", result, err)
}
otherScope := record.Scope()
otherScope.AuthKeyID = [8]byte{9}
if result, err := codes.VerifyScoped(ctx, "scoped-current", otherScope, "00000", 5); err != nil || result.Status != store.LoginCodeVerifyMissing {
t.Fatalf("cross-scope verify=%+v err=%v", result, err)
}
stored, found, err := codes.Get(ctx, "scoped-current")
if err != nil || !found || stored.Attempts != 0 {
t.Fatalf("victim after cross-scope verify=%+v found=%v err=%v", stored, found, err)
}
})
t.Run("wrong attempts preserve ttl then delete code and index", func(t *testing.T) {
codes := NewCodeStore()
record := newRecord()
if err := codes.Set(ctx, "scoped-wrong", record, time.Minute); err != nil {
t.Fatal(err)
}
before := codes.m["scoped-wrong"]
first, err := codes.VerifyScoped(ctx, "scoped-wrong", record.Scope(), "00000", 9)
if err != nil || first.Status != store.LoginCodeVerifyInvalid || first.Record.Attempts != 1 {
t.Fatalf("first wrong=%+v err=%v", first, err)
}
after := codes.m["scoped-wrong"]
if !after.expires.Equal(before.expires) || after.code.Revision == before.code.Revision {
t.Fatalf("wrong attempt expiry/revision before=%+v after=%+v", before, after)
}
second, err := codes.VerifyScoped(ctx, "scoped-wrong", record.Scope(), "00000", 9)
if err != nil || second.Status != store.LoginCodeVerifyInvalid || second.Record.Attempts != 2 {
t.Fatalf("threshold wrong=%+v err=%v", second, err)
}
if _, found, _ := codes.Get(ctx, "scoped-wrong"); found {
t.Fatal("threshold-exhausted scoped code remains")
}
if got := codes.scopes[record.Scope()]; got != "" {
t.Fatalf("threshold-exhausted scope index=%q, want missing", got)
}
if result, err := codes.VerifyScoped(ctx, "scoped-wrong", record.Scope(), record.Code, 9); err != nil || result.Status != store.LoginCodeVerifyMissing {
t.Fatalf("verify after exhaustion=%+v err=%v", result, err)
}
})
t.Run("correct code consumes both keys exactly once", func(t *testing.T) {
codes := NewCodeStore()
record := newRecord()
if err := codes.Set(ctx, "scoped-correct", record, time.Minute); err != nil {
t.Fatal(err)
}
expected, found, err := codes.Get(ctx, "scoped-correct")
if err != nil || !found {
t.Fatalf("get expected found=%v err=%v", found, err)
}
accepted, err := codes.VerifyScoped(ctx, "scoped-correct", record.Scope(), record.Code, 5)
if err != nil || accepted.Status != store.LoginCodeVerifyAccepted || accepted.Record != expected {
t.Fatalf("accepted=%+v err=%v, want %+v", accepted, err, expected)
}
if _, found, _ := codes.Get(ctx, "scoped-correct"); found || codes.scopes[record.Scope()] != "" {
t.Fatal("accepted scoped code or index remains")
}
if repeated, err := codes.VerifyScoped(ctx, "scoped-correct", record.Scope(), record.Code, 5); err != nil || repeated.Status != store.LoginCodeVerifyMissing {
t.Fatalf("repeated verify=%+v err=%v", repeated, err)
}
})
t.Run("legacy and inconsistent records fail closed", func(t *testing.T) {
codes := NewCodeStore()
legacy := newRecord()
legacy.Version = 0
if err := codes.Set(ctx, "scoped-legacy", legacy, time.Minute); err != nil {
t.Fatal(err)
}
if result, err := codes.VerifyScoped(ctx, "scoped-legacy", legacy.Scope(), legacy.Code, 5); err != nil || result.Status != store.LoginCodeVerifyMissing {
t.Fatalf("legacy verify=%+v err=%v", result, err)
}
if _, found, _ := codes.Get(ctx, "scoped-legacy"); found || codes.scopes[legacy.Scope()] != "" {
t.Fatal("legacy code or index remains")
}
inconsistent := newRecord()
if err := codes.Set(ctx, "scoped-inconsistent", inconsistent, time.Minute); err != nil {
t.Fatal(err)
}
entry := codes.m["scoped-inconsistent"]
entry.code.Phone = "15550016999"
codes.m["scoped-inconsistent"] = entry
if result, err := codes.VerifyScoped(ctx, "scoped-inconsistent", inconsistent.Scope(), inconsistent.Code, 5); err != nil || result.Status != store.LoginCodeVerifyMissing {
t.Fatalf("inconsistent verify=%+v err=%v", result, err)
}
if _, found, _ := codes.Get(ctx, "scoped-inconsistent"); found || codes.scopes[inconsistent.Scope()] != "" {
t.Fatal("inconsistent code or selecting index remains")
}
})
}
func TestCodeStoreAtomicScopedConcurrency(t *testing.T) {
ctx := context.Background()
const workers = 64
newRecord := func(maxAttempts int) store.PhoneCode {
return store.PhoneCode{
Version: store.PhoneCodeVersionCurrent,
Phone: "15550016022",
Code: "12345",
Channel: store.PhoneCodeChannelPhone,
Purpose: store.PhoneCodePurposeChangePhone,
UserID: 420022,
AuthKeyID: [8]byte{5, 6, 7, 8},
MaxAttempts: maxAttempts,
}
}
t.Run("correct verification has one winner", func(t *testing.T) {
codes := NewCodeStore()
record := newRecord(7)
if err := codes.Set(ctx, "scoped-verify-race", record, time.Minute); err != nil {
t.Fatal(err)
}
statuses := concurrentMemoryScopedVerify(t, codes, "scoped-verify-race", record.Scope(), record.Code, workers)
if statuses[store.LoginCodeVerifyAccepted] != 1 || statuses[store.LoginCodeVerifyMissing] != workers-1 || statuses[store.LoginCodeVerifyInvalid] != 0 {
t.Fatalf("correct race statuses=%+v", statuses)
}
})
t.Run("wrong attempts cannot be lost", func(t *testing.T) {
codes := NewCodeStore()
record := newRecord(7)
if err := codes.Set(ctx, "scoped-wrong-race", record, time.Minute); err != nil {
t.Fatal(err)
}
statuses := concurrentMemoryScopedVerify(t, codes, "scoped-wrong-race", record.Scope(), "00000", workers)
if statuses[store.LoginCodeVerifyInvalid] != 7 || statuses[store.LoginCodeVerifyMissing] != workers-7 {
t.Fatalf("wrong race statuses=%+v", statuses)
}
if _, found, _ := codes.Get(ctx, "scoped-wrong-race"); found || codes.scopes[record.Scope()] != "" {
t.Fatal("wrong race left code or scope index")
}
})
t.Run("verification and cancellation share one winner", func(t *testing.T) {
codes := NewCodeStore()
record := newRecord(7)
if err := codes.Set(ctx, "scoped-mixed-race", record, time.Minute); err != nil {
t.Fatal(err)
}
results := make(chan bool, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func(cancel bool) {
defer wg.Done()
if cancel {
_, found, err := codes.ConsumeScoped(ctx, "scoped-mixed-race", record.Scope())
if err != nil {
t.Errorf("ConsumeScoped: %v", err)
}
results <- found
return
}
result, err := codes.VerifyScoped(ctx, "scoped-mixed-race", record.Scope(), record.Code, 5)
if err != nil {
t.Errorf("VerifyScoped: %v", err)
}
results <- result.Status == store.LoginCodeVerifyAccepted
}(i%2 == 0)
}
wg.Wait()
close(results)
winners := 0
for won := range results {
if won {
winners++
}
}
if winners != 1 {
t.Fatalf("verify/cancel winners=%d, want 1", winners)
}
})
}
func concurrentMemoryScopedVerify(t *testing.T, codes *CodeStore, hash string, scope store.PhoneCodeScope, code string, workers int) map[store.LoginCodeVerifyStatus]int {
t.Helper()
results := make(chan store.LoginCodeVerifyStatus, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
result, err := codes.VerifyScoped(context.Background(), hash, scope, code, 5)
if err != nil {
t.Errorf("VerifyScoped: %v", err)
return
}
results <- result.Status
}()
}
wg.Wait()
close(results)
statuses := make(map[store.LoginCodeVerifyStatus]int)
for status := range results {
statuses[status]++
}
return statuses
}

View file

@ -8,8 +8,9 @@ import (
// UpdateStateStore 是 store.UpdateStateStore 的内存实现。
type UpdateStateStore struct {
mu sync.RWMutex
states map[updateStateKey]domain.UpdateState
mu sync.RWMutex
states map[updateStateKey]domain.UpdateState
observed map[updateStateKey]domain.UpdateState
}
// UpdateEventStore 是 store.UpdateEventStore 的内存实现。
@ -157,7 +158,10 @@ func (s *UpdateEventStore) MaxContiguousPts(_ context.Context, userID int64) (in
// NewUpdateStateStore 创建内存 UpdateStateStore。
func NewUpdateStateStore() *UpdateStateStore {
return &UpdateStateStore{states: make(map[updateStateKey]domain.UpdateState)}
return &UpdateStateStore{
states: make(map[updateStateKey]domain.UpdateState),
observed: make(map[updateStateKey]domain.UpdateState),
}
}
func (s *UpdateStateStore) Get(_ context.Context, id [8]byte, userID int64) (domain.UpdateState, bool, error) {
@ -191,9 +195,39 @@ func (s *UpdateStateStore) Save(_ context.Context, id [8]byte, userID int64, st
return nil
}
func (s *UpdateStateStore) ObserveClientState(_ context.Context, id [8]byte, userID int64, st domain.UpdateState) error {
s.mu.Lock()
key := updateStateKey{authKeyID: id, userID: userID}
prev := s.observed[key]
if st.Pts < prev.Pts {
st.Pts = prev.Pts
}
if st.Qts < prev.Qts {
st.Qts = prev.Qts
}
if st.Date < prev.Date {
st.Date = prev.Date
}
if st.Seq < prev.Seq {
st.Seq = prev.Seq
}
s.observed[key] = st
s.mu.Unlock()
return nil
}
// ObservedClientState 暴露给同包/服务测试验证 retention 安全水位;业务读路径仍用 Get。
func (s *UpdateStateStore) ObservedClientState(id [8]byte, userID int64) (domain.UpdateState, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
st, ok := s.observed[updateStateKey{authKeyID: id, userID: userID}]
return st, ok
}
func (s *UpdateStateStore) Delete(_ context.Context, id [8]byte, userID int64) error {
s.mu.Lock()
delete(s.states, updateStateKey{authKeyID: id, userID: userID})
delete(s.observed, updateStateKey{authKeyID: id, userID: userID})
s.mu.Unlock()
return nil
}
@ -203,6 +237,7 @@ func (s *UpdateStateStore) DeleteAuthKey(_ context.Context, id [8]byte) error {
for k := range s.states {
if k.authKeyID == id {
delete(s.states, k)
delete(s.observed, k)
}
}
s.mu.Unlock()