MediaWiki master
MultiBackendSessionStore.php
Go to the documentation of this file.
1<?php
8namespace MediaWiki\Session;
9
10use Psr\Log\LoggerInterface;
11use RuntimeException;
15
47
48 private const STATS_LABEL_ANON = 'anonymous';
49 private const STATS_LABEL_AUTH = 'authenticated';
50
51 private BagOStuff $anonSessionStore;
52 private BagOStuff $authenticatedSessionStore;
54 private bool $sameBackend;
55 private StatsFactory $statsFactory;
56
61 private LoggerInterface $logger;
62
63 public function __construct(
64 BagOStuff $anonSessionStore,
65 BagOStuff $authenticatedSessionStore,
66 LoggerInterface $logger,
67 StatsFactory $statsFactory
68 ) {
69 $this->setLogger( $logger );
70 // Do some logging of the individual stores before wrapping.
71 $this->logger->debug( 'SessionManager using anon store ' . get_class( $anonSessionStore ) );
72 $this->logger->debug( 'SessionManager using auth store ' . get_class( $authenticatedSessionStore ) );
73
74 $this->sameBackend = $anonSessionStore === $authenticatedSessionStore;
75 $this->anonSessionStore = $this->wrapWithCachedBagOStuff( $anonSessionStore );
76 $this->authenticatedSessionStore = $this->wrapWithCachedBagOStuff( $authenticatedSessionStore );
77 $this->statsFactory = $statsFactory;
78 }
79
81 public function setLogger( LoggerInterface $logger ): void {
82 $this->logger = $logger;
83 }
84
85 private function wrapWithCachedBagOStuff( BagOStuff $store ): CachedBagOStuff {
86 if ( $store instanceof CachedBagOStuff ) {
87 return $store;
88 }
89
90 return new CachedBagOStuff( $store );
91 }
92
101 private function getActiveStore( SessionInfo $sessionInfo ): array {
102 $userInfo = $sessionInfo->getUserInfo();
103 $anonData = null;
104
105 if ( $userInfo === null ) {
106 $this->logger->debug( 'No user info found for this session', [
107 'sessionInfo' => (string)$sessionInfo,
108 'exception' => new RuntimeException(),
109 ] );
110
111 // We don't yet know whether this is an anonymous or authenticated session
112 // (e.g., getSessionById() is being used), so we need to check both stores.
113 $anonKey = $this->anonSessionStore->makeKey( 'MWSession', $sessionInfo->getId() );
114 $authKey = $this->authenticatedSessionStore->makeKey( 'MWSession', $sessionInfo->getId() );
115
116 $authData = $this->authenticatedSessionStore->get( $authKey );
117 if ( !$this->authenticatedSessionStore->wasLastGetCached() ) {
118 $this->statsFactory->getCounter( 'sessionstore_nouserinfo_get_total' )
119 ->setLabel( 'type', 'authenticated' )
120 ->setLabel( 'status', $authData ? 'hit' : 'miss' )
121 ->increment();
122 }
123 if ( !$authData ) {
124 $anonData = $this->anonSessionStore->get( $anonKey );
125 if ( !$this->anonSessionStore->wasLastGetCached() ) {
126 $this->statsFactory->getCounter( 'sessionstore_nouserinfo_get_total' )
127 ->setLabel( 'type', 'anonymous' )
128 ->setLabel( 'status', $anonData ? 'hit' : 'miss' )
129 ->increment();
130 }
131 }
132
133 $anonUserName = $anonData['metadata']['userName'] ?? null;
134 $authUserName = $authData['metadata']['userName'] ?? null;
135
136 if ( $anonData && $anonUserName !== null ) {
137 // The data does not match the store!
138 // This is actually expected when the two stores are the same
139 // (which is useful for testing in production).
140 if ( !$this->sameBackend ) {
141 $this->logger->warning( 'No userInfo: authenticated data should not be in the anonymous store', [
142 'sessionInfo' => (string)$sessionInfo,
143 'exception' => new RuntimeException(),
144 ] );
145 }
146
147 $anonData = false;
148 $anonUserName = null;
149 }
150
151 if ( $authData && $authUserName === null ) {
152 if ( !$this->sameBackend ) {
153 $this->logger->warning( 'No userInfo: anonymous data should not be in the authenticated store', [
154 'sessionInfo' => (string)$sessionInfo,
155 'exception' => new RuntimeException(),
156 ] );
157 }
158 $authData = false;
159 }
160
161 if ( $anonData && $authData && !$this->sameBackend ) {
162 $this->logger->warning( 'Both stores should not have the same session data', [
163 'sessionInfo' => (string)$sessionInfo,
164 'exception' => new RuntimeException(),
165 ] );
166 }
167
168 if ( $authData ) {
169 return [ $this->authenticatedSessionStore, true ];
170 }
171
172 return [ $this->anonSessionStore, false ];
173 }
174
175 if ( !$userInfo->isAnon() ) {
176 // Hopefully, we can use the user info here to know if it's an anonymous user or
177 // and authenticated user. This will help us determine which store to use.
178 return [ $this->authenticatedSessionStore, true ];
179 }
180
181 return [ $this->anonSessionStore, false ];
182 }
183
192 public function get( SessionInfo $info ) {
193 $this->logger->debug( __METHOD__ . " was called for $info" );
194
195 [ $store, $isAuthenticated ] = $this->getActiveStore( $info );
196
197 $key = $store->makeKey( 'MWSession', $info->getId() );
198 $data = $store->get( $key );
199 $userName = $data['metadata']['userName'] ?? null;
200
201 if ( $isAuthenticated && $data && $userName === null ) {
202 $this->logger->warning( 'Store is authenticated, but the user associated to the data is anonymous', [
203 'sessionInfo' => (string)$info,
204 'user' => (string)$info->getUserInfo(),
205 'exception' => new RuntimeException(),
206 ] );
207 }
208
209 if ( !$isAuthenticated && $userName !== null ) {
210 $this->logger->warning( 'Store is anonymous, but the user associated to the data is authenticated', [
211 'sessionInfo' => (string)$info,
212 'user' => (string)$info->getUserInfo(),
213 'exception' => new RuntimeException(),
214 ] );
215 }
216
217 // We are interested in tracking reads from the actual store (uncached in-process)
218 // to know how many times we do actual store look-ups.
219 if ( !$store->wasLastGetCached() ) {
220 $this->statsFactory->getCounter( 'sessionstore_get_total' )
221 ->setLabel( 'type', $isAuthenticated ? self::STATS_LABEL_AUTH : self::STATS_LABEL_ANON )
222 ->increment();
223 }
224
225 return $data;
226 }
227
237 public function set( SessionInfo $info, $value, $exptime = 0, $flags = 0 ): void {
238 $this->logger->debug( __METHOD__ . " was called for $info" );
239
240 if ( $flags === BagOStuff::WRITE_CACHE_ONLY && $value === false ) {
241 // writing $value === false in the cache is only used by
242 // SessionManager::generateSessionId() to prevent an unnecessary store lookup.
243 // We don't know which store the ID is going to be used for in that case,
244 // so need to avoid calling getActiveStore() which would result in a store lookup
245 // and defeat the purpose. Since a new random key can't conflict with existing keys,
246 // no harm in just updating both caches.
247 $anonKey = $this->anonSessionStore->makeKey( 'MWSession', $info->getId() );
248 $authKey = $this->authenticatedSessionStore->makeKey( 'MWSession', $info->getId() );
249
250 $this->anonSessionStore->set( $anonKey, false, 0, BagOStuff::WRITE_CACHE_ONLY );
251 $this->authenticatedSessionStore->set( $authKey, false, 0, BagOStuff::WRITE_CACHE_ONLY );
252 return;
253 }
254
255 [ $store, $isAuthenticated ] = $this->getActiveStore( $info );
256
257 $key = $store->makeKey( 'MWSession', $info->getId() );
258 $userName = $value['metadata']['userName'] ?? null;
259
260 // SessionManager::generateSessionId() can perform a cache warming
261 // operation by setting `false` as the cache value. We don't want to
262 // log those.
263 if ( $value ) {
264 if ( $isAuthenticated && $userName === null ) {
265 $this->logger->warning( 'Store is authenticated, but the user associated to the data is anonymous', [
266 'sessionInfo' => (string)$info,
267 'user' => (string)$info->getUserInfo(),
268 'exception' => new RuntimeException(),
269 'flags' => (string)$flags
270 ] );
271 }
272
273 if ( !$isAuthenticated && $userName !== null ) {
274 $this->logger->warning( 'Store is anonymous, but the user associated to the data is authenticated', [
275 'sessionInfo' => (string)$info,
276 'user' => (string)$info->getUserInfo(),
277 'exception' => new RuntimeException(),
278 'flags' => (string)$flags
279 ] );
280 }
281 }
282
283 $store->set( $key, $value, $exptime, $flags );
284
285 if ( ( $flags & BagOStuff::WRITE_CACHE_ONLY ) === 0 ) {
286 $this->statsFactory->getCounter( 'sessionstore_set_total' )
287 ->setLabel( 'type', $isAuthenticated ? self::STATS_LABEL_AUTH : self::STATS_LABEL_ANON )
288 ->increment();
289 }
290 }
291
297 public function delete( SessionInfo $info ): void {
298 $this->logger->debug( __METHOD__ . " was called for $info" );
299
300 [ $store, $isAuthenticated ] = $this->getActiveStore( $info );
301 $key = $store->makeKey( 'MWSession', $info->getId() );
302
303 $store->delete( $key );
304
305 $this->statsFactory->getCounter( 'sessionstore_delete_total' )
306 ->setLabel( 'type', $isAuthenticated ? self::STATS_LABEL_AUTH : self::STATS_LABEL_ANON )
307 ->increment();
308 }
309
313 public function shutdown(): void {
314 if ( random_int( 1, 100 ) === 1 ) {
315 $this->logger->debug( 'Cleaning session store expired entries' );
316 $timeNow = wfTimestampNow();
317 $this->authenticatedSessionStore->deleteObjectsExpiringBefore( $timeNow );
318 $this->anonSessionStore->deleteObjectsExpiringBefore( $timeNow );
319 }
320 }
321}
wfTimestampNow()
Convenience function; returns MediaWiki timestamp for the present time.
if(!defined('MW_SETUP_CALLBACK'))
Definition WebStart.php:69
An implementation of a session store with two backends for storing anonymous and authenticated sessio...
shutdown()
Will be called during shutdown.void
__construct(BagOStuff $anonSessionStore, BagOStuff $authenticatedSessionStore, LoggerInterface $logger, StatsFactory $statsFactory)
Immutable value object returned by SessionProvider.
getId()
Return the session ID.
Abstract class for any ephemeral data store.
Definition BagOStuff.php:73
Wrap any BagOStuff and add an in-process memory cache to it.
This is the primary interface for validating metrics definitions, caching defined metrics,...
This is a session store abstraction layer, which can be used to read and write sessions to configured...