go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/server/loginsessions/internal/store.go (about) 1 // Copyright 2022 The LUCI Authors. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 package internal 16 17 import ( 18 "context" 19 "sync" 20 "time" 21 22 "google.golang.org/protobuf/proto" 23 24 "go.chromium.org/luci/common/clock" 25 "go.chromium.org/luci/common/errors" 26 "go.chromium.org/luci/common/logging" 27 "go.chromium.org/luci/gae/service/datastore" 28 29 "go.chromium.org/luci/server/loginsessions/internal/statepb" 30 ) 31 32 // ErrNoSession is returned by SessionStore if the login session is missing. 33 var ErrNoSession = errors.New("no login session") 34 35 // SessionStore is a storage layer for login sessions. 36 type SessionStore interface { 37 // Create transactionally stores a session if it didn't exist before. 38 // 39 // The caller should have session.Id populated already with a random ID. 40 // 41 // Returns an error if there's already such session or the transaction failed. 42 Create(ctx context.Context, session *statepb.LoginSession) error 43 44 // Get returns an existing session or ErrNoSession if it is missing. 45 // 46 // Always returns a new copy of the protobuf message that can be safely 47 // mutated by the caller. 48 Get(ctx context.Context, sessionID string) (*statepb.LoginSession, error) 49 50 // Update transactionally updates an existing session. 51 // 52 // The callback is called to mutate the session in-place. The resulting 53 // session is then stored back (if it really was mutated). The callback may 54 // be called multiple times if the transaction is retried. 55 // 56 // If there's no such session returns ErrNoSession. May return other errors 57 // if the transaction fails. 58 // 59 // On success returns the session that is stored in the store now. 60 Update(ctx context.Context, sessionID string, cb func(*statepb.LoginSession)) (*statepb.LoginSession, error) 61 62 // Cleanup deletes login sessions that expired sufficiently long ago. 63 Cleanup(ctx context.Context) error 64 } 65 66 //////////////////////////////////////////////////////////////////////////////// 67 68 // MemorySessionStore implements SessionStore using an in-memory map. 69 // 70 // For tests and running locally during development. 71 type MemorySessionStore struct { 72 m sync.Mutex 73 sessions map[string]*statepb.LoginSession 74 } 75 76 func (s *MemorySessionStore) Create(ctx context.Context, session *statepb.LoginSession) error { 77 if session.Id == "" { 78 panic("session ID is empty") 79 } 80 s.m.Lock() 81 defer s.m.Unlock() 82 if s.sessions[session.Id] != nil { 83 return errors.Reason("already have a session with this ID").Err() 84 } 85 if s.sessions == nil { 86 s.sessions = make(map[string]*statepb.LoginSession, 1) 87 } 88 s.sessions[session.Id] = proto.Clone(session).(*statepb.LoginSession) 89 return nil 90 } 91 92 func (s *MemorySessionStore) Get(ctx context.Context, sessionID string) (*statepb.LoginSession, error) { 93 s.m.Lock() 94 defer s.m.Unlock() 95 if session := s.sessions[sessionID]; session != nil { 96 return proto.Clone(session).(*statepb.LoginSession), nil 97 } 98 return nil, ErrNoSession 99 } 100 101 func (s *MemorySessionStore) Update(ctx context.Context, sessionID string, cb func(*statepb.LoginSession)) (*statepb.LoginSession, error) { 102 s.m.Lock() 103 defer s.m.Unlock() 104 session := s.sessions[sessionID] 105 if session == nil { 106 return nil, ErrNoSession 107 } 108 clone := proto.Clone(session).(*statepb.LoginSession) 109 cb(clone) 110 if clone.Id != sessionID { 111 panic("changing session ID is forbidden") 112 } 113 s.sessions[sessionID] = proto.Clone(clone).(*statepb.LoginSession) 114 return clone, nil 115 } 116 117 func (s *MemorySessionStore) Cleanup(ctx context.Context) error { 118 // Don't care about the cleanup for in-memory implementation. 119 return nil 120 } 121 122 //////////////////////////////////////////////////////////////////////////////// 123 124 // Keep session for 7 extra days in case we need to investigate login issues. 125 const sessionCleanupDelay = 7 * 24 * time.Hour 126 127 type loginSessionEntity struct { 128 _extra datastore.PropertyMap `gae:"-,extra"` 129 _kind string `gae:"$kind,loginsessions.LoginSession"` 130 131 ID string `gae:"$id"` 132 Session *statepb.LoginSession 133 TTL time.Time 134 } 135 136 // DatastoreSessionStore implements SessionStore using Cloud Datastore. 137 type DatastoreSessionStore struct{} 138 139 func (s *DatastoreSessionStore) Create(ctx context.Context, session *statepb.LoginSession) error { 140 if session.Id == "" { 141 panic("session ID is empty") 142 } 143 return datastore.RunInTransaction(ctx, func(ctx context.Context) error { 144 switch err := datastore.Get(ctx, &loginSessionEntity{ID: session.Id}); { 145 case err == nil: 146 return errors.Reason("already have a session with this ID").Err() 147 case err != datastore.ErrNoSuchEntity: 148 return errors.Annotate(err, "failed to check if the session already exists").Err() 149 } 150 return datastore.Put(ctx, &loginSessionEntity{ 151 ID: session.Id, 152 Session: session, 153 TTL: session.Expiry.AsTime().Add(sessionCleanupDelay).UTC(), 154 }) 155 }, nil) 156 } 157 158 func (s *DatastoreSessionStore) Get(ctx context.Context, sessionID string) (*statepb.LoginSession, error) { 159 ent := loginSessionEntity{ID: sessionID} 160 switch err := datastore.Get(ctx, &ent); { 161 case err == nil: 162 return ent.Session, nil 163 case err == datastore.ErrNoSuchEntity: 164 return nil, ErrNoSession 165 default: 166 return nil, errors.Annotate(err, "datastore error fetching session").Err() 167 } 168 } 169 170 func (s *DatastoreSessionStore) Update(ctx context.Context, sessionID string, cb func(*statepb.LoginSession)) (*statepb.LoginSession, error) { 171 var stored *statepb.LoginSession 172 173 err := datastore.RunInTransaction(ctx, func(ctx context.Context) error { 174 ent := loginSessionEntity{ID: sessionID} 175 switch err := datastore.Get(ctx, &ent); { 176 case err == datastore.ErrNoSuchEntity: 177 return ErrNoSession 178 case err != nil: 179 return errors.Annotate(err, "error fetching session").Err() 180 } 181 clone := proto.Clone(ent.Session).(*statepb.LoginSession) 182 cb(clone) 183 if !proto.Equal(ent.Session, clone) { 184 if ent.Session.Id != clone.Id { 185 panic("changing session ID is forbidden") 186 } 187 ent.Session = clone 188 ent.TTL = clone.Expiry.AsTime().Add(sessionCleanupDelay).UTC() 189 if err := datastore.Put(ctx, &ent); err != nil { 190 return errors.Annotate(err, "failed to store updated session").Err() 191 } 192 } 193 stored = clone 194 return nil 195 }, nil) 196 197 switch { 198 case err == ErrNoSession: 199 return nil, err 200 case err != nil: 201 return nil, errors.Annotate(err, "session transaction error").Err() 202 default: 203 return stored, nil 204 } 205 } 206 207 func (s *DatastoreSessionStore) Cleanup(ctx context.Context) error { 208 q := datastore.NewQuery("loginsessions.LoginSession"). 209 Lt("TTL", clock.Now(ctx).UTC()). 210 KeysOnly(true) 211 212 var batch []*datastore.Key 213 214 // Best effort deletion. The cron will keep retrying anyway. 215 deleteBatch := func() { 216 if err := datastore.Delete(ctx, batch); err != nil { 217 logging.Warningf(ctx, "Failed to delete a batch of sessions: %s", err) 218 } else { 219 batch = batch[:0] 220 } 221 } 222 223 err := datastore.Run(ctx, q, func(key *datastore.Key) { 224 logging.Infof(ctx, "Cleaning up session %s", key.StringID()) 225 batch = append(batch, key) 226 if len(batch) >= 50 { 227 deleteBatch() 228 } 229 }) 230 231 deleteBatch() 232 return err 233 }