1
0
Fork 0
mirror of https://we.phorge.it/source/phorge.git synced 2024-11-09 16:32:39 +01:00

Execute Maniphest batch edits in the background with a web UI progress bar

Summary:
Ref T8637. This does nothing interesting, just has empty scaffolding for a bulk job queue.

Basic idea is that when you do something like a batch edit in Maniphest, we:

  - Create a BulkJob with all the details.
  - Queue a worker to start the job.
  - Send you to a progress bar page for the job.

In the background:

  - The "start job" worker creates a ton of Task objects, then queues worker tasks to do the work.

In the foreground:

  - Fancy ajax animates the progress bar and it goes wooosh.

In general:

  - Big jobs actually work.
  - Jobs get logged.
  - You can monitor jobs.
  - Terrible junk like T8637 should be much harder to write and much easier to catch and diagnose.

Test Plan:
No interesting code/beahavior yet. Clean `storage adjust`.

{F526411}

Reviewers: chad, btrahan

Reviewed By: btrahan

Subscribers: epriestley

Maniphest Tasks: T8637

Differential Revision: https://secure.phabricator.com/D13392
This commit is contained in:
epriestley 2015-06-23 13:36:16 -07:00
parent 716bd4e4b4
commit 3215899925
31 changed files with 1767 additions and 244 deletions

View file

@ -55,6 +55,7 @@ return array(
'rsrc/css/application/conpherence/widget-pane.css' => '2af42ebe',
'rsrc/css/application/contentsource/content-source-view.css' => '4b8b05d4',
'rsrc/css/application/countdown/timer.css' => '86b7b0a0',
'rsrc/css/application/daemon/bulk-job.css' => 'df9c1d4a',
'rsrc/css/application/dashboard/dashboard.css' => '17937d22',
'rsrc/css/application/diff/inline-comment-summary.css' => '51efda3a',
'rsrc/css/application/differential/add-comment.css' => 'c47f8c40',
@ -343,6 +344,7 @@ return array(
'rsrc/js/application/conpherence/behavior-quicksand-blacklist.js' => '7927a7d3',
'rsrc/js/application/conpherence/behavior-widget-pane.js' => '93568464',
'rsrc/js/application/countdown/timer.js' => 'e4cc26b3',
'rsrc/js/application/daemon/behavior-bulk-job-reload.js' => 'edf8a145',
'rsrc/js/application/dashboard/behavior-dashboard-async-panel.js' => '469c0d9e',
'rsrc/js/application/dashboard/behavior-dashboard-move-panels.js' => '82439934',
'rsrc/js/application/dashboard/behavior-dashboard-query-panel-select.js' => '453c5375',
@ -495,6 +497,7 @@ return array(
'aphront-two-column-view-css' => '16ab3ad2',
'aphront-typeahead-control-css' => '0e403212',
'auth-css' => '44975d4b',
'bulk-job-css' => 'df9c1d4a',
'calendar-icon-css' => '98ce946d',
'changeset-view-manager' => '58562350',
'conduit-api-css' => '7bc725c4',
@ -541,6 +544,7 @@ return array(
'javelin-behavior-aphront-more' => 'a80d0378',
'javelin-behavior-audio-source' => '59b251eb',
'javelin-behavior-audit-preview' => 'd835b03a',
'javelin-behavior-bulk-job-reload' => 'edf8a145',
'javelin-behavior-choose-control' => '6153c708',
'javelin-behavior-config-reorder-fields' => 'b6993408',
'javelin-behavior-conpherence-drag-and-drop-photo' => 'cf86d16a',
@ -1925,6 +1929,10 @@ return array(
'javelin-uri',
'phabricator-notification',
),
'edf8a145' => array(
'javelin-behavior',
'javelin-uri',
),
'eeaa9e5a' => array(
'javelin-behavior',
'javelin-stratcom',

View file

@ -0,0 +1,15 @@
CREATE TABLE {$NAMESPACE}_worker.worker_bulkjob (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
phid VARBINARY(64) NOT NULL,
authorPHID VARBINARY(64) NOT NULL,
jobTypeKey VARCHAR(32) COLLATE {$COLLATE_TEXT} NOT NULL,
status VARCHAR(32) COLLATE {$COLLATE_TEXT} NOT NULL,
parameters LONGTEXT COLLATE {$COLLATE_TEXT} NOT NULL,
size INT UNSIGNED NOT NULL,
dateCreated INT UNSIGNED NOT NULL,
dateModified INT UNSIGNED NOT NULL,
UNIQUE KEY `key_phid` (phid),
KEY `key_type` (jobTypeKey),
KEY `key_author` (authorPHID),
KEY `key_status` (status)
) ENGINE=InnoDB, COLLATE {$COLLATE_TEXT};

View file

@ -0,0 +1,9 @@
CREATE TABLE {$NAMESPACE}_worker.worker_bulktask (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
bulkJobPHID VARBINARY(64) NOT NULL,
objectPHID VARBINARY(64) NOT NULL,
status VARCHAR(32) COLLATE {$COLLATE_TEXT} NOT NULL,
data LONGTEXT COLLATE {$COLLATE_TEXT} NOT NULL,
KEY `key_job` (bulkJobPHID, status),
KEY `key_object` (objectPHID)
) ENGINE=InnoDB, COLLATE {$COLLATE_TEXT};

View file

@ -0,0 +1,19 @@
CREATE TABLE {$NAMESPACE}_worker.worker_bulkjobtransaction (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
phid VARBINARY(64) NOT NULL,
authorPHID VARBINARY(64) NOT NULL,
objectPHID VARBINARY(64) NOT NULL,
viewPolicy VARBINARY(64) NOT NULL,
editPolicy VARBINARY(64) NOT NULL,
commentPHID VARBINARY(64) DEFAULT NULL,
commentVersion INT UNSIGNED NOT NULL,
transactionType VARCHAR(32) COLLATE {$COLLATE_TEXT} NOT NULL,
oldValue LONGTEXT COLLATE {$COLLATE_TEXT} NOT NULL,
newValue LONGTEXT COLLATE {$COLLATE_TEXT} NOT NULL,
contentSource LONGTEXT COLLATE {$COLLATE_TEXT} NOT NULL,
metadata LONGTEXT COLLATE {$COLLATE_TEXT} NOT NULL,
dateCreated INT UNSIGNED NOT NULL,
dateModified INT UNSIGNED NOT NULL,
UNIQUE KEY `key_phid` (`phid`),
KEY `key_object` (`objectPHID`)
) ENGINE=InnoDB, COLLATE {$COLLATE_TEXT};

View file

@ -0,0 +1,16 @@
CREATE TABLE {$NAMESPACE}_worker.edge (
src VARBINARY(64) NOT NULL,
type INT UNSIGNED NOT NULL,
dst VARBINARY(64) NOT NULL,
dateCreated INT UNSIGNED NOT NULL,
seq INT UNSIGNED NOT NULL,
dataID INT UNSIGNED,
PRIMARY KEY (src, type, dst),
KEY `src` (src, type, dateCreated, seq),
UNIQUE KEY `key_dst` (dst, type, src)
) ENGINE=InnoDB, COLLATE {$COLLATE_TEXT};
CREATE TABLE {$NAMESPACE}_worker.edgedata (
id INT UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT,
data LONGTEXT NOT NULL COLLATE {$COLLATE_TEXT}
) ENGINE=InnoDB, COLLATE {$COLLATE_TEXT};

View file

@ -1082,6 +1082,7 @@ phutil_register_library_map(array(
'ManiphestTaskDependedOnByTaskEdgeType' => 'applications/maniphest/edge/ManiphestTaskDependedOnByTaskEdgeType.php',
'ManiphestTaskDependsOnTaskEdgeType' => 'applications/maniphest/edge/ManiphestTaskDependsOnTaskEdgeType.php',
'ManiphestTaskDetailController' => 'applications/maniphest/controller/ManiphestTaskDetailController.php',
'ManiphestTaskEditBulkJobType' => 'applications/maniphest/bulk/ManiphestTaskEditBulkJobType.php',
'ManiphestTaskEditController' => 'applications/maniphest/controller/ManiphestTaskEditController.php',
'ManiphestTaskHasCommitEdgeType' => 'applications/maniphest/edge/ManiphestTaskHasCommitEdgeType.php',
'ManiphestTaskHasMockEdgeType' => 'applications/maniphest/edge/ManiphestTaskHasMockEdgeType.php',
@ -1708,6 +1709,9 @@ phutil_register_library_map(array(
'PhabricatorCustomFieldStringIndexStorage' => 'infrastructure/customfield/storage/PhabricatorCustomFieldStringIndexStorage.php',
'PhabricatorCustomHeaderConfigType' => 'applications/config/custom/PhabricatorCustomHeaderConfigType.php',
'PhabricatorDaemon' => 'infrastructure/daemon/PhabricatorDaemon.php',
'PhabricatorDaemonBulkJobListController' => 'applications/daemon/controller/PhabricatorDaemonBulkJobListController.php',
'PhabricatorDaemonBulkJobMonitorController' => 'applications/daemon/controller/PhabricatorDaemonBulkJobMonitorController.php',
'PhabricatorDaemonBulkJobViewController' => 'applications/daemon/controller/PhabricatorDaemonBulkJobViewController.php',
'PhabricatorDaemonConsoleController' => 'applications/daemon/controller/PhabricatorDaemonConsoleController.php',
'PhabricatorDaemonController' => 'applications/daemon/controller/PhabricatorDaemonController.php',
'PhabricatorDaemonDAO' => 'applications/daemon/storage/PhabricatorDaemonDAO.php',
@ -2820,6 +2824,19 @@ phutil_register_library_map(array(
'PhabricatorWorkerActiveTask' => 'infrastructure/daemon/workers/storage/PhabricatorWorkerActiveTask.php',
'PhabricatorWorkerArchiveTask' => 'infrastructure/daemon/workers/storage/PhabricatorWorkerArchiveTask.php',
'PhabricatorWorkerArchiveTaskQuery' => 'infrastructure/daemon/workers/query/PhabricatorWorkerArchiveTaskQuery.php',
'PhabricatorWorkerBulkJob' => 'infrastructure/daemon/workers/storage/PhabricatorWorkerBulkJob.php',
'PhabricatorWorkerBulkJobCreateWorker' => 'infrastructure/daemon/workers/bulk/PhabricatorWorkerBulkJobCreateWorker.php',
'PhabricatorWorkerBulkJobEditor' => 'infrastructure/daemon/workers/editor/PhabricatorWorkerBulkJobEditor.php',
'PhabricatorWorkerBulkJobPHIDType' => 'infrastructure/daemon/workers/phid/PhabricatorWorkerBulkJobPHIDType.php',
'PhabricatorWorkerBulkJobQuery' => 'infrastructure/daemon/workers/query/PhabricatorWorkerBulkJobQuery.php',
'PhabricatorWorkerBulkJobSearchEngine' => 'infrastructure/daemon/workers/query/PhabricatorWorkerBulkJobSearchEngine.php',
'PhabricatorWorkerBulkJobTaskWorker' => 'infrastructure/daemon/workers/bulk/PhabricatorWorkerBulkJobTaskWorker.php',
'PhabricatorWorkerBulkJobTestCase' => 'infrastructure/daemon/workers/__tests__/PhabricatorWorkerBulkJobTestCase.php',
'PhabricatorWorkerBulkJobTransaction' => 'infrastructure/daemon/workers/storage/PhabricatorWorkerBulkJobTransaction.php',
'PhabricatorWorkerBulkJobTransactionQuery' => 'infrastructure/daemon/workers/query/PhabricatorWorkerBulkJobTransactionQuery.php',
'PhabricatorWorkerBulkJobType' => 'infrastructure/daemon/workers/bulk/PhabricatorWorkerBulkJobType.php',
'PhabricatorWorkerBulkJobWorker' => 'infrastructure/daemon/workers/bulk/PhabricatorWorkerBulkJobWorker.php',
'PhabricatorWorkerBulkTask' => 'infrastructure/daemon/workers/storage/PhabricatorWorkerBulkTask.php',
'PhabricatorWorkerDAO' => 'infrastructure/daemon/workers/storage/PhabricatorWorkerDAO.php',
'PhabricatorWorkerLeaseQuery' => 'infrastructure/daemon/workers/query/PhabricatorWorkerLeaseQuery.php',
'PhabricatorWorkerManagementCancelWorkflow' => 'infrastructure/daemon/workers/management/PhabricatorWorkerManagementCancelWorkflow.php',
@ -2829,6 +2846,7 @@ phutil_register_library_map(array(
'PhabricatorWorkerManagementRetryWorkflow' => 'infrastructure/daemon/workers/management/PhabricatorWorkerManagementRetryWorkflow.php',
'PhabricatorWorkerManagementWorkflow' => 'infrastructure/daemon/workers/management/PhabricatorWorkerManagementWorkflow.php',
'PhabricatorWorkerPermanentFailureException' => 'infrastructure/daemon/workers/exception/PhabricatorWorkerPermanentFailureException.php',
'PhabricatorWorkerSchemaSpec' => 'infrastructure/daemon/workers/storage/PhabricatorWorkerSchemaSpec.php',
'PhabricatorWorkerTask' => 'infrastructure/daemon/workers/storage/PhabricatorWorkerTask.php',
'PhabricatorWorkerTaskData' => 'infrastructure/daemon/workers/storage/PhabricatorWorkerTaskData.php',
'PhabricatorWorkerTaskDetailController' => 'applications/daemon/controller/PhabricatorWorkerTaskDetailController.php',
@ -4589,6 +4607,7 @@ phutil_register_library_map(array(
'ManiphestTaskDependedOnByTaskEdgeType' => 'PhabricatorEdgeType',
'ManiphestTaskDependsOnTaskEdgeType' => 'PhabricatorEdgeType',
'ManiphestTaskDetailController' => 'ManiphestController',
'ManiphestTaskEditBulkJobType' => 'PhabricatorWorkerBulkJobType',
'ManiphestTaskEditController' => 'ManiphestController',
'ManiphestTaskHasCommitEdgeType' => 'PhabricatorEdgeType',
'ManiphestTaskHasMockEdgeType' => 'PhabricatorEdgeType',
@ -5300,6 +5319,9 @@ phutil_register_library_map(array(
'PhabricatorCustomFieldStringIndexStorage' => 'PhabricatorCustomFieldIndexStorage',
'PhabricatorCustomHeaderConfigType' => 'PhabricatorConfigOptionType',
'PhabricatorDaemon' => 'PhutilDaemon',
'PhabricatorDaemonBulkJobListController' => 'PhabricatorDaemonController',
'PhabricatorDaemonBulkJobMonitorController' => 'PhabricatorDaemonController',
'PhabricatorDaemonBulkJobViewController' => 'PhabricatorDaemonController',
'PhabricatorDaemonConsoleController' => 'PhabricatorDaemonController',
'PhabricatorDaemonController' => 'PhabricatorController',
'PhabricatorDaemonDAO' => 'PhabricatorLiskDAO',
@ -6603,6 +6625,25 @@ phutil_register_library_map(array(
'PhabricatorWorkerActiveTask' => 'PhabricatorWorkerTask',
'PhabricatorWorkerArchiveTask' => 'PhabricatorWorkerTask',
'PhabricatorWorkerArchiveTaskQuery' => 'PhabricatorQuery',
'PhabricatorWorkerBulkJob' => array(
'PhabricatorWorkerDAO',
'PhabricatorPolicyInterface',
'PhabricatorSubscribableInterface',
'PhabricatorApplicationTransactionInterface',
'PhabricatorDestructibleInterface',
),
'PhabricatorWorkerBulkJobCreateWorker' => 'PhabricatorWorkerBulkJobWorker',
'PhabricatorWorkerBulkJobEditor' => 'PhabricatorApplicationTransactionEditor',
'PhabricatorWorkerBulkJobPHIDType' => 'PhabricatorPHIDType',
'PhabricatorWorkerBulkJobQuery' => 'PhabricatorCursorPagedPolicyAwareQuery',
'PhabricatorWorkerBulkJobSearchEngine' => 'PhabricatorApplicationSearchEngine',
'PhabricatorWorkerBulkJobTaskWorker' => 'PhabricatorWorkerBulkJobWorker',
'PhabricatorWorkerBulkJobTestCase' => 'PhabricatorTestCase',
'PhabricatorWorkerBulkJobTransaction' => 'PhabricatorApplicationTransaction',
'PhabricatorWorkerBulkJobTransactionQuery' => 'PhabricatorApplicationTransactionQuery',
'PhabricatorWorkerBulkJobType' => 'Phobject',
'PhabricatorWorkerBulkJobWorker' => 'PhabricatorWorker',
'PhabricatorWorkerBulkTask' => 'PhabricatorWorkerDAO',
'PhabricatorWorkerDAO' => 'PhabricatorLiskDAO',
'PhabricatorWorkerLeaseQuery' => 'PhabricatorQuery',
'PhabricatorWorkerManagementCancelWorkflow' => 'PhabricatorWorkerManagementWorkflow',
@ -6612,6 +6653,7 @@ phutil_register_library_map(array(
'PhabricatorWorkerManagementRetryWorkflow' => 'PhabricatorWorkerManagementWorkflow',
'PhabricatorWorkerManagementWorkflow' => 'PhabricatorManagementWorkflow',
'PhabricatorWorkerPermanentFailureException' => 'Exception',
'PhabricatorWorkerSchemaSpec' => 'PhabricatorConfigSchemaSpec',
'PhabricatorWorkerTask' => 'PhabricatorWorkerDAO',
'PhabricatorWorkerTaskData' => 'PhabricatorWorkerDAO',
'PhabricatorWorkerTaskDetailController' => 'PhabricatorDaemonController',

View file

@ -46,6 +46,15 @@ final class PhabricatorDaemonsApplication extends PhabricatorApplication {
'(?P<id>[1-9]\d*)/' => 'PhabricatorDaemonLogViewController',
),
'event/(?P<id>[1-9]\d*)/' => 'PhabricatorDaemonLogEventViewController',
'bulk/' => array(
'(?:query/(?P<queryKey>[^/]+)/)?' =>
'PhabricatorDaemonBulkJobListController',
'monitor/(?P<id>\d+)/' =>
'PhabricatorDaemonBulkJobMonitorController',
'view/(?P<id>\d+)/' =>
'PhabricatorDaemonBulkJobViewController',
),
),
);
}

View file

@ -0,0 +1,31 @@
<?php
final class PhabricatorDaemonBulkJobListController
extends PhabricatorDaemonController {
public function shouldAllowPublic() {
return true;
}
public function handleRequest(AphrontRequest $request) {
$controller = id(new PhabricatorApplicationSearchController())
->setQueryKey($request->getURIData('queryKey'))
->setSearchEngine(new PhabricatorWorkerBulkJobSearchEngine())
->setNavigation($this->buildSideNavView());
return $this->delegateToController($controller);
}
protected function buildSideNavView($for_app = false) {
$user = $this->getRequest()->getUser();
$nav = new AphrontSideNavFilterView();
$nav->setBaseURI(new PhutilURI($this->getApplicationURI()));
id(new PhabricatorWorkerBulkJobSearchEngine())
->setViewer($user)
->addNavigationItems($nav->getMenu());
$nav->selectFilter(null);
return $nav;
}
}

View file

@ -0,0 +1,165 @@
<?php
final class PhabricatorDaemonBulkJobMonitorController
extends PhabricatorDaemonController {
public function shouldAllowPublic() {
return true;
}
public function handleRequest(AphrontRequest $request) {
$viewer = $this->getViewer();
$job = id(new PhabricatorWorkerBulkJobQuery())
->setViewer($viewer)
->withIDs(array($request->getURIData('id')))
->executeOne();
if (!$job) {
return new Aphront404Response();
}
// If the user clicks "Continue" on a completed job, take them back to
// whatever application sent them here.
if ($request->getStr('done')) {
if ($request->isFormPost()) {
$done_uri = $job->getDoneURI();
return id(new AphrontRedirectResponse())->setURI($done_uri);
}
}
$title = pht('Bulk Job %d', $job->getID());
if ($job->getStatus() == PhabricatorWorkerBulkJob::STATUS_CONFIRM) {
$can_edit = PhabricatorPolicyFilter::hasCapability(
$viewer,
$job,
PhabricatorPolicyCapability::CAN_EDIT);
if ($can_edit) {
if ($request->isFormPost()) {
$type_status = PhabricatorWorkerBulkJobTransaction::TYPE_STATUS;
$xactions = array();
$xactions[] = id(new PhabricatorWorkerBulkJobTransaction())
->setTransactionType($type_status)
->setNewValue(PhabricatorWorkerBulkJob::STATUS_WAITING);
$editor = id(new PhabricatorWorkerBulkJobEditor())
->setActor($viewer)
->setContentSourceFromRequest($request)
->setContinueOnMissingFields(true)
->applyTransactions($job, $xactions);
return id(new AphrontRedirectResponse())
->setURI($job->getMonitorURI());
} else {
return $this->newDialog()
->setTitle(pht('Confirm Bulk Job'))
->appendParagraph($job->getDescriptionForConfirm())
->appendParagraph(
pht('Start work on this bulk job?'))
->addCancelButton($job->getManageURI(), pht('Details'))
->addSubmitButton(pht('Start Work'));
}
} else {
return $this->newDialog()
->setTitle(pht('Waiting For Confirmation'))
->appendParagraph(
pht(
'This job is waiting for confirmation before work begins.'))
->addCancelButotn($job->getManageURI(), pht('Details'));
}
}
$dialog = $this->newDialog()
->setTitle(pht('%s: %s', $title, $job->getStatusName()))
->addCancelButton($job->getManageURI(), pht('Details'));
switch ($job->getStatus()) {
case PhabricatorWorkerBulkJob::STATUS_WAITING:
$dialog->appendParagraph(
pht('This job is waiting for tasks to be queued.'));
break;
case PhabricatorWorkerBulkJob::STATUS_RUNNING:
$dialog->appendParagraph(
pht('This job is running.'));
break;
case PhabricatorWorkerBulkJob::STATUS_COMPLETE:
$dialog->appendParagraph(
pht('This job is complete.'));
break;
}
$counts = $job->loadTaskStatusCounts();
if ($counts) {
$dialog->appendParagraph($this->renderProgress($counts));
}
switch ($job->getStatus()) {
case PhabricatorWorkerBulkJob::STATUS_COMPLETE:
$dialog->addHiddenInput('done', true);
$dialog->addSubmitButton(pht('Continue'));
break;
default:
Javelin::initBehavior('bulk-job-reload');
break;
}
return $dialog;
}
private function renderProgress(array $counts) {
$this->requireResource('bulk-job-css');
$states = array(
PhabricatorWorkerBulkTask::STATUS_DONE => array(
'class' => 'bulk-job-progress-slice-green',
),
PhabricatorWorkerBulkTask::STATUS_RUNNING => array(
'class' => 'bulk-job-progress-slice-blue',
),
PhabricatorWorkerBulkTask::STATUS_WAITING => array(
'class' => 'bulk-job-progress-slice-empty',
),
PhabricatorWorkerBulkTask::STATUS_FAIL => array(
'class' => 'bulk-job-progress-slice-red',
),
);
$total = array_sum($counts);
$offset = 0;
$bars = array();
foreach ($states as $state => $spec) {
$size = idx($counts, $state, 0);
if (!$size) {
continue;
}
$classes = array();
$classes[] = 'bulk-job-progress-slice';
$classes[] = $spec['class'];
$width = ($size / $total);
$bars[] = phutil_tag(
'div',
array(
'class' => implode(' ', $classes),
'style' =>
'left: '.sprintf('%.2f%%', 100 * $offset).'; '.
'width: '.sprintf('%.2f%%', 100 * $width).';',
),
'');
$offset += $width;
}
return phutil_tag(
'div',
array(
'class' => 'bulk-job-progress-bar',
),
$bars);
}
}

View file

@ -0,0 +1,83 @@
<?php
final class PhabricatorDaemonBulkJobViewController
extends PhabricatorDaemonController {
public function shouldAllowPublic() {
return true;
}
public function handleRequest(AphrontRequest $request) {
$viewer = $this->getViewer();
$job = id(new PhabricatorWorkerBulkJobQuery())
->setViewer($viewer)
->withIDs(array($request->getURIData('id')))
->executeOne();
if (!$job) {
return new Aphront404Response();
}
$title = pht('Bulk Job %d', $job->getID());
$crumbs = $this->buildApplicationCrumbs();
$crumbs->addTextCrumb(pht('Bulk Jobs'), '/daemon/bulk/');
$crumbs->addTextCrumb($title);
$properties = $this->renderProperties($job);
$actions = $this->renderActions($job);
$properties->setActionList($actions);
$box = id(new PHUIObjectBoxView())
->setHeaderText($title)
->addPropertyList($properties);
$timeline = $this->buildTransactionTimeline(
$job,
new PhabricatorWorkerBulkJobTransactionQuery());
$timeline->setShouldTerminate(true);
return $this->buildApplicationPage(
array(
$crumbs,
$box,
$timeline,
),
array(
'title' => $title,
));
}
private function renderProperties(PhabricatorWorkerBulkJob $job) {
$viewer = $this->getViewer();
$view = id(new PHUIPropertyListView())
->setUser($viewer)
->setObject($job);
$view->addProperty(
pht('Author'),
$viewer->renderHandle($job->getAuthorPHID()));
$view->addProperty(pht('Status'), $job->getStatusName());
return $view;
}
private function renderActions(PhabricatorWorkerBulkJob $job) {
$viewer = $this->getViewer();
$actions = id(new PhabricatorActionListView())
->setUser($viewer)
->setObject($job);
$actions->addAction(
id(new PhabricatorActionView())
->setHref($job->getDoneURI())
->setIcon('fa-arrow-circle-o-right')
->setName(pht('Continue')));
return $actions;
}
}

View file

@ -10,6 +10,9 @@ abstract class PhabricatorDaemonController extends PhabricatorController {
$nav->addFilter('/', pht('Console'));
$nav->addFilter('log', pht('All Daemons'));
$nav->addLabel(pht('Bulk Jobs'));
$nav->addFilter('bulk', pht('Manage Bulk Jobs'));
return $nav;
}

View file

@ -0,0 +1,296 @@
<?php
final class ManiphestTaskEditBulkJobType
extends PhabricatorWorkerBulkJobType {
public function getBulkJobTypeKey() {
return 'maniphest.task.edit';
}
public function getJobName(PhabricatorWorkerBulkJob $job) {
return pht('Maniphest Bulk Edit');
}
public function getDescriptionForConfirm(PhabricatorWorkerBulkJob $job) {
return pht(
'You are about to apply a bulk edit to Maniphest which will affect '.
'%s task(s).',
new PhutilNumber($job->getSize()));
}
public function getJobSize(PhabricatorWorkerBulkJob $job) {
return count($job->getParameter('taskPHIDs', array()));
}
public function getDoneURI(PhabricatorWorkerBulkJob $job) {
return $job->getParameter('doneURI');
}
public function createTasks(PhabricatorWorkerBulkJob $job) {
$tasks = array();
foreach ($job->getParameter('taskPHIDs', array()) as $phid) {
$tasks[] = PhabricatorWorkerBulkTask::initializeNewTask($job, $phid);
}
return $tasks;
}
public function runTask(
PhabricatorUser $actor,
PhabricatorWorkerBulkJob $job,
PhabricatorWorkerBulkTask $task) {
$object = id(new ManiphestTaskQuery())
->setViewer($actor)
->requireCapabilities(
array(
PhabricatorPolicyCapability::CAN_VIEW,
PhabricatorPolicyCapability::CAN_EDIT,
))
->withPHIDs(array($task->getObjectPHID()))
->executeOne();
if (!$object) {
return;
}
$field_list = PhabricatorCustomField::getObjectFields(
$object,
PhabricatorCustomField::ROLE_EDIT);
$field_list->readFieldsFromStorage($object);
$actions = $job->getParameter('actions');
$xactions = $this->buildTransactions($actions, $object);
$editor = id(new ManiphestTransactionEditor())
->setActor($actor)
->setContentSource($job->newContentSource())
->setContinueOnNoEffect(true)
->setContinueOnMissingFields(true)
->applyTransactions($object, $xactions);
}
private function buildTransactions($actions, ManiphestTask $task) {
$value_map = array();
$type_map = array(
'add_comment' => PhabricatorTransactions::TYPE_COMMENT,
'assign' => ManiphestTransaction::TYPE_OWNER,
'status' => ManiphestTransaction::TYPE_STATUS,
'priority' => ManiphestTransaction::TYPE_PRIORITY,
'add_project' => PhabricatorTransactions::TYPE_EDGE,
'remove_project' => PhabricatorTransactions::TYPE_EDGE,
'add_ccs' => PhabricatorTransactions::TYPE_SUBSCRIBERS,
'remove_ccs' => PhabricatorTransactions::TYPE_SUBSCRIBERS,
'space' => PhabricatorTransactions::TYPE_SPACE,
);
$edge_edit_types = array(
'add_project' => true,
'remove_project' => true,
'add_ccs' => true,
'remove_ccs' => true,
);
$xactions = array();
foreach ($actions as $action) {
if (empty($type_map[$action['action']])) {
throw new Exception(pht("Unknown batch edit action '%s'!", $action));
}
$type = $type_map[$action['action']];
// Figure out the current value, possibly after modifications by other
// batch actions of the same type. For example, if the user chooses to
// "Add Comment" twice, we should add both comments. More notably, if the
// user chooses "Remove Project..." and also "Add Project...", we should
// avoid restoring the removed project in the second transaction.
if (array_key_exists($type, $value_map)) {
$current = $value_map[$type];
} else {
switch ($type) {
case PhabricatorTransactions::TYPE_COMMENT:
$current = null;
break;
case ManiphestTransaction::TYPE_OWNER:
$current = $task->getOwnerPHID();
break;
case ManiphestTransaction::TYPE_STATUS:
$current = $task->getStatus();
break;
case ManiphestTransaction::TYPE_PRIORITY:
$current = $task->getPriority();
break;
case PhabricatorTransactions::TYPE_EDGE:
$current = $task->getProjectPHIDs();
break;
case PhabricatorTransactions::TYPE_SUBSCRIBERS:
$current = $task->getSubscriberPHIDs();
break;
case PhabricatorTransactions::TYPE_SPACE:
$current = PhabricatorSpacesNamespaceQuery::getObjectSpacePHID(
$task);
break;
}
}
// Check if the value is meaningful / provided, and normalize it if
// necessary. This discards, e.g., empty comments and empty owner
// changes.
$value = $action['value'];
switch ($type) {
case PhabricatorTransactions::TYPE_COMMENT:
if (!strlen($value)) {
continue 2;
}
break;
case PhabricatorTransactions::TYPE_SPACE:
if (empty($value)) {
continue 2;
}
$value = head($value);
break;
case ManiphestTransaction::TYPE_OWNER:
if (empty($value)) {
continue 2;
}
$value = head($value);
$no_owner = PhabricatorPeopleNoOwnerDatasource::FUNCTION_TOKEN;
if ($value === $no_owner) {
$value = null;
}
break;
case PhabricatorTransactions::TYPE_EDGE:
if (empty($value)) {
continue 2;
}
break;
case PhabricatorTransactions::TYPE_SUBSCRIBERS:
if (empty($value)) {
continue 2;
}
break;
}
// If the edit doesn't change anything, go to the next action. This
// check is only valid for changes like "owner", "status", etc, not
// for edge edits, because we should still apply an edit like
// "Remove Projects: A, B" to a task with projects "A, B".
if (empty($edge_edit_types[$action['action']])) {
if ($value == $current) {
continue;
}
}
// Apply the value change; for most edits this is just replacement, but
// some need to merge the current and edited values (add/remove project).
switch ($type) {
case PhabricatorTransactions::TYPE_COMMENT:
if (strlen($current)) {
$value = $current."\n\n".$value;
}
break;
case PhabricatorTransactions::TYPE_EDGE:
$is_remove = $action['action'] == 'remove_project';
$current = array_fill_keys($current, true);
$value = array_fill_keys($value, true);
$new = $current;
$did_something = false;
if ($is_remove) {
foreach ($value as $phid => $ignored) {
if (isset($new[$phid])) {
unset($new[$phid]);
$did_something = true;
}
}
} else {
foreach ($value as $phid => $ignored) {
if (empty($new[$phid])) {
$new[$phid] = true;
$did_something = true;
}
}
}
if (!$did_something) {
continue 2;
}
$value = array_keys($new);
break;
case PhabricatorTransactions::TYPE_SUBSCRIBERS:
$is_remove = $action['action'] == 'remove_ccs';
$current = array_fill_keys($current, true);
$new = array();
$did_something = false;
if ($is_remove) {
foreach ($value as $phid) {
if (isset($current[$phid])) {
$new[$phid] = true;
$did_something = true;
}
}
if ($new) {
$value = array('-' => array_keys($new));
}
} else {
$new = array();
foreach ($value as $phid) {
$new[$phid] = true;
$did_something = true;
}
if ($new) {
$value = array('+' => array_keys($new));
}
}
if (!$did_something) {
continue 2;
}
break;
}
$value_map[$type] = $value;
}
$template = new ManiphestTransaction();
foreach ($value_map as $type => $value) {
$xaction = clone $template;
$xaction->setTransactionType($type);
switch ($type) {
case PhabricatorTransactions::TYPE_COMMENT:
$xaction->attachComment(
id(new ManiphestTransactionComment())
->setContent($value));
break;
case PhabricatorTransactions::TYPE_EDGE:
$project_type = PhabricatorProjectObjectHasProjectEdgeType::EDGECONST;
$xaction
->setMetadataValue('edge:type', $project_type)
->setNewValue(
array(
'=' => array_fuse($value),
));
break;
default:
$xaction->setNewValue($value);
break;
}
$xactions[] = $xaction;
}
return $xactions;
}
}

View file

@ -45,8 +45,7 @@ final class ManiphestBatchEditController extends ManiphestController {
if (!$tasks) {
throw new Exception(
pht(
"You don't have permission to edit any of the selected tasks."));
pht("You don't have permission to edit any of the selected tasks."));
}
if ($project) {
@ -62,27 +61,32 @@ final class ManiphestBatchEditController extends ManiphestController {
$actions = phutil_json_decode($actions);
}
if ($request->isFormPost() && is_array($actions)) {
foreach ($tasks as $task) {
$field_list = PhabricatorCustomField::getObjectFields(
$task,
PhabricatorCustomField::ROLE_EDIT);
$field_list->readFieldsFromStorage($task);
if ($request->isFormPost() && $actions) {
$job = PhabricatorWorkerBulkJob::initializeNewJob(
$viewer,
new ManiphestTaskEditBulkJobType(),
array(
'taskPHIDs' => mpull($tasks, 'getPHID'),
'actions' => $actions,
'cancelURI' => $cancel_uri,
'doneURI' => $redirect_uri,
));
$xactions = $this->buildTransactions($actions, $task);
if ($xactions) {
// TODO: Set content source to "batch edit".
$type_status = PhabricatorWorkerBulkJobTransaction::TYPE_STATUS;
$editor = id(new ManiphestTransactionEditor())
->setActor($viewer)
->setContentSourceFromRequest($request)
->setContinueOnNoEffect(true)
->setContinueOnMissingFields(true)
->applyTransactions($task, $xactions);
}
}
$xactions = array();
$xactions[] = id(new PhabricatorWorkerBulkJobTransaction())
->setTransactionType($type_status)
->setNewValue(PhabricatorWorkerBulkJob::STATUS_CONFIRM);
return id(new AphrontRedirectResponse())->setURI($redirect_uri);
$editor = id(new PhabricatorWorkerBulkJobEditor())
->setActor($viewer)
->setContentSourceFromRequest($request)
->setContinueOnMissingFields(true)
->applyTransactions($job, $xactions);
return id(new AphrontRedirectResponse())
->setURI($job->getMonitorURI());
}
$handles = ManiphestTaskListView::loadTaskHandles($viewer, $tasks);
@ -210,228 +214,4 @@ final class ManiphestBatchEditController extends ManiphestController {
));
}
private function buildTransactions($actions, ManiphestTask $task) {
$value_map = array();
$type_map = array(
'add_comment' => PhabricatorTransactions::TYPE_COMMENT,
'assign' => ManiphestTransaction::TYPE_OWNER,
'status' => ManiphestTransaction::TYPE_STATUS,
'priority' => ManiphestTransaction::TYPE_PRIORITY,
'add_project' => PhabricatorTransactions::TYPE_EDGE,
'remove_project' => PhabricatorTransactions::TYPE_EDGE,
'add_ccs' => PhabricatorTransactions::TYPE_SUBSCRIBERS,
'remove_ccs' => PhabricatorTransactions::TYPE_SUBSCRIBERS,
'space' => PhabricatorTransactions::TYPE_SPACE,
);
$edge_edit_types = array(
'add_project' => true,
'remove_project' => true,
'add_ccs' => true,
'remove_ccs' => true,
);
$xactions = array();
foreach ($actions as $action) {
if (empty($type_map[$action['action']])) {
throw new Exception(pht("Unknown batch edit action '%s'!", $action));
}
$type = $type_map[$action['action']];
// Figure out the current value, possibly after modifications by other
// batch actions of the same type. For example, if the user chooses to
// "Add Comment" twice, we should add both comments. More notably, if the
// user chooses "Remove Project..." and also "Add Project...", we should
// avoid restoring the removed project in the second transaction.
if (array_key_exists($type, $value_map)) {
$current = $value_map[$type];
} else {
switch ($type) {
case PhabricatorTransactions::TYPE_COMMENT:
$current = null;
break;
case ManiphestTransaction::TYPE_OWNER:
$current = $task->getOwnerPHID();
break;
case ManiphestTransaction::TYPE_STATUS:
$current = $task->getStatus();
break;
case ManiphestTransaction::TYPE_PRIORITY:
$current = $task->getPriority();
break;
case PhabricatorTransactions::TYPE_EDGE:
$current = $task->getProjectPHIDs();
break;
case PhabricatorTransactions::TYPE_SUBSCRIBERS:
$current = $task->getSubscriberPHIDs();
break;
case PhabricatorTransactions::TYPE_SPACE:
$current = PhabricatorSpacesNamespaceQuery::getObjectSpacePHID(
$task);
break;
}
}
// Check if the value is meaningful / provided, and normalize it if
// necessary. This discards, e.g., empty comments and empty owner
// changes.
$value = $action['value'];
switch ($type) {
case PhabricatorTransactions::TYPE_COMMENT:
if (!strlen($value)) {
continue 2;
}
break;
case PhabricatorTransactions::TYPE_SPACE:
if (empty($value)) {
continue 2;
}
$value = head($value);
break;
case ManiphestTransaction::TYPE_OWNER:
if (empty($value)) {
continue 2;
}
$value = head($value);
$no_owner = PhabricatorPeopleNoOwnerDatasource::FUNCTION_TOKEN;
if ($value === $no_owner) {
$value = null;
}
break;
case PhabricatorTransactions::TYPE_EDGE:
if (empty($value)) {
continue 2;
}
break;
case PhabricatorTransactions::TYPE_SUBSCRIBERS:
if (empty($value)) {
continue 2;
}
break;
}
// If the edit doesn't change anything, go to the next action. This
// check is only valid for changes like "owner", "status", etc, not
// for edge edits, because we should still apply an edit like
// "Remove Projects: A, B" to a task with projects "A, B".
if (empty($edge_edit_types[$action['action']])) {
if ($value == $current) {
continue;
}
}
// Apply the value change; for most edits this is just replacement, but
// some need to merge the current and edited values (add/remove project).
switch ($type) {
case PhabricatorTransactions::TYPE_COMMENT:
if (strlen($current)) {
$value = $current."\n\n".$value;
}
break;
case PhabricatorTransactions::TYPE_EDGE:
$is_remove = $action['action'] == 'remove_project';
$current = array_fill_keys($current, true);
$value = array_fill_keys($value, true);
$new = $current;
$did_something = false;
if ($is_remove) {
foreach ($value as $phid => $ignored) {
if (isset($new[$phid])) {
unset($new[$phid]);
$did_something = true;
}
}
} else {
foreach ($value as $phid => $ignored) {
if (empty($new[$phid])) {
$new[$phid] = true;
$did_something = true;
}
}
}
if (!$did_something) {
continue 2;
}
$value = array_keys($new);
break;
case PhabricatorTransactions::TYPE_SUBSCRIBERS:
$is_remove = $action['action'] == 'remove_ccs';
$current = array_fill_keys($current, true);
$new = array();
$did_something = false;
if ($is_remove) {
foreach ($value as $phid) {
if (isset($current[$phid])) {
$new[$phid] = true;
$did_something = true;
}
}
if ($new) {
$value = array('-' => array_keys($new));
}
} else {
$new = array();
foreach ($value as $phid) {
$new[$phid] = true;
$did_something = true;
}
if ($new) {
$value = array('+' => array_keys($new));
}
}
if (!$did_something) {
continue 2;
}
break;
}
$value_map[$type] = $value;
}
$template = new ManiphestTransaction();
foreach ($value_map as $type => $value) {
$xaction = clone $template;
$xaction->setTransactionType($type);
switch ($type) {
case PhabricatorTransactions::TYPE_COMMENT:
$xaction->attachComment(
id(new ManiphestTransactionComment())
->setContent($value));
break;
case PhabricatorTransactions::TYPE_EDGE:
$project_type = PhabricatorProjectObjectHasProjectEdgeType::EDGECONST;
$xaction
->setMetadataValue('edge:type', $project_type)
->setNewValue(
array(
'=' => array_fuse($value),
));
break;
default:
$xaction->setNewValue($value);
break;
}
$xactions[] = $xaction;
}
return $xactions;
}
}

View file

@ -15,6 +15,7 @@ final class PhabricatorContentSource extends Phobject {
const SOURCE_DAEMON = 'daemon';
const SOURCE_LIPSUM = 'lipsum';
const SOURCE_PHORTUNE = 'phortune';
const SOURCE_BULK = 'bulk';
private $source;
private $params = array();
@ -79,6 +80,7 @@ final class PhabricatorContentSource extends Phobject {
self::SOURCE_LIPSUM => pht('Lipsum'),
self::SOURCE_UNKNOWN => pht('Old World'),
self::SOURCE_PHORTUNE => pht('Phortune'),
self::SOURCE_BULK => pht('Bulk Edit'),
);
}

View file

@ -0,0 +1,10 @@
<?php
final class PhabricatorWorkerBulkJobTestCase extends PhabricatorTestCase {
public function testGetAllBulkJobTypes() {
PhabricatorWorkerBulkJobType::getAllJobTypes();
$this->assertTrue(true);
}
}

View file

@ -0,0 +1,51 @@
<?php
final class PhabricatorWorkerBulkJobCreateWorker
extends PhabricatorWorkerBulkJobWorker {
protected function doWork() {
$lock = $this->acquireJobLock();
$job = $this->loadJob();
$actor = $this->loadActor($job);
$status = $job->getStatus();
switch ($status) {
case PhabricatorWorkerBulkJob::STATUS_WAITING:
// This is what we expect. Other statuses indicate some kind of race
// is afoot.
break;
default:
throw new PhabricatorWorkerPermanentFailureException(
pht(
'Found unexpected job status ("%s").',
$status));
}
$tasks = $job->createTasks();
foreach ($tasks as $task) {
$task->save();
}
$this->updateJobStatus(
$job,
PhabricatorWorkerBulkJob::STATUS_RUNNING);
$lock->unlock();
foreach ($tasks as $task) {
PhabricatorWorker::scheduleTask(
'PhabricatorWorkerBulkJobTaskWorker',
array(
'jobID' => $job->getID(),
'taskID' => $task->getID(),
),
array(
'priority' => PhabricatorWorker::PRIORITY_BULK,
));
}
$this->updateJob($job);
}
}

View file

@ -0,0 +1,46 @@
<?php
final class PhabricatorWorkerBulkJobTaskWorker
extends PhabricatorWorkerBulkJobWorker {
protected function doWork() {
$lock = $this->acquireTaskLock();
$task = $this->loadTask();
$status = $task->getStatus();
switch ($task->getStatus()) {
case PhabricatorWorkerBulkTask::STATUS_WAITING:
// This is what we expect.
break;
default:
throw new PhabricatorWorkerPermanentFailureException(
pht(
'Found unexpected task status ("%s").',
$status));
}
$task
->setStatus(PhabricatorWorkerBulkTask::STATUS_RUNNING)
->save();
$lock->unlock();
$job = $this->loadJob();
$actor = $this->loadActor($job);
try {
$job->runTask($actor, $task);
$status = PhabricatorWorkerBulkTask::STATUS_DONE;
} catch (Exception $ex) {
phlog($ex);
$status = PhabricatorWorkerBulkTask::STATUS_FAIL;
}
$task
->setStatus($status)
->save();
$this->updateJob($job);
}
}

View file

@ -0,0 +1,28 @@
<?php
abstract class PhabricatorWorkerBulkJobType extends Phobject {
abstract public function getJobName(PhabricatorWorkerBulkJob $job);
abstract public function getBulkJobTypeKey();
abstract public function getJobSize(PhabricatorWorkerBulkJob $job);
abstract public function getDescriptionForConfirm(
PhabricatorWorkerBulkJob $job);
abstract public function createTasks(PhabricatorWorkerBulkJob $job);
abstract public function runTask(
PhabricatorUser $actor,
PhabricatorWorkerBulkJob $job,
PhabricatorWorkerBulkTask $task);
public function getDoneURI(PhabricatorWorkerBulkJob $job) {
return $job->getManageURI();
}
final public static function getAllJobTypes() {
return id(new PhutilClassMapQuery())
->setAncestorClass(__CLASS__)
->setUniqueMethod('getBulkJobTypeKey')
->execute();
}
}

View file

@ -0,0 +1,138 @@
<?php
abstract class PhabricatorWorkerBulkJobWorker
extends PhabricatorWorker {
final protected function acquireJobLock() {
return PhabricatorGlobalLock::newLock('bulkjob.'.$this->getJobID())
->lock(15);
}
final protected function acquireTaskLock() {
return PhabricatorGlobalLock::newLock('bulktask.'.$this->getTaskID())
->lock(15);
}
final protected function getJobID() {
$data = $this->getTaskData();
$id = idx($data, 'jobID');
if (!$id) {
throw new PhabricatorWorkerPermanentFailureException(
pht('Worker has no job ID.'));
}
return $id;
}
final protected function getTaskID() {
$data = $this->getTaskData();
$id = idx($data, 'taskID');
if (!$id) {
throw new PhabricatorWorkerPermanentFailureException(
pht('Worker has no task ID.'));
}
return $id;
}
final protected function loadJob() {
$id = $this->getJobID();
$job = id(new PhabricatorWorkerBulkJobQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withIDs(array($id))
->executeOne();
if (!$job) {
throw new PhabricatorWorkerPermanentFailureException(
pht('Worker has invalid job ID ("%s").', $id));
}
return $job;
}
final protected function loadTask() {
$id = $this->getTaskID();
$task = id(new PhabricatorWorkerBulkTask())->load($id);
if (!$task) {
throw new PhabricatorWorkerPermanentFailureException(
pht('Worker has invalid task ID ("%s").', $id));
}
return $task;
}
final protected function loadActor(PhabricatorWorkerBulkJob $job) {
$actor_phid = $job->getAuthorPHID();
$actor = id(new PhabricatorPeopleQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withPHIDs(array($actor_phid))
->executeOne();
if (!$actor) {
throw new PhabricatorWorkerPermanentFailureException(
pht('Worker has invalid actor PHID ("%s").', $actor_phid));
}
$can_edit = PhabricatorPolicyFilter::hasCapability(
$actor,
$job,
PhabricatorPolicyCapability::CAN_EDIT);
if (!$can_edit) {
throw new PhabricatorWorkerPermanentFailureException(
pht('Job actor does not have permission to edit job.'));
}
return $actor;
}
final protected function updateJob(PhabricatorWorkerBulkJob $job) {
$has_work = $this->hasRemainingWork($job);
if ($has_work) {
return;
}
$lock = $this->acquireJobLock();
$job = $this->loadJob();
if ($job->getStatus() == PhabricatorWorkerBulkJob::STATUS_RUNNING) {
if (!$this->hasRemainingWork($job)) {
$this->updateJobStatus(
$job,
PhabricatorWorkerBulkJob::STATUS_COMPLETE);
}
}
$lock->unlock();
}
private function hasRemainingWork(PhabricatorWorkerBulkJob $job) {
return (bool)queryfx_one(
$job->establishConnection('r'),
'SELECT * FROM %T WHERE bulkJobPHID = %s
AND status NOT IN (%Ls) LIMIT 1',
id(new PhabricatorWorkerBulkTask())->getTableName(),
$job->getPHID(),
array(
PhabricatorWorkerBulkTask::STATUS_DONE,
PhabricatorWorkerBulkTask::STATUS_FAIL,
));
}
protected function updateJobStatus(PhabricatorWorkerBulkJob $job, $status) {
$type_status = PhabricatorWorkerBulkJobTransaction::TYPE_STATUS;
$xactions = array();
$xactions[] = id(new PhabricatorWorkerBulkJobTransaction())
->setTransactionType($type_status)
->setNewValue($status);
$daemon_source = PhabricatorContentSource::newForSource(
PhabricatorContentSource::SOURCE_DAEMON,
array());
$app_phid = id(new PhabricatorDaemonsApplication())->getPHID();
$editor = id(new PhabricatorWorkerBulkJobEditor())
->setActor(PhabricatorUser::getOmnipotentUser())
->setActingAsPHID($app_phid)
->setContentSource($daemon_source)
->setContinueOnMissingFields(true)
->applyTransactions($job, $xactions);
}
}

View file

@ -0,0 +1,87 @@
<?php
final class PhabricatorWorkerBulkJobEditor
extends PhabricatorApplicationTransactionEditor {
public function getEditorApplicationClass() {
return 'PhabricatorDaemonsApplication';
}
public function getEditorObjectsDescription() {
return pht('Bulk Jobs');
}
public function getTransactionTypes() {
$types = parent::getTransactionTypes();
$types[] = PhabricatorWorkerBulkJobTransaction::TYPE_STATUS;
return $types;
}
protected function getCustomTransactionOldValue(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
switch ($xaction->getTransactionType()) {
case PhabricatorWorkerBulkJobTransaction::TYPE_STATUS:
return $object->getStatus();
}
}
protected function getCustomTransactionNewValue(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
switch ($xaction->getTransactionType()) {
case PhabricatorWorkerBulkJobTransaction::TYPE_STATUS:
return $xaction->getNewValue();
}
}
protected function applyCustomInternalTransaction(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
$type = $xaction->getTransactionType();
$new = $xaction->getNewValue();
switch ($type) {
case PhabricatorWorkerBulkJobTransaction::TYPE_STATUS:
$object->setStatus($xaction->getNewValue());
return;
}
return parent::applyCustomInternalTransaction($object, $xaction);
}
protected function applyCustomExternalTransaction(
PhabricatorLiskDAO $object,
PhabricatorApplicationTransaction $xaction) {
$type = $xaction->getTransactionType();
$new = $xaction->getNewValue();
switch ($type) {
case PhabricatorWorkerBulkJobTransaction::TYPE_STATUS:
switch ($new) {
case PhabricatorWorkerBulkJob::STATUS_WAITING:
PhabricatorWorker::scheduleTask(
'PhabricatorWorkerBulkJobCreateWorker',
array(
'jobID' => $object->getID(),
),
array(
'priority' => PhabricatorWorker::PRIORITY_BULK,
));
break;
}
return;
}
return parent::applyCustomExternalTransaction($object, $xaction);
}
}

View file

@ -0,0 +1,37 @@
<?php
final class PhabricatorWorkerBulkJobPHIDType extends PhabricatorPHIDType {
const TYPECONST = 'BULK';
public function getTypeName() {
return pht('Bulk Job');
}
public function newObject() {
return new PhabricatorWorkerBulkJob();
}
protected function buildQueryForObjects(
PhabricatorObjectQuery $query,
array $phids) {
return id(new PhabricatorWorkerBulkJobQuery())
->withPHIDs($phids);
}
public function loadHandles(
PhabricatorHandleQuery $query,
array $handles,
array $objects) {
foreach ($handles as $phid => $handle) {
$job = $objects[$phid];
$id = $job->getID();
$handle->setName(pht('Bulk Job %d', $id));
}
}
}

View file

@ -0,0 +1,106 @@
<?php
final class PhabricatorWorkerBulkJobQuery
extends PhabricatorCursorPagedPolicyAwareQuery {
private $ids;
private $phids;
private $authorPHIDs;
private $bulkJobTypes;
private $statuses;
public function withIDs(array $ids) {
$this->ids = $ids;
return $this;
}
public function withPHIDs(array $phids) {
$this->phids = $phids;
return $this;
}
public function withAuthorPHIDs(array $author_phids) {
$this->authorPHIDs = $author_phids;
return $this;
}
public function withBulkJobTypes(array $job_types) {
$this->bulkJobTypes = $job_types;
return $this;
}
public function withStatuses(array $statuses) {
$this->statuses = $statuses;
return $this;
}
public function newResultObject() {
return new PhabricatorWorkerBulkJob();
}
protected function loadPage() {
return $this->loadStandardPage($this->newResultObject());
}
protected function willFilterPage(array $page) {
$map = PhabricatorWorkerBulkJobType::getAllJobTypes();
foreach ($page as $key => $job) {
$implementation = idx($map, $job->getJobTypeKey());
if (!$implementation) {
$this->didRejectResult($job);
unset($page[$key]);
continue;
}
$job->attachJobImplementation($implementation);
}
return $page;
}
protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) {
$where = parent::buildWhereClauseParts($conn);
if ($this->ids !== null) {
$where[] = qsprintf(
$conn,
'id IN (%Ld)',
$this->ids);
}
if ($this->phids !== null) {
$where[] = qsprintf(
$conn,
'phid IN (%Ls)',
$this->phids);
}
if ($this->authorPHIDs !== null) {
$where[] = qsprintf(
$conn,
'authorPHID IN (%Ls)',
$this->authorPHIDs);
}
if ($this->bulkJobTypes !== null) {
$where[] = qsprintf(
$conn,
'bulkJobType IN (%Ls)',
$this->bulkJobTypes);
}
if ($this->statuses !== null) {
$where[] = qsprintf(
$conn,
'status IN (%Ls)',
$this->statuses);
}
return $where;
}
public function getQueryApplicationClass() {
return 'PhabricatorDaemonsApplication';
}
}

View file

@ -0,0 +1,98 @@
<?php
final class PhabricatorWorkerBulkJobSearchEngine
extends PhabricatorApplicationSearchEngine {
public function getResultTypeDescription() {
return pht('Bulk Jobs');
}
public function getApplicationClassName() {
return 'PhabricatorDaemonsApplication';
}
public function newQuery() {
return id(new PhabricatorWorkerBulkJobQuery());
}
protected function buildQueryFromParameters(array $map) {
$query = $this->newQuery();
if ($map['authorPHIDs']) {
$query->withAuthorPHIDs($map['authorPHIDs']);
}
return $query;
}
protected function buildCustomSearchFields() {
return array(
id(new PhabricatorSearchUsersField())
->setLabel(pht('Authors'))
->setKey('authorPHIDs')
->setAliases(array('author', 'authors')),
);
}
protected function getURI($path) {
return '/daemon/bulk/'.$path;
}
protected function getBuiltinQueryNames() {
$names = array();
if ($this->requireViewer()->isLoggedIn()) {
$names['authored'] = pht('Authored Jobs');
}
$names['all'] = pht('All Jobs');
return $names;
}
public function buildSavedQueryFromBuiltin($query_key) {
$query = $this->newSavedQuery();
$query->setQueryKey($query_key);
switch ($query_key) {
case 'all':
return $query;
case 'authored':
return $query->setParameter(
'authorPHIDs',
array($this->requireViewer()->getPHID()));
}
return parent::buildSavedQueryFromBuiltin($query_key);
}
protected function renderResultList(
array $jobs,
PhabricatorSavedQuery $query,
array $handles) {
assert_instances_of($jobs, 'PhabricatorWorkerBulkJob');
$viewer = $this->requireViewer();
$list = id(new PHUIObjectItemListView())
->setUser($viewer);
foreach ($jobs as $job) {
$size = pht('%s Bulk Task(s)', new PhutilNumber($job->getSize()));
$item = id(new PHUIObjectItemView())
->setObjectName(pht('Bulk Job %d', $job->getID()))
->setHeader($job->getJobName())
->addAttribute(phabricator_datetime($job->getDateCreated(), $viewer))
->setHref($job->getManageURI())
->addIcon($job->getStatusIcon(), $job->getStatusName())
->addIcon('none', $size);
$list->addItem($item);
}
// TODO: Needs new wrapper when merging to redesign.
return $list;
}
}

View file

@ -0,0 +1,10 @@
<?php
final class PhabricatorWorkerBulkJobTransactionQuery
extends PhabricatorApplicationTransactionQuery {
public function getTemplateApplicationTransaction() {
return new PhabricatorWorkerBulkJobTransaction();
}
}

View file

@ -0,0 +1,272 @@
<?php
/**
* @task implementation Job Implementation
*/
final class PhabricatorWorkerBulkJob
extends PhabricatorWorkerDAO
implements
PhabricatorPolicyInterface,
PhabricatorSubscribableInterface,
PhabricatorApplicationTransactionInterface,
PhabricatorDestructibleInterface {
const STATUS_CONFIRM = 'confirm';
const STATUS_WAITING = 'waiting';
const STATUS_RUNNING = 'running';
const STATUS_COMPLETE = 'complete';
protected $authorPHID;
protected $jobTypeKey;
protected $status;
protected $parameters = array();
protected $size;
private $jobImplementation = self::ATTACHABLE;
protected function getConfiguration() {
return array(
self::CONFIG_AUX_PHID => true,
self::CONFIG_SERIALIZATION => array(
'parameters' => self::SERIALIZATION_JSON,
),
self::CONFIG_COLUMN_SCHEMA => array(
'jobTypeKey' => 'text32',
'status' => 'text32',
'size' => 'uint32',
),
self::CONFIG_KEY_SCHEMA => array(
'key_type' => array(
'columns' => array('jobTypeKey'),
),
'key_author' => array(
'columns' => array('authorPHID'),
),
'key_status' => array(
'columns' => array('status'),
),
),
) + parent::getConfiguration();
}
public static function initializeNewJob(
PhabricatorUser $actor,
PhabricatorWorkerBulkJobType $type,
array $parameters) {
$job = id(new PhabricatorWorkerBulkJob())
->setAuthorPHID($actor->getPHID())
->setJobTypeKey($type->getBulkJobTypeKey())
->setParameters($parameters)
->attachJobImplementation($type);
$job->setSize($job->computeSize());
return $job;
}
public function generatePHID() {
return PhabricatorPHID::generateNewPHID(
PhabricatorWorkerBulkJobPHIDType::TYPECONST);
}
public function getMonitorURI() {
return '/daemon/bulk/monitor/'.$this->getID().'/';
}
public function getManageURI() {
return '/daemon/bulk/view/'.$this->getID().'/';
}
public function getParameter($key, $default = null) {
return idx($this->parameters, $key, $default);
}
public function setParameter($key, $value) {
$this->parameters[$key] = $value;
return $this;
}
public function loadTaskStatusCounts() {
$table = new PhabricatorWorkerBulkTask();
$conn_r = $table->establishConnection('r');
$rows = queryfx_all(
$conn_r,
'SELECT status, COUNT(*) N FROM %T WHERE bulkJobPHID = %s
GROUP BY status',
$table->getTableName(),
$this->getPHID());
return ipull($rows, 'N', 'status');
}
public function newContentSource() {
return PhabricatorContentSource::newForSource(
PhabricatorContentSource::SOURCE_BULK,
array(
'jobID' => $this->getID(),
));
}
public function getStatusIcon() {
$map = array(
self::STATUS_CONFIRM => 'fa-question',
self::STATUS_WAITING => 'fa-clock-o',
self::STATUS_RUNNING => 'fa-clock-o',
self::STATUS_COMPLETE => 'fa-check grey',
);
return idx($map, $this->getStatus(), 'none');
}
public function getStatusName() {
$map = array(
self::STATUS_CONFIRM => pht('Confirming'),
self::STATUS_WAITING => pht('Waiting'),
self::STATUS_RUNNING => pht('Running'),
self::STATUS_COMPLETE => pht('Complete'),
);
return idx($map, $this->getStatus(), $this->getStatus());
}
/* -( Job Implementation )------------------------------------------------- */
protected function getJobImplementation() {
return $this->assertAttached($this->jobImplementation);
}
public function attachJobImplementation(PhabricatorWorkerBulkJobType $type) {
$this->jobImplementation = $type;
return $this;
}
private function computeSize() {
return $this->getJobImplementation()->getJobSize($this);
}
public function getCancelURI() {
return $this->getJobImplementation()->getCancelURI($this);
}
public function getDoneURI() {
return $this->getJobImplementation()->getDoneURI($this);
}
public function getDescriptionForConfirm() {
return $this->getJobImplementation()->getDescriptionForConfirm($this);
}
public function createTasks() {
return $this->getJobImplementation()->createTasks($this);
}
public function runTask(
PhabricatorUser $actor,
PhabricatorWorkerBulkTask $task) {
return $this->getJobImplementation()->runTask($actor, $this, $task);
}
public function getJobName() {
return $this->getJobImplementation()->getJobName($this);
}
/* -( PhabricatorPolicyInterface )----------------------------------------- */
public function getCapabilities() {
return array(
PhabricatorPolicyCapability::CAN_VIEW,
PhabricatorPolicyCapability::CAN_EDIT,
);
}
public function getPolicy($capability) {
switch ($capability) {
case PhabricatorPolicyCapability::CAN_VIEW:
return PhabricatorPolicies::getMostOpenPolicy();
case PhabricatorPolicyCapability::CAN_EDIT:
return $this->getAuthorPHID();
}
}
public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {
return false;
}
public function describeAutomaticCapability($capability) {
switch ($capability) {
case PhabricatorPolicyCapability::CAN_EDIT:
return pht('Only the owner of a bulk job can edit it.');
default:
return null;
}
}
/* -( PhabricatorSubscribableInterface )----------------------------------- */
public function isAutomaticallySubscribed($phid) {
return false;
}
public function shouldShowSubscribersProperty() {
return true;
}
public function shouldAllowSubscription($phid) {
return true;
}
/* -( PhabricatorApplicationTransactionInterface )------------------------- */
public function getApplicationTransactionEditor() {
return new PhabricatorWorkerBulkJobEditor();
}
public function getApplicationTransactionObject() {
return $this;
}
public function getApplicationTransactionTemplate() {
return new PhabricatorWorkerBulkJobTransaction();
}
public function willRenderTimeline(
PhabricatorApplicationTransactionView $timeline,
AphrontRequest $request) {
return $timeline;
}
/* -( PhabricatorDestructibleInterface )----------------------------------- */
public function destroyObjectPermanently(
PhabricatorDestructionEngine $engine) {
$this->openTransaction();
// We're only removing the actual task objects. This may leave stranded
// workers in the queue itself, but they'll just flush out automatically
// when they can't load bulk job data.
$task_table = new PhabricatorWorkerBulkTask();
$conn_w = $task_table->establishConnection('w');
queryfx(
$conn_w,
'DELETE FROM %T WHERE bulkJobPHID = %s',
$task_table->getPHID(),
$this->getPHID());
$this->delete();
$this->saveTransaction();
}
}

View file

@ -0,0 +1,51 @@
<?php
final class PhabricatorWorkerBulkJobTransaction
extends PhabricatorApplicationTransaction {
const TYPE_STATUS = 'bulkjob.status';
public function getApplicationName() {
return 'worker';
}
public function getApplicationTransactionType() {
return PhabricatorWorkerBulkJobPHIDType::TYPECONST;
}
public function getTitle() {
$author_phid = $this->getAuthorPHID();
$old = $this->getOldValue();
$new = $this->getNewValue();
$type = $this->getTransactionType();
switch ($type) {
case self::TYPE_STATUS:
if ($old === null) {
return pht(
'%s created this bulk job.',
$this->renderHandleLink($author_phid));
} else {
switch ($new) {
case PhabricatorWorkerBulkJob::STATUS_WAITING:
return pht(
'%s confirmed this job.',
$this->renderHandleLink($author_phid));
case PhabricatorWorkerBulkJob::STATUS_RUNNING:
return pht(
'%s marked this job as running.',
$this->renderHandleLink($author_phid));
case PhabricatorWorkerBulkJob::STATUS_COMPLETE:
return pht(
'%s marked this job complete.',
$this->renderHandleLink($author_phid));
}
}
break;
}
return parent::getTitle();
}
}

View file

@ -0,0 +1,46 @@
<?php
final class PhabricatorWorkerBulkTask
extends PhabricatorWorkerDAO {
const STATUS_WAITING = 'waiting';
const STATUS_RUNNING = 'running';
const STATUS_DONE = 'done';
const STATUS_FAIL = 'fail';
protected $bulkJobPHID;
protected $objectPHID;
protected $status;
protected $data = array();
protected function getConfiguration() {
return array(
self::CONFIG_TIMESTAMPS => false,
self::CONFIG_SERIALIZATION => array(
'data' => self::SERIALIZATION_JSON,
),
self::CONFIG_COLUMN_SCHEMA => array(
'status' => 'text32',
),
self::CONFIG_KEY_SCHEMA => array(
'key_job' => array(
'columns' => array('bulkJobPHID', 'status'),
),
'key_object' => array(
'columns' => array('objectPHID'),
),
),
) + parent::getConfiguration();
}
public static function initializeNewTask(
PhabricatorWorkerBulkJob $job,
$object_phid) {
return id(new PhabricatorWorkerBulkTask())
->setBulkJobPHID($job->getPHID())
->setStatus(self::STATUS_WAITING)
->setObjectPHID($object_phid);
}
}

View file

@ -0,0 +1,10 @@
<?php
final class PhabricatorWorkerSchemaSpec
extends PhabricatorConfigSchemaSpec {
public function buildSchemata() {
$this->buildEdgeSchemata(new PhabricatorWorkerBulkJob());
}
}

View file

@ -1177,6 +1177,11 @@ final class PhabricatorUSEnglishTranslation
'%s Broken Test(s)' => '%s Broken',
'%s Unsound Test(s)' => '%s Unsound',
'%s Other Test(s)' => '%s Other',
'%s Bulk Task(s)' => array(
'%s Task',
'%s Tasks',
),
);
}

View file

@ -0,0 +1,32 @@
/**
* @provides bulk-job-css
*/
.bulk-job-progress-bar {
position: relative;
width: 100%;
border: 1px solid {$lightgreyborder};
height: 32px;
}
.bulk-job-progress-slice {
position: absolute;
top: 0;
bottom: 0;
}
.bulk-job-progress-slice-green {
background-color: {$green};
}
.bulk-job-progress-slice-blue {
background-color: {$blue};
}
.bulk-job-progress-slice-red {
background-color: {$red};
}
.bulk-job-progress-slice-empty {
background-color: {$lightbluebackground};
}

View file

@ -0,0 +1,18 @@
/**
* @provides javelin-behavior-bulk-job-reload
* @requires javelin-behavior
* javelin-uri
*/
JX.behavior('bulk-job-reload', function() {
// TODO: It would be nice to have a pretty Ajax progress bar here, but just
// reload the page for now.
function reload() {
JX.$U().go();
}
setTimeout(reload, 1000);
});