https://www.mediawiki.org/wiki/Special:Code/MediaWiki/108302
Revision: 108302
Author: raindrift
Date: 2012-01-07 01:53:27 +0000 (Sat, 07 Jan 2012)
Log Message:
-----------
Adding concurrency checking backend class
Note: This is not done! That's why it's in a branch!
Modified Paths:
--------------
branches/concurrency/includes/AutoLoader.php
branches/concurrency/includes/installer/MysqlUpdater.php
branches/concurrency/maintenance/tables.sql
Added Paths:
-----------
branches/concurrency/includes/ConcurrencyCheck.php
branches/concurrency/maintenance/archives/patch-concurrencycheck.sql
branches/concurrency/tests/phpunit/includes/ConcurrencyCheckTest.php
Modified: branches/concurrency/includes/AutoLoader.php
===================================================================
--- branches/concurrency/includes/AutoLoader.php 2012-01-07 01:45:06 UTC
(rev 108301)
+++ branches/concurrency/includes/AutoLoader.php 2012-01-07 01:53:27 UTC
(rev 108302)
@@ -43,6 +43,7 @@
'ChannelFeed' => 'includes/Feed.php',
'Collation' => 'includes/Collation.php',
'ConcatenatedGzipHistoryBlob' => 'includes/HistoryBlob.php',
+ 'ConcurrencyCheck' => 'includes/ConcurrencyCheck.php',
'ConfEditor' => 'includes/ConfEditor.php',
'ConfEditorParseError' => 'includes/ConfEditor.php',
'ConfEditorToken' => 'includes/ConfEditor.php',
@@ -52,7 +53,6 @@
'DeferredUpdates' => 'includes/DeferredUpdates.php',
'DerivativeRequest' => 'includes/WebRequest.php',
'DiffHistoryBlob' => 'includes/HistoryBlob.php',
-
'DoubleReplacer' => 'includes/StringUtils.php',
'DummyLinker' => 'includes/Linker.php',
'Dump7ZipOutput' => 'includes/Export.php',
Added: branches/concurrency/includes/ConcurrencyCheck.php
===================================================================
--- branches/concurrency/includes/ConcurrencyCheck.php
(rev 0)
+++ branches/concurrency/includes/ConcurrencyCheck.php 2012-01-07 01:53:27 UTC
(rev 108302)
@@ -0,0 +1,304 @@
+<?php
+
+/**
+ * Class for cooperative locking of web resources
+ *
+ * Each resource is identified by a combination of the "resource type" (the
application, the type
+ * of content, etc), and the resource's primary key or some other unique
numeric ID.
+ *
+ * Currently, a resource can only be checked out by a single user. Other
attempts to check it out result
+ * in the checkout failing. In the future, an option for multiple
simulataneous checkouts could be added
+ * without much trouble.
+ *
+ * This could be done with named locks, except then it would be impossible to
build a list of all the
+ * resources currently checked out for a given application. There's no good
way to construct a query
+ * that answers the question, "What locks do you have starting with [foo]"
This could be done really well
+ * with a concurrent, reliable, distributed key/value store, but we don't have
one of those right now.
+ *
+ * @author Ian Baker <[email protected]>
+ */
+class ConcurrencyCheck {
+
+
+ // TODO: docblock
+ public function __construct( $resourceType, $user, $expirationTime =
null ) {
+
+ // All database calls are to the master, since the whole point
of this class is maintaining
+ // concurrency. Most reads should come from cache anyway.
+ $this->dbw = wfGetDb( DB_MASTER );
+
+ $this->user = $user;
+ $this->resourceType = $resourceType;
+ $this->setExpirationTime( $expirationTime );
+ }
+
+ /**
+ * Check out a resource. This establishes an atomically generated,
cooperative lock
+ * on a key. The lock is tied to the current user.
+ *
+ * @var $record Integer containing the record id to check out
+ * @var $override Boolean (optional) describing whether to override an
existing checkout
+ * @return boolean
+ */
+ public function checkout( $record, $override = null ) {
+ $this->validateId( $record );
+ $dbw = $this->dbw;
+ $userId = $this->user->getId();
+
+ // attempt an insert, check success (this is atomic)
+ $insertError = null;
+ $res = $dbw->insert(
+ 'concurrencycheck',
+ array(
+ 'cc_resource_type' => $this->resourceType,
+ 'cc_record' => $record,
+ 'cc_user' => $userId,
+ 'cc_expiration' => time() +
$this->expirationTime,
+ ),
+ __METHOD__,
+ array('IGNORE')
+ );
+
+ // if the insert succeeded, checkout is done.
+ if( $dbw->affectedRows() === 1 ) {
+ //TODO: delete cache key
+ return true;
+ }
+
+ $dbw->begin();
+ $row = $dbw->selectRow(
+ 'concurrencycheck',
+ array( 'cc_user', 'cc_expiration' ),
+ array(
+ 'cc_resource_type' => $this->resourceType,
+ 'cc_record' => $record,
+ ),
+ __METHOD__,
+ array()
+ );
+
+ // not checked out by current user, checkout is unexpired,
override is unset
+ if( ! ( $override || $row->cc_user == $userId ||
$row->cc_expiration <= time() ) ) {
+ $dbw->rollback();
+ return false;
+ }
+
+ // execute a replace
+ $res = $dbw->replace(
+ 'concurrencycheck',
+ array( array('cc_resource_type', 'cc_record') ),
+ array(
+ 'cc_resource_type' => $this->resourceType,
+ 'cc_record' => $record,
+ 'cc_user' => $userId,
+ 'cc_expiration' => time() +
$this->expirationTime,
+ ),
+ __METHOD__
+ );
+
+ // TODO cache the result.
+
+ $dbw->commit();
+ return true;
+
+ // if insert succeeds, delete the cache key. don't make a new
one since they have to be created atomically.
+ //
+ // if insert fails:
+ // begin transaction
+ // select where key=key and expiration > now()
+ // if row is missing or user matches:
+ // execute a replace()
+ // overwrite the cache key (might as well, since this is
inside a transaction)
+ // commit
+ // if select returned an unexpired row owned by someone else,
return failure.
+
+ // optional: check to see if the current user already has the
resource checked out, and if so,
+ // return that checkout information instead. (does anyone want
that?)
+ }
+
+ /**
+ * Check in a resource. Only works if the resource is checked out by
the current user.
+ *
+ * @var $record Integer containing the record id to checkin
+ * @return Boolean
+ */
+ public function checkin( $record ) {
+ $this->validateId( $record );
+ $dbw = $this->dbw;
+ $userId = $this->user->getId();
+
+ $dbw->delete(
+ 'concurrencycheck',
+ array(
+ 'cc_resource_type' => $this->resourceType,
+ 'cc_record' => $record,
+ 'cc_user' => $userId, // only the owner can
perform a checkin
+ ),
+ __METHOD__,
+ array()
+ );
+
+ // check row count (this is atomic, select would not be)
+ if( $dbw->affectedRows() === 1 ) {
+ // TODO: remove record from cache
+ return true;
+ }
+
+ return false;
+
+ // delete the row, specifying the username in the where clause
(keeps users from checking in stuff that's not theirs).
+ // if a row was deleted:
+ // remove the record from memcache. (removing cache key
doesn't require atomicity)
+ // return true
+ // else
+ // return false
+
+ }
+
+ /**
+ * Remove all expired checkouts.
+ *
+ * @return Integer describing the number of records expired.
+ */
+ public function expire() {
+ $dbw = $this->dbw;
+
+ $now = time();
+
+ // get the rows to remove from cache.
+ $res = $dbw->select(
+ 'concurrencycheck',
+ array( '*' ),
+ array(
+ 'cc_expiration <= ' . $now,
+ ),
+ __METHOD__,
+ array()
+ );
+
+ // remove the rows from the db
+ $dbw->delete(
+ 'concurrencycheck',
+ array(
+ 'cc_expiration <= ' . $now,
+ ),
+ __METHOD__,
+ array()
+ );
+
+ // TODO: fetch the rows here, remove them from cache.
+
+ // return the number of rows removed.
+ return $dbw->affectedRows();
+
+ // grab a unixtime.
+ // select all rows where expiration < time
+ // delete all rows where expiration < time
+ // remove selected rows from memcache
+ //
+ // previous idea, probably wrong:
+ // select all expired rows.
+ // foreach( expired )
+ // delete row where id=id and expiration < now() (accounts
for updates)
+ // if delete succeeded, remove cache key (txn not required,
since removing cache key doesn't require atomicity)
+ // (sadly, this must be many deletes to coordinate removal
from memcache)
+ // (is it necessary to remove expired cache entries?)
+ }
+
+ public function status( $keys ) {
+ $dbw = $this->dbw;
+
+ // validate keys.
+ foreach( $keys as $key ) {
+ $this->validateId( $key );
+ }
+
+ $checkouts = array();
+
+ // TODO: check cache before selecting
+
+ // check for each of $keys in cache (also check expiration)
+ // build a list of the missing ones.
+ // run the below select with that list.
+ // when finished, re-add any missing keys with the 'invalid'
status.
+
+ $this->expire();
+
+ $dbw->begin();
+ $res = $dbw->select(
+ 'concurrencycheck',
+ array( '*' ),
+ array(
+ 'cc_resource_type' => $this->resourceType,
+ 'cc_record IN (' . implode( ',', $keys ) . ')',
+ 'cc_expiration > unix_timestamp(now())'
+ ),
+ __METHOD__,
+ array()
+ );
+
+ while( $res && $record = $res->fetchRow() ) {
+ $record['status'] = 'valid';
+ # cache the row.
+ $checkouts[ $record['cc_record'] ] = $record;
+ }
+
+ // if a key was passed in but has no (unexpired) checkout,
include it in the
+ // result set to make things easier and more consistent on the
client-side.
+ foreach( $keys as $key ) {
+ if( ! array_key_exists( $key, $checkouts ) ) {
+ $checkouts[$key]['status'] = 'invalid';
+ }
+ }
+
+ return $checkouts;
+
+ // fetch keys from cache or db (keys are an array)
+ //
+ // for all unexpired keys present in cache, store cached return
value for returning later.
+ //
+ // if some keys remain (missing from cache or expired):
+ // execute expire() to make sure db records are cleared
+ // for all remaining keys:
+ // begin transaction
+ // select rows where key in (keys) and expiration > now()
+ // overwrite any memcache entry
+ // commit
+ // return values that were added to cache, plus values pulled
from cache
+ }
+
+ public function list() {
+
+ }
+
+ public function setUser ( $user ) {
+ $this->user = $user;
+ }
+
+ public function setExpirationTime ( $expirationTime = null ) {
+ // check to make sure the time is digits only, so it can be
used in queries
+ // negative number are allowed, though mostly only used for
testing
+ // TODO: define maximum and minimum times in configuration, to
prevent DoS
+ if( $expirationTime && preg_match('/^[\d-]+$/',
$expirationTime) ) {
+ $this->expirationTime = $expirationTime; // the amount
of time before a checkout expires.
+ } else {
+ $this->expirationTime = 60 * 15; // 15 mins. TODO: make
a configurable default for this.
+ }
+ }
+
+ /**
+ * Check to make sure a record ID is numeric, throw an exception if not.
+ *
+ * @var $record Integer
+ * @throws ConcurrencyCheckBadRecordIdException
+ * @return boolean
+ */
+ private static function validateId ( $record ) {
+ if( ! preg_match('/^\d+$/', $record) ) {
+ throw new ConcurrencyCheckBadRecordIdException( 'Record
ID ' . $record . ' must be a positive integer' );
+ }
+ return true;
+ }
+}
+
+class ConcurrencyCheckBadRecordIdException extends MWException {};
Property changes on: branches/concurrency/includes/ConcurrencyCheck.php
___________________________________________________________________
Added: svn:eol-style
+ native
Modified: branches/concurrency/includes/installer/MysqlUpdater.php
===================================================================
--- branches/concurrency/includes/installer/MysqlUpdater.php 2012-01-07
01:45:06 UTC (rev 108301)
+++ branches/concurrency/includes/installer/MysqlUpdater.php 2012-01-07
01:53:27 UTC (rev 108302)
@@ -192,6 +192,7 @@
array( 'modifyField', 'user', 'ug_group',
'patch-ug_group-length-increase.sql' ),
array( 'addField', 'uploadstash', 'us_chunk_inx',
'patch-uploadstash_chunk.sql' ),
array( 'addfield', 'job', 'job_timestamp',
'patch-jobs-add-timestamp.sql' ),
+ array( 'addTable', 'concurrencycheck',
'patch-concurrencycheck.sql'),
);
}
Added: branches/concurrency/maintenance/archives/patch-concurrencycheck.sql
===================================================================
--- branches/concurrency/maintenance/archives/patch-concurrencycheck.sql
(rev 0)
+++ branches/concurrency/maintenance/archives/patch-concurrencycheck.sql
2012-01-07 01:53:27 UTC (rev 108302)
@@ -0,0 +1,25 @@
+--
+-- Store atomic locking information for web resources, to permit
+-- UI that warns users when concurrently editing things that aren't
+-- concurrently editable.
+--
+CREATE TABLE /*_*/concurrencycheck (
+ -- a string describing the resource or application being checked out.
+ cc_resource_type varchar(255) NOT NULL,
+
+ -- the (numeric) ID of the record that's being checked out.
+ cc_record int unsigned NOT NULL,
+
+ -- the user who has control of the resource
+ cc_user int unsigned NOT NULL,
+
+ -- the date/time on which this record expires
+ cc_expiration varbinary(14) not null
+
+) /*$wgDBTableOptions*/;
+-- composite pk.
+CREATE UNIQUE INDEX /*i*/cc_resource_record ON /*_*/concurrencycheck
(cc_resource_type, cc_record);
+-- sometimes there's a delete based on userid.
+CREATE INDEX /*i*/cc_user ON /*_*/concurrencycheck (cc_user);
+-- sometimes there's a delete based on expiration
+CREATE INDEX /*i*/cc_expiration ON /*_*/concurrencycheck (cc_expiration);
Property changes on:
branches/concurrency/maintenance/archives/patch-concurrencycheck.sql
___________________________________________________________________
Added: svn:eol-style
+ native
Modified: branches/concurrency/maintenance/tables.sql
===================================================================
--- branches/concurrency/maintenance/tables.sql 2012-01-07 01:45:06 UTC (rev
108301)
+++ branches/concurrency/maintenance/tables.sql 2012-01-07 01:53:27 UTC (rev
108302)
@@ -1483,4 +1483,31 @@
-- Should cover *most* configuration - strings, ints, bools, etc.
CREATE INDEX /*i*/cf_name_value ON /*_*/config (cf_name,cf_value(255));
+--
+-- Store atomic locking information for web resources, to permit
+-- UI that warns users when concurrently editing things that aren't
+-- concurrently editable.
+--
+CREATE TABLE /*_*/concurrencycheck (
+ -- a string describing the resource or application being checked out.
+ cc_resource_type varchar(255) NOT NULL,
+
+ -- the (numeric) ID of the record that's being checked out.
+ cc_record int unsigned NOT NULL,
+
+ -- the user who has control of the resource
+ cc_user int unsigned NOT NULL,
+
+ -- the date/time on which this record expires
+ cc_expiration varbinary(14) not null
+
+) /*$wgDBTableOptions*/;
+-- composite pk.
+CREATE UNIQUE INDEX /*i*/cc_resource_record ON /*_*/concurrencycheck
(cc_resource_type, cc_record);
+-- sometimes there's a delete based on userid.
+CREATE INDEX /*i*/cc_user ON /*_*/concurrencycheck (cc_user);
+-- sometimes there's a delete based on expiration
+CREATE INDEX /*i*/cc_expiration ON /*_*/concurrencycheck (cc_expiration);
+
+
-- vim: sw=2 sts=2 et
Added: branches/concurrency/tests/phpunit/includes/ConcurrencyCheckTest.php
===================================================================
--- branches/concurrency/tests/phpunit/includes/ConcurrencyCheckTest.php
(rev 0)
+++ branches/concurrency/tests/phpunit/includes/ConcurrencyCheckTest.php
2012-01-07 01:53:27 UTC (rev 108302)
@@ -0,0 +1,93 @@
+<?php
+
+class ConcurrencyCheckTest extends MediaWikiTestCase {
+ /**
+ * @var Array of test users
+ */
+ public static $users;
+
+ // Prepare test environment
+
+ public function setUp() {
+ parent::setUp();
+
+ self::$users = array(
+ 'user1' => new ApiTestUser(
+ 'Concurrencychecktestuser1',
+ 'ConcurrencyCheck Test User 1',
+ '[email protected]',
+ array()
+ ),
+ 'user2' => new ApiTestUser(
+ 'Concurrencychecktestuser2',
+ 'ConcurrencyCheck Test User 2',
+ '[email protected]',
+ array()
+ ),
+ );
+ }
+
+ public function tearDown() {
+ parent::tearDown();
+
+ // perhaps clean up all the cruft left in the db
+ }
+
+ // Actual tests from here down
+
+ public function testCheckoutCheckin() {
+ $first = new ConcurrencyCheck( 'CCUnitTest',
self::$users['user1']->user );
+ $second = new ConcurrencyCheck( 'CCUnitTest',
self::$users['user2']->user );
+ $testKey = 1337;
+
+ // clean up after any previously failed tests
+ $first->checkin($testKey);
+ $second->checkin($testKey);
+
+ // tests
+ $this->assertTrue( $first->checkout($testKey), "Initial
checkout" );
+ $this->assertFalse( $second->checkout($testKey), "Checkout of
locked resource fails as different user" );
+ $this->assertTrue( $first->checkout($testKey), "Checkout of
locked resource succeeds as original user" );
+ $this->assertFalse( $second->checkin($testKey), "Checkin of
locked resource fails as different user" );
+ $this->assertTrue( $first->checkin($testKey), "Checkin of
locked resource succeeds as original user" );
+ $second->setExpirationTime(-5);
+ $this->assertTrue( $second->checkout($testKey), "Checked-in
resource is now available to second user" );
+ $second->setExpirationTime();
+ $this->assertTrue( $first->checkout($testKey), "Checkout of
expired resource succeeds as first user");
+ $this->assertTrue( $second->checkout($testKey, true), "Checkout
override" );
+ $this->assertFalse( $first->checkout($testKey), "Checkout of
overriden resource fails as different user" );
+
+ // cleanup
+ $this->assertTrue( $second->checkin($testKey), "Checkin of
record with changed ownership" );
+ }
+
+ public function testExpire() {
+ $cc = new ConcurrencyCheck( 'CCUnitTest',
self::$users['user1']->user );
+ $cc->setExpirationTime(-1);
+ $cc->checkout( 1338 ); // these numbers are test record ids.
+ $cc->checkout( 1339 );
+ $cc->setExpirationTime();
+ $cc->checkout( 13310 );
+
+ // tests
+ $this->assertEquals( 2, $cc->expire(), "Resource expiration" );
+ $this->assertTrue( $cc->checkin( 13310 ), "Checkin succeeds
after expiration" );
+ }
+
+ public function testStatus() {
+ $cc = new ConcurrencyCheck( 'CCUnitTest',
self::$users['user1']->user );
+ $cc->checkout( 1337 );
+ $cc->checkout( 1338 );
+ $cc->setExpirationTime(-5);
+ $cc->checkout( 1339 );
+ $cc->setExpirationTime();
+
+ // tests
+ $output = $cc->status( array( 1337, 1338, 1339, 13310 ) );
+ $this->assertEquals( true, is_array( $output ), "Status returns
values" );
+ $this->assertEquals( 4, count( $output ), "Output has the
correct number of records" );
+ $this->assertEquals( 'valid', $output[1337]['status'], "Current
checkouts are listed as valid");
+ $this->assertEquals( 'invalid', $output[1339]['status'],
"Expired checkouts are invalid");
+ $this->assertEquals( 'invalid', $output[13310]['status'],
"Missing checkouts are invalid");
+ }
+}
\ No newline at end of file
Property changes on:
branches/concurrency/tests/phpunit/includes/ConcurrencyCheckTest.php
___________________________________________________________________
Added: svn:eol-style
+ native
_______________________________________________
MediaWiki-CVS mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-cvs