@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Move "update related object after commit" to a separate worker in the task queue

Summary:
Depends on D20462. Ref T13276. Currently, the "Message" parser also updates related tasks and revisions when a commit is published.

For PHI1165, which ran into a race with message parsing, I originally believed we needed to separate this logic and lock + yield to avoid the race. D20462 provides what is probably a better approach for avoiding the race.

Still, I think separating these "update related revisions" and "updated related tasks" chunks into separate workers is a net improvement. There may still be some value in doing lock + yield in the future to deal with other issues, and when we occasionally run into problems with pulling a diff out of the repository to update the revision (usually because the diff is too big) this isolates the problem better and allows the commit to import.

I think the only thing to watch out for here is that Herald may now run before the revision and commit are attached to one another. This is fine for all current Herald rules, we just need to be mindful in implementing new rules.

Test Plan: Used `bin/repository reparse --message` on various commits, including commits that close revisions and close tasks.

Reviewers: amckinley

Reviewed By: amckinley

Maniphest Tasks: T13276

Differential Revision: https://secure.phabricator.com/D20463

+312 -203
+2
src/__phutil_library_map__.php
··· 1020 1020 'DiffusionURIEditor' => 'applications/diffusion/editor/DiffusionURIEditor.php', 1021 1021 'DiffusionURITestCase' => 'applications/diffusion/request/__tests__/DiffusionURITestCase.php', 1022 1022 'DiffusionUpdateCoverageConduitAPIMethod' => 'applications/diffusion/conduit/DiffusionUpdateCoverageConduitAPIMethod.php', 1023 + 'DiffusionUpdateObjectAfterCommitWorker' => 'applications/diffusion/worker/DiffusionUpdateObjectAfterCommitWorker.php', 1023 1024 'DiffusionView' => 'applications/diffusion/view/DiffusionView.php', 1024 1025 'DivinerArticleAtomizer' => 'applications/diviner/atomizer/DivinerArticleAtomizer.php', 1025 1026 'DivinerAtom' => 'applications/diviner/atom/DivinerAtom.php', ··· 6685 6686 'DiffusionURIEditor' => 'PhabricatorApplicationTransactionEditor', 6686 6687 'DiffusionURITestCase' => 'PhutilTestCase', 6687 6688 'DiffusionUpdateCoverageConduitAPIMethod' => 'DiffusionConduitAPIMethod', 6689 + 'DiffusionUpdateObjectAfterCommitWorker' => 'PhabricatorWorker', 6688 6690 'DiffusionView' => 'AphrontView', 6689 6691 'DivinerArticleAtomizer' => 'DivinerAtomizer', 6690 6692 'DivinerAtom' => 'Phobject',
+2 -8
src/applications/differential/engine/DifferentialDiffExtractionEngine.php
··· 315 315 $editor = id(new DifferentialTransactionEditor()) 316 316 ->setActor($viewer) 317 317 ->setContinueOnMissingFields(true) 318 + ->setContinueOnNoEffect(true) 318 319 ->setContentSource($content_source) 319 320 ->setChangedPriorToCommitURI($changed_uri) 320 321 ->setIsCloseByCommit(true); ··· 324 325 $editor->setActingAsPHID($author_phid); 325 326 } 326 327 327 - try { 328 - $editor->applyTransactions($revision, $xactions); 329 - } catch (PhabricatorApplicationTransactionNoEffectException $ex) { 330 - // NOTE: We've marked transactions other than the CLOSE transaction 331 - // as ignored when they don't have an effect, so this means that we 332 - // lost a race to close the revision. That's perfectly fine, we can 333 - // just continue normally. 334 - } 328 + $editor->applyTransactions($revision, $xactions); 335 329 } 336 330 337 331 private function loadConcerningBuilds(DifferentialRevision $revision) {
+214
src/applications/diffusion/worker/DiffusionUpdateObjectAfterCommitWorker.php
··· 1 + <?php 2 + 3 + final class DiffusionUpdateObjectAfterCommitWorker 4 + extends PhabricatorWorker { 5 + 6 + private $properties; 7 + 8 + protected function getViewer() { 9 + return PhabricatorUser::getOmnipotentUser(); 10 + } 11 + 12 + protected function doWork() { 13 + $viewer = $this->getViewer(); 14 + $data = $this->getTaskData(); 15 + 16 + $commit_phid = idx($data, 'commitPHID'); 17 + if (!$commit_phid) { 18 + throw new PhabricatorWorkerPermanentFailureException( 19 + pht('No "commitPHID" in task data.')); 20 + } 21 + 22 + $commit = id(new DiffusionCommitQuery()) 23 + ->setViewer($viewer) 24 + ->withPHIDs(array($commit_phid)) 25 + ->needIdentities(true) 26 + ->executeOne(); 27 + if (!$commit) { 28 + throw new PhabricatorWorkerPermanentFailureException( 29 + pht( 30 + 'Unable to load commit "%s".', 31 + $commit_phid)); 32 + } 33 + 34 + $object_phid = idx($data, 'objectPHID'); 35 + if (!$object_phid) { 36 + throw new PhabricatorWorkerPermanentFailureException( 37 + pht('No "objectPHID" in task data.')); 38 + } 39 + 40 + $object = id(new PhabricatorObjectQuery()) 41 + ->setViewer($viewer) 42 + ->withPHIDs(array($object_phid)) 43 + ->executeOne(); 44 + if (!$object) { 45 + throw new PhabricatorWorkerPermanentFailureException( 46 + pht( 47 + 'Unable to load object "%s".', 48 + $object_phid)); 49 + } 50 + 51 + $properties = idx($data, 'properties', array()); 52 + $this->properties = $properties; 53 + 54 + if ($object instanceof ManiphestTask) { 55 + $this->updateTask($commit, $object); 56 + } else if ($object instanceof DifferentialRevision) { 57 + $this->updateRevision($commit, $object); 58 + } 59 + } 60 + 61 + protected function getUpdateProperty($key, $default = null) { 62 + return idx($this->properties, $key, $default); 63 + } 64 + 65 + protected function getActingPHID(PhabricatorRepositoryCommit $commit) { 66 + if ($commit->hasCommitterIdentity()) { 67 + return $commit->getCommitterIdentity()->getIdentityDisplayPHID(); 68 + } 69 + 70 + if ($commit->hasAuthorIdentity()) { 71 + return $commit->getAuthorIdentity()->getIdentityDisplayPHID(); 72 + } 73 + 74 + return id(new PhabricatorDiffusionApplication())->getPHID(); 75 + } 76 + 77 + protected function loadActingUser($acting_phid) { 78 + // If we we were able to identify an author or committer for the commit, we 79 + // try to act as that user when affecting other objects, like tasks marked 80 + // with "Fixes Txxx". 81 + 82 + // This helps to prevent mistakes where a user accidentally writes the 83 + // wrong task IDs and affects tasks they can't see (and thus can't undo the 84 + // status changes for). 85 + 86 + // This is just a guard rail, not a security measure. An attacker can still 87 + // forge another user's identity trivially by forging author or committer 88 + // email addresses. 89 + 90 + // We also let commits with unrecognized authors act on any task to make 91 + // behavior less confusing for new installs, and any user can craft a 92 + // commit with an unrecognized author and committer. 93 + 94 + $viewer = $this->getViewer(); 95 + 96 + $user_type = PhabricatorPeopleUserPHIDType::TYPECONST; 97 + if (phid_get_type($acting_phid) === $user_type) { 98 + $acting_user = id(new PhabricatorPeopleQuery()) 99 + ->setViewer($viewer) 100 + ->withPHIDs(array($acting_phid)) 101 + ->executeOne(); 102 + if ($acting_user) { 103 + return $acting_user; 104 + } 105 + } 106 + 107 + return $viewer; 108 + } 109 + 110 + private function updateTask( 111 + PhabricatorRepositoryCommit $commit, 112 + ManiphestTask $task) { 113 + 114 + $acting_phid = $this->getActingPHID($commit); 115 + $acting_user = $this->loadActingUser($acting_phid); 116 + 117 + $commit_phid = $commit->getPHID(); 118 + 119 + $xactions = array(); 120 + 121 + $xactions[] = $this->newEdgeTransaction( 122 + $task, 123 + $commit, 124 + ManiphestTaskHasCommitEdgeType::EDGECONST); 125 + 126 + $status = $this->getUpdateProperty('status'); 127 + if ($status) { 128 + $xactions[] = $task->getApplicationTransactionTemplate() 129 + ->setTransactionType(ManiphestTaskStatusTransaction::TRANSACTIONTYPE) 130 + ->setMetadataValue('commitPHID', $commit_phid) 131 + ->setNewValue($status); 132 + } 133 + 134 + $content_source = $this->newContentSource(); 135 + 136 + $editor = $task->getApplicationTransactionEditor() 137 + ->setActor($acting_user) 138 + ->setActingAsPHID($acting_phid) 139 + ->setContentSource($content_source) 140 + ->setContinueOnNoEffect(true) 141 + ->setContinueOnMissingFields(true) 142 + ->setUnmentionablePHIDMap( 143 + array( 144 + $commit_phid => $commit_phid, 145 + )); 146 + 147 + $editor->applyTransactions($task, $xactions); 148 + } 149 + 150 + private function updateRevision( 151 + PhabricatorRepositoryCommit $commit, 152 + DifferentialRevision $revision) { 153 + 154 + // Reload the revision to get the active diff, which is currently required 155 + // by "updateRevisionWithCommit()". 156 + $revision = id(new DifferentialRevisionQuery()) 157 + ->setViewer($this->getViewer()) 158 + ->withIDs(array($revision->getID())) 159 + ->needActiveDiffs(true) 160 + ->executeOne(); 161 + 162 + $acting_phid = $this->getActingPHID($commit); 163 + $acting_user = $this->loadActingUser($acting_phid); 164 + 165 + $xactions = array(); 166 + 167 + $xactions[] = $this->newEdgeTransaction( 168 + $revision, 169 + $commit, 170 + DiffusionCommitHasRevisionEdgeType::EDGECONST); 171 + 172 + $match_data = $this->getUpdateProperty('revisionMatchData'); 173 + 174 + $type_close = DifferentialRevisionCloseTransaction::TRANSACTIONTYPE; 175 + $xactions[] = $revision->getApplicationTransactionTemplate() 176 + ->setTransactionType($type_close) 177 + ->setNewValue(true) 178 + ->setMetadataValue('isCommitClose', true) 179 + ->setMetadataValue('revisionMatchData', $match_data) 180 + ->setMetadataValue('commitPHID', $commit->getPHID()); 181 + 182 + $extraction_engine = id(new DifferentialDiffExtractionEngine()) 183 + ->setViewer($acting_user) 184 + ->setAuthorPHID($acting_phid); 185 + 186 + $content_source = $this->newContentSource(); 187 + 188 + $extraction_engine->updateRevisionWithCommit( 189 + $revision, 190 + $commit, 191 + $xactions, 192 + $content_source); 193 + } 194 + 195 + private function newEdgeTransaction( 196 + $object, 197 + PhabricatorRepositoryCommit $commit, 198 + $edge_type) { 199 + 200 + $commit_phid = $commit->getPHID(); 201 + 202 + return $object->getApplicationTransactionTemplate() 203 + ->setTransactionType(PhabricatorTransactions::TYPE_EDGE) 204 + ->setMetadataValue('edge:type', $edge_type) 205 + ->setNewValue( 206 + array( 207 + '+' => array( 208 + $commit_phid => $commit_phid, 209 + ), 210 + )); 211 + } 212 + 213 + 214 + }
+15 -12
src/applications/repository/management/PhabricatorRepositoryManagementReparseWorkflow.php
··· 311 311 ); 312 312 313 313 if ($all_from_repo && !$force_local) { 314 - foreach ($classes as $class) { 315 - PhabricatorWorker::scheduleTask( 316 - $class, 317 - $spec, 318 - array( 319 - 'priority' => PhabricatorWorker::PRIORITY_IMPORT, 320 - )); 321 - } 314 + $background = true; 322 315 } else { 323 - foreach ($classes as $class) { 324 - $worker = newv($class, array($spec)); 325 - $worker->executeTask(); 326 - } 316 + $background = false; 317 + } 318 + 319 + if (!$background) { 320 + PhabricatorWorker::setRunAllTasksInProcess(true); 321 + } 322 + 323 + foreach ($classes as $class) { 324 + PhabricatorWorker::scheduleTask( 325 + $class, 326 + $spec, 327 + array( 328 + 'priority' => PhabricatorWorker::PRIORITY_IMPORT, 329 + )); 327 330 } 328 331 329 332 $progress->update(1);
+79 -183
src/applications/repository/worker/commitmessageparser/PhabricatorRepositoryCommitMessageParserWorker.php
··· 148 148 $author_phid); 149 149 } 150 150 151 - $differential_app = 'PhabricatorDifferentialApplication'; 152 - $revision_id = null; 153 - $low_level_query = null; 154 - if (PhabricatorApplication::isClassInstalled($differential_app)) { 155 - $low_level_query = id(new DiffusionLowLevelCommitFieldsQuery()) 156 - ->setRepository($repository) 157 - ->withCommitRef($ref); 158 - $field_values = $low_level_query->execute(); 159 - $revision_id = idx($field_values, 'revisionID'); 160 - 161 - if (!empty($field_values['reviewedByPHIDs'])) { 162 - $data->setCommitDetail( 163 - 'reviewerPHID', 164 - reset($field_values['reviewedByPHIDs'])); 165 - } 166 - 167 - $data->setCommitDetail('differential.revisionID', $revision_id); 168 - } 169 - 170 151 if ($author_phid != $commit->getAuthorPHID()) { 171 152 $commit->setAuthorPHID($author_phid); 172 153 } ··· 191 172 $should_autoclose = $force_autoclose || 192 173 $repository->shouldAutocloseCommit($commit); 193 174 194 - 195 175 // When updating related objects, we'll act under an omnipotent user to 196 176 // ensure we can see them, but take actions as either the committer or 197 177 // author (if we recognize their accounts) or the Diffusion application 198 178 // (if we do not). 199 179 200 - $actor = PhabricatorUser::getOmnipotentUser(); 201 - $acting_as_phid = nonempty( 202 - $committer_phid, 203 - $author_phid, 204 - id(new PhabricatorDiffusionApplication())->getPHID()); 205 - 206 - $acting_user = $this->loadActingUser($actor, $acting_as_phid); 207 - 208 - $conn_w = id(new DifferentialRevision())->establishConnection('w'); 209 - 210 - // NOTE: The `differential_commit` table has a unique ID on `commitPHID`, 211 - // preventing more than one revision from being associated with a commit. 212 - // Generally this is good and desirable, but with the advent of hash 213 - // tracking we may end up in a situation where we match several different 214 - // revisions. We just kind of ignore this and pick one, we might want to 215 - // revisit this and do something differently. (If we match several revisions 216 - // someone probably did something very silly, though.) 217 - 218 - $revision = null; 219 - if ($revision_id) { 220 - $revision_query = id(new DifferentialRevisionQuery()) 221 - ->withIDs(array($revision_id)) 222 - ->setViewer($actor) 223 - ->needReviewers(true) 224 - ->needActiveDiffs(true); 225 - 226 - $revision = $revision_query->executeOne(); 227 - 228 - if ($revision) { 229 - $commit_drev = DiffusionCommitHasRevisionEdgeType::EDGECONST; 230 - id(new PhabricatorEdgeEditor()) 231 - ->addEdge($commit->getPHID(), $commit_drev, $revision->getPHID()) 232 - ->save(); 233 - 234 - $should_close = !$revision->isPublished() && $should_autoclose; 235 - if ($should_close) { 236 - $type_close = DifferentialRevisionCloseTransaction::TRANSACTIONTYPE; 237 - 238 - $commit_close_xaction = id(new DifferentialTransaction()) 239 - ->setTransactionType($type_close) 240 - ->setNewValue(true); 241 - 242 - $commit_close_xaction->setMetadataValue( 243 - 'commitPHID', 244 - $commit->getPHID()); 245 - 246 - if ($low_level_query) { 247 - $commit_close_xaction->setMetadataValue( 248 - 'revisionMatchData', 249 - $low_level_query->getRevisionMatchData()); 250 - $data->setCommitDetail( 251 - 'revisionMatchData', 252 - $low_level_query->getRevisionMatchData()); 253 - } 254 - 255 - $extraction_engine = id(new DifferentialDiffExtractionEngine()) 256 - ->setViewer($actor) 257 - ->setAuthorPHID($acting_as_phid); 258 - 259 - $content_source = $this->newContentSource(); 260 - 261 - $extraction_engine->updateRevisionWithCommit( 262 - $revision, 263 - $commit, 264 - array( 265 - $commit_close_xaction, 266 - ), 267 - $content_source); 268 - } 269 - } 270 - } 271 - 272 180 if ($should_autoclose) { 273 - $this->closeTasks( 274 - $actor, 275 - $acting_as_phid, 276 - $repository, 277 - $commit, 278 - $message, 279 - $acting_user); 181 + $actor = PhabricatorUser::getOmnipotentUser(); 182 + $this->closeRevisions($actor, $ref, $commit, $data); 183 + $this->closeTasks($actor, $ref, $commit, $data); 280 184 } 281 185 282 186 $data->save(); ··· 295 199 ->execute(); 296 200 } 297 201 298 - private function closeTasks( 202 + private function closeRevisions( 299 203 PhabricatorUser $actor, 300 - $acting_as, 301 - PhabricatorRepository $repository, 204 + DiffusionCommitRef $ref, 302 205 PhabricatorRepositoryCommit $commit, 303 - $message, 304 - PhabricatorUser $acting_user = null) { 206 + PhabricatorRepositoryCommitData $data) { 305 207 306 - // If we we were able to identify an author for the commit, we try to act 307 - // as that user when loading tasks marked with "Fixes Txxx". This prevents 308 - // mistakes where a user accidentally writes the wrong task IDs and affects 309 - // tasks they can't see (and thus can't undo the status changes for). 208 + $differential = 'PhabricatorDifferentialApplication'; 209 + if (!PhabricatorApplication::isClassInstalled($differential)) { 210 + return; 211 + } 310 212 311 - // This is just a guard rail, not a security measure. An attacker can still 312 - // forge another user's identity trivially by forging author or committer 313 - // emails. We also let commits with unrecognized authors act on any task to 314 - // make behavior less confusing for new installs. 213 + $repository = $commit->getRepository(); 315 214 316 - if (!$acting_user) { 317 - $acting_user = $actor; 215 + $field_query = id(new DiffusionLowLevelCommitFieldsQuery()) 216 + ->setRepository($repository) 217 + ->withCommitRef($ref); 218 + 219 + $field_values = $field_query->execute(); 220 + 221 + $revision_id = idx($field_values, 'revisionID'); 222 + if (!$revision_id) { 223 + return; 318 224 } 319 225 226 + $revision = id(new DifferentialRevisionQuery()) 227 + ->setViewer($actor) 228 + ->withIDs(array($revision_id)) 229 + ->executeOne(); 230 + if (!$revision) { 231 + return; 232 + } 233 + 234 + // NOTE: This is very old code from when revisions had a single reviewer. 235 + // It still powers the "Reviewer (Deprecated)" field in Herald, but should 236 + // be removed. 237 + if (!empty($field_values['reviewedByPHIDs'])) { 238 + $data->setCommitDetail( 239 + 'reviewerPHID', 240 + head($field_values['reviewedByPHIDs'])); 241 + } 242 + 243 + $match_data = $field_query->getRevisionMatchData(); 244 + 245 + $data->setCommitDetail('differential.revisionID', $revision_id); 246 + $data->setCommitDetail('revisionMatchData', $match_data); 247 + 248 + $properties = array( 249 + 'revisionMatchData' => $match_data, 250 + ); 251 + $this->queueObjectUpdate($commit, $revision, $properties); 252 + } 253 + 254 + private function closeTasks( 255 + PhabricatorUser $actor, 256 + DiffusionCommitRef $ref, 257 + PhabricatorRepositoryCommit $commit, 258 + PhabricatorRepositoryCommitData $data) { 259 + 320 260 $maniphest = 'PhabricatorManiphestApplication'; 321 261 if (!PhabricatorApplication::isClassInstalled($maniphest)) { 322 262 return; ··· 324 264 325 265 $prefixes = ManiphestTaskStatus::getStatusPrefixMap(); 326 266 $suffixes = ManiphestTaskStatus::getStatusSuffixMap(); 267 + $message = $data->getCommitMessage(); 327 268 328 269 $matches = id(new ManiphestCustomFieldStatusParser()) 329 270 ->parseCorpus($message); 330 271 331 - $task_statuses = array(); 272 + $task_map = array(); 332 273 foreach ($matches as $match) { 333 274 $prefix = phutil_utf8_strtolower($match['prefix']); 334 275 $suffix = phutil_utf8_strtolower($match['suffix']); ··· 340 281 341 282 foreach ($match['monograms'] as $task_monogram) { 342 283 $task_id = (int)trim($task_monogram, 'tT'); 343 - $task_statuses[$task_id] = $status; 284 + $task_map[$task_id] = $status; 344 285 } 345 286 } 346 287 347 - if (!$task_statuses) { 288 + if (!$task_map) { 348 289 return; 349 290 } 350 291 351 292 $tasks = id(new ManiphestTaskQuery()) 352 - ->setViewer($acting_user) 353 - ->withIDs(array_keys($task_statuses)) 354 - ->needProjectPHIDs(true) 355 - ->requireCapabilities( 356 - array( 357 - PhabricatorPolicyCapability::CAN_VIEW, 358 - PhabricatorPolicyCapability::CAN_EDIT, 359 - )) 293 + ->setViewer($actor) 294 + ->withIDs(array_keys($task_map)) 360 295 ->execute(); 361 - 362 296 foreach ($tasks as $task_id => $task) { 363 - $xactions = array(); 297 + $status = $task_map[$task_id]; 364 298 365 - $edge_type = ManiphestTaskHasCommitEdgeType::EDGECONST; 366 - $edge_xaction = id(new ManiphestTransaction()) 367 - ->setTransactionType(PhabricatorTransactions::TYPE_EDGE) 368 - ->setMetadataValue('edge:type', $edge_type) 369 - ->setNewValue( 370 - array( 371 - '+' => array( 372 - $commit->getPHID() => $commit->getPHID(), 373 - ), 374 - )); 299 + $properties = array( 300 + 'status' => $status, 301 + ); 375 302 376 - $status = $task_statuses[$task_id]; 377 - if ($status) { 378 - if ($task->getStatus() != $status) { 379 - $xactions[] = id(new ManiphestTransaction()) 380 - ->setTransactionType( 381 - ManiphestTaskStatusTransaction::TRANSACTIONTYPE) 382 - ->setMetadataValue('commitPHID', $commit->getPHID()) 383 - ->setNewValue($status); 384 - 385 - $edge_xaction->setMetadataValue('commitPHID', $commit->getPHID()); 386 - } 387 - } 388 - 389 - $xactions[] = $edge_xaction; 390 - 391 - $content_source = $this->newContentSource(); 392 - 393 - $editor = id(new ManiphestTransactionEditor()) 394 - ->setActor($actor) 395 - ->setActingAsPHID($acting_as) 396 - ->setContinueOnNoEffect(true) 397 - ->setContinueOnMissingFields(true) 398 - ->setUnmentionablePHIDMap( 399 - array($commit->getPHID() => $commit->getPHID())) 400 - ->setContentSource($content_source); 401 - 402 - $editor->applyTransactions($task, $xactions); 303 + $this->queueObjectUpdate($commit, $task, $properties); 403 304 } 404 305 } 405 306 406 - private function loadActingUser(PhabricatorUser $viewer, $user_phid) { 407 - if (!$user_phid) { 408 - return null; 409 - } 410 - 411 - $user_type = PhabricatorPeopleUserPHIDType::TYPECONST; 412 - if (phid_get_type($user_phid) != $user_type) { 413 - return null; 414 - } 415 - 416 - $user = id(new PhabricatorPeopleQuery()) 417 - ->setViewer($viewer) 418 - ->withPHIDs(array($user_phid)) 419 - ->executeOne(); 420 - if (!$user) { 421 - return null; 422 - } 307 + private function queueObjectUpdate( 308 + PhabricatorRepositoryCommit $commit, 309 + $object, 310 + array $properties) { 423 311 424 - return $user; 312 + $this->queueTask( 313 + 'DiffusionUpdateObjectAfterCommitWorker', 314 + array( 315 + 'commitPHID' => $commit->getPHID(), 316 + 'objectPHID' => $object->getPHID(), 317 + 'properties' => $properties, 318 + ), 319 + array( 320 + 'priority' => PhabricatorWorker::PRIORITY_DEFAULT, 321 + )); 425 322 } 426 - 427 323 428 324 }