001/* 002 * (C) Copyright 2014-2016 Nuxeo SA (http://nuxeo.com/) and others. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 * 016 * Contributors: 017 * Florent Guillaume 018 */ 019package org.nuxeo.ecm.core.storage.dbs; 020 021import static java.lang.Boolean.FALSE; 022import static java.lang.Boolean.TRUE; 023import static org.nuxeo.ecm.core.api.security.SecurityConstants.BROWSE; 024import static org.nuxeo.ecm.core.api.security.SecurityConstants.EVERYONE; 025import static org.nuxeo.ecm.core.api.security.SecurityConstants.UNSUPPORTED_ACL; 026import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.INITIAL_CHANGE_TOKEN; 027import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.INITIAL_SYS_CHANGE_TOKEN; 028import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_ACE_GRANT; 029import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_ACE_PERMISSION; 030import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_ACE_USER; 031import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_ACL; 032import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_ACP; 033import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_ANCESTOR_IDS; 034import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_CHANGE_TOKEN; 035import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_FULLTEXT_JOBID; 036import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_ID; 037import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_IS_PROXY; 038import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_IS_VERSION; 039import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_MIXIN_TYPES; 040import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_NAME; 041import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_PARENT_ID; 042import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_POS; 043import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_PREFIX; 044import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_PRIMARY_TYPE; 045import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_PROXY_IDS; 046import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_PROXY_TARGET_ID; 047import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_PROXY_VERSION_SERIES_ID; 048import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_READ_ACL; 049import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_SYS_CHANGE_TOKEN; 050import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_VERSION_SERIES_ID; 051 052import java.io.Serializable; 053import java.util.ArrayList; 054import java.util.Arrays; 055import java.util.Collections; 056import java.util.HashMap; 057import java.util.HashSet; 058import java.util.LinkedHashSet; 059import java.util.LinkedList; 060import java.util.List; 061import java.util.Map; 062import java.util.Map.Entry; 063import java.util.Set; 064 065import org.apache.commons.lang.StringUtils; 066import org.apache.commons.logging.Log; 067import org.apache.commons.logging.LogFactory; 068import org.nuxeo.ecm.core.api.ConcurrentUpdateException; 069import org.nuxeo.ecm.core.api.model.DeltaLong; 070import org.nuxeo.ecm.core.api.repository.RepositoryManager; 071import org.nuxeo.ecm.core.schema.SchemaManager; 072import org.nuxeo.ecm.core.schema.types.Schema; 073import org.nuxeo.ecm.core.security.SecurityService; 074import org.nuxeo.ecm.core.storage.BaseDocument; 075import org.nuxeo.ecm.core.storage.DefaultFulltextParser; 076import org.nuxeo.ecm.core.storage.FulltextConfiguration; 077import org.nuxeo.ecm.core.storage.FulltextParser; 078import org.nuxeo.ecm.core.storage.FulltextUpdaterWork; 079import org.nuxeo.ecm.core.storage.FulltextUpdaterWork.IndexAndText; 080import org.nuxeo.ecm.core.storage.State; 081import org.nuxeo.ecm.core.storage.State.ListDiff; 082import org.nuxeo.ecm.core.storage.State.StateDiff; 083import org.nuxeo.ecm.core.storage.StateHelper; 084import org.nuxeo.ecm.core.work.api.Work; 085import org.nuxeo.ecm.core.work.api.WorkManager; 086import org.nuxeo.ecm.core.work.api.WorkManager.Scheduling; 087import org.nuxeo.runtime.api.Framework; 088 089/** 090 * Transactional state for a session. 091 * <p> 092 * Until {@code save()} is called, data lives in the transient map. 093 * <p> 094 * Upon save, data is written to the repository, even though it has not yet been committed (this means that other 095 * sessions can read uncommitted data). It's also kept in an undo log in order for rollback to be possible. 096 * <p> 097 * On commit, the undo log is forgotten. On rollback, the undo log is replayed. 098 * 099 * @since 5.9.4 100 */ 101public class DBSTransactionState { 102 103 private static final Log log = LogFactory.getLog(DBSTransactionState.class); 104 105 private static final String KEY_UNDOLOG_CREATE = "__UNDOLOG_CREATE__\0\0"; 106 107 protected final DBSRepository repository; 108 109 protected final DBSSession session; 110 111 /** Retrieved and created document state. */ 112 protected Map<String, DBSDocumentState> transientStates = new HashMap<>(); 113 114 /** Ids of documents created but not yet saved. */ 115 protected Set<String> transientCreated = new LinkedHashSet<>(); 116 117 /** 118 * Document ids modified as "user changes", which means that a change token should be checked. 119 * 120 * @since 9.2 121 */ 122 protected final Set<Serializable> userChangeIds = new HashSet<>(); 123 124 /** 125 * Undo log. 126 * <p> 127 * A map of document ids to null or State. The value is null when the document has to be deleted when applying the 128 * undo log. Otherwise the value is a State. If the State contains the key {@link #KEY_UNDOLOG_CREATE} then the 129 * state must be re-created completely when applying the undo log, otherwise just applied as an update. 130 * <p> 131 * Null when there is no active transaction. 132 */ 133 protected Map<String, State> undoLog; 134 135 protected final Set<String> browsePermissions; 136 137 public DBSTransactionState(DBSRepository repository, DBSSession session) { 138 this.repository = repository; 139 this.session = session; 140 SecurityService securityService = Framework.getLocalService(SecurityService.class); 141 browsePermissions = new HashSet<>(Arrays.asList(securityService.getPermissionsToCheck(BROWSE))); 142 } 143 144 /** 145 * New transient state for something just read from the repository. 146 */ 147 protected DBSDocumentState newTransientState(State state) { 148 if (state == null) { 149 return null; 150 } 151 String id = (String) state.get(KEY_ID); 152 if (transientStates.containsKey(id)) { 153 throw new IllegalStateException("Already transient: " + id); 154 } 155 DBSDocumentState docState = new DBSDocumentState(state); // copy 156 transientStates.put(id, docState); 157 return docState; 158 } 159 160 /** 161 * Returns a state and marks it as transient, because it's about to be modified or returned to user code (where it 162 * may be modified). 163 */ 164 public DBSDocumentState getStateForUpdate(String id) { 165 // check transient state 166 DBSDocumentState docState = transientStates.get(id); 167 if (docState != null) { 168 return docState; 169 } 170 // fetch from repository 171 State state = repository.readState(id); 172 return newTransientState(state); 173 } 174 175 /** 176 * Returns a state which won't be modified. 177 */ 178 // TODO in some cases it's good to have this kept in memory instead of 179 // rereading from database every time 180 // XXX getStateForReadOneShot 181 public State getStateForRead(String id) { 182 // check transient state 183 DBSDocumentState docState = transientStates.get(id); 184 if (docState != null) { 185 return docState.getState(); 186 } 187 // fetch from repository 188 return repository.readState(id); 189 } 190 191 /** 192 * Returns states and marks them transient, because they're about to be returned to user code (where they may be 193 * modified). 194 */ 195 public List<DBSDocumentState> getStatesForUpdate(List<String> ids) { 196 // check which ones we have to fetch from repository 197 List<String> idsToFetch = new LinkedList<>(); 198 for (String id : ids) { 199 // check transient state 200 DBSDocumentState docState = transientStates.get(id); 201 if (docState != null) { 202 continue; 203 } 204 // will have to fetch it 205 idsToFetch.add(id); 206 } 207 if (!idsToFetch.isEmpty()) { 208 List<State> states = repository.readStates(idsToFetch); 209 for (State state : states) { 210 newTransientState(state); 211 } 212 } 213 // everything now fetched in transient 214 List<DBSDocumentState> docStates = new ArrayList<>(ids.size()); 215 for (String id : ids) { 216 DBSDocumentState docState = transientStates.get(id); 217 if (docState == null) { 218 if (log.isTraceEnabled()) { 219 log.trace("Cannot fetch document with id: " + id, new Throwable("debug stack trace")); 220 } 221 continue; 222 } 223 docStates.add(docState); 224 } 225 return docStates; 226 } 227 228 // XXX TODO for update or for read? 229 public DBSDocumentState getChildState(String parentId, String name) { 230 // check transient state 231 for (DBSDocumentState docState : transientStates.values()) { 232 if (!parentId.equals(docState.getParentId())) { 233 continue; 234 } 235 if (!name.equals(docState.getName())) { 236 continue; 237 } 238 return docState; 239 } 240 // fetch from repository 241 State state = repository.readChildState(parentId, name, Collections.emptySet()); 242 if (state == null) { 243 return null; 244 } 245 String id = (String) state.get(KEY_ID); 246 if (transientStates.containsKey(id)) { 247 // found transient, even though we already checked 248 // that means that in-memory it's not a child, but in-database it's a child (was moved) 249 // -> ignore the database state 250 return null; 251 } 252 return newTransientState(state); 253 } 254 255 public boolean hasChild(String parentId, String name) { 256 // check transient state 257 for (DBSDocumentState docState : transientStates.values()) { 258 if (!parentId.equals(docState.getParentId())) { 259 continue; 260 } 261 if (!name.equals(docState.getName())) { 262 continue; 263 } 264 return true; 265 } 266 // check repository 267 return repository.hasChild(parentId, name, Collections.emptySet()); 268 } 269 270 public List<DBSDocumentState> getChildrenStates(String parentId) { 271 List<DBSDocumentState> docStates = new LinkedList<>(); 272 Set<String> seen = new HashSet<>(); 273 // check transient state 274 for (DBSDocumentState docState : transientStates.values()) { 275 if (!parentId.equals(docState.getParentId())) { 276 continue; 277 } 278 docStates.add(docState); 279 seen.add(docState.getId()); 280 } 281 // fetch from repository 282 List<State> states = repository.queryKeyValue(KEY_PARENT_ID, parentId, seen); 283 for (State state : states) { 284 String id = (String) state.get(KEY_ID); 285 if (transientStates.containsKey(id)) { 286 // found transient, even though we passed an exclusion list for known children 287 // that means that in-memory it's not a child, but in-database it's a child (was moved) 288 // -> ignore the database state 289 continue; 290 } 291 docStates.add(newTransientState(state)); 292 } 293 return docStates; 294 } 295 296 public List<String> getChildrenIds(String parentId) { 297 List<String> children = new ArrayList<>(); 298 Set<String> seen = new HashSet<>(); 299 // check transient state 300 for (DBSDocumentState docState : transientStates.values()) { 301 String id = docState.getId(); 302 if (!parentId.equals(docState.getParentId())) { 303 continue; 304 } 305 seen.add(id); 306 children.add(id); 307 } 308 // fetch from repository 309 List<State> states = repository.queryKeyValue(KEY_PARENT_ID, parentId, seen); 310 for (State state : states) { 311 String id = (String) state.get(KEY_ID); 312 if (transientStates.containsKey(id)) { 313 // found transient, even though we passed an exclusion list for known children 314 // that means that in-memory it's not a child, but in-database it's a child (was moved) 315 // -> ignore the database state 316 continue; 317 } 318 children.add(id); 319 } 320 return new ArrayList<>(children); 321 } 322 323 public boolean hasChildren(String parentId) { 324 // check transient state 325 for (DBSDocumentState docState : transientStates.values()) { 326 if (!parentId.equals(docState.getParentId())) { 327 continue; 328 } 329 return true; 330 } 331 // check repository 332 return repository.queryKeyValuePresence(KEY_PARENT_ID, parentId, Collections.emptySet()); 333 } 334 335 public DBSDocumentState createChild(String id, String parentId, String name, Long pos, String typeName) { 336 // id may be not-null for import 337 if (id == null) { 338 id = repository.generateNewId(); 339 } 340 transientCreated.add(id); 341 DBSDocumentState docState = new DBSDocumentState(); 342 transientStates.put(id, docState); 343 docState.put(KEY_ID, id); 344 docState.put(KEY_PARENT_ID, parentId); 345 docState.put(KEY_ANCESTOR_IDS, getAncestorIds(parentId)); 346 docState.put(KEY_NAME, name); 347 docState.put(KEY_POS, pos); 348 docState.put(KEY_PRIMARY_TYPE, typeName); 349 if (session.changeTokenEnabled) { 350 docState.put(KEY_SYS_CHANGE_TOKEN, INITIAL_SYS_CHANGE_TOKEN); 351 } 352 // update read acls for new doc 353 updateDocumentReadAcls(id); 354 return docState; 355 } 356 357 /** Gets ancestors including id itself. */ 358 protected Object[] getAncestorIds(String id) { 359 if (id == null) { 360 return null; 361 } 362 State state = getStateForRead(id); 363 if (state == null) { 364 throw new RuntimeException("No such id: " + id); 365 } 366 Object[] ancestors = (Object[]) state.get(KEY_ANCESTOR_IDS); 367 if (ancestors == null) { 368 return new Object[] { id }; 369 } else { 370 Object[] newAncestors = new Object[ancestors.length + 1]; 371 System.arraycopy(ancestors, 0, newAncestors, 0, ancestors.length); 372 newAncestors[ancestors.length] = id; 373 return newAncestors; 374 } 375 } 376 377 /** 378 * Copies the document into a newly-created object. 379 * <p> 380 * The copy is automatically saved. 381 */ 382 public DBSDocumentState copy(String id) { 383 DBSDocumentState copyState = new DBSDocumentState(getStateForRead(id)); 384 String copyId = repository.generateNewId(); 385 copyState.put(KEY_ID, copyId); 386 copyState.put(KEY_PROXY_IDS, null); // no proxies to this new doc 387 // other fields updated by the caller 388 transientStates.put(copyId, copyState); 389 transientCreated.add(copyId); 390 return copyState; 391 } 392 393 /** 394 * Updates ancestors recursively after a move. 395 * <p> 396 * Recursing from given doc, replace the first ndel ancestors with those passed. 397 * <p> 398 * Doesn't check transient (assumes save is done). The modifications are automatically saved. 399 */ 400 public void updateAncestors(String id, int ndel, Object[] ancestorIds) { 401 int nadd = ancestorIds.length; 402 Set<String> ids = getSubTree(id, null, null); 403 ids.add(id); 404 for (String cid : ids) { 405 // XXX TODO oneShot update, don't pollute transient space 406 DBSDocumentState docState = getStateForUpdate(cid); 407 Object[] ancestors = (Object[]) docState.get(KEY_ANCESTOR_IDS); 408 Object[] newAncestors; 409 if (ancestors == null) { 410 newAncestors = ancestorIds.clone(); 411 } else { 412 newAncestors = new Object[ancestors.length - ndel + nadd]; 413 System.arraycopy(ancestorIds, 0, newAncestors, 0, nadd); 414 System.arraycopy(ancestors, ndel, newAncestors, nadd, ancestors.length - ndel); 415 } 416 docState.put(KEY_ANCESTOR_IDS, newAncestors); 417 } 418 } 419 420 /** 421 * Updates the Read ACLs recursively on a document. 422 */ 423 public void updateTreeReadAcls(String id) { 424 // versions too XXX TODO 425 Set<String> ids = getSubTree(id, null, null); 426 ids.add(id); 427 ids.forEach(this::updateDocumentReadAcls); 428 } 429 430 /** 431 * Updates the Read ACLs on a document (not recursively) 432 */ 433 protected void updateDocumentReadAcls(String id) { 434 // XXX TODO oneShot update, don't pollute transient space 435 DBSDocumentState docState = getStateForUpdate(id); 436 docState.put(KEY_READ_ACL, getReadACL(docState)); 437 } 438 439 /** 440 * Gets the Read ACL (flat list of users having browse permission, including inheritance) on a document. 441 */ 442 protected String[] getReadACL(DBSDocumentState docState) { 443 Set<String> racls = new HashSet<>(); 444 State state = docState.getState(); 445 LOOP: do { 446 @SuppressWarnings("unchecked") 447 List<Serializable> aclList = (List<Serializable>) state.get(KEY_ACP); 448 if (aclList != null) { 449 for (Serializable aclSer : aclList) { 450 State aclMap = (State) aclSer; 451 @SuppressWarnings("unchecked") 452 List<Serializable> aceList = (List<Serializable>) aclMap.get(KEY_ACL); 453 for (Serializable aceSer : aceList) { 454 State aceMap = (State) aceSer; 455 String username = (String) aceMap.get(KEY_ACE_USER); 456 String permission = (String) aceMap.get(KEY_ACE_PERMISSION); 457 Boolean granted = (Boolean) aceMap.get(KEY_ACE_GRANT); 458 if (TRUE.equals(granted) && browsePermissions.contains(permission)) { 459 racls.add(username); 460 } 461 if (FALSE.equals(granted)) { 462 if (!EVERYONE.equals(username)) { 463 // TODO log 464 racls.add(UNSUPPORTED_ACL); 465 } 466 break LOOP; 467 } 468 } 469 } 470 } 471 // get parent 472 if (TRUE.equals(state.get(KEY_IS_VERSION))) { 473 String versionSeriesId = (String) state.get(KEY_VERSION_SERIES_ID); 474 state = versionSeriesId == null ? null : getStateForRead(versionSeriesId); 475 } else { 476 String parentId = (String) state.get(KEY_PARENT_ID); 477 state = parentId == null ? null : getStateForRead(parentId); 478 } 479 } while (state != null); 480 481 // sort to have canonical order 482 List<String> racl = new ArrayList<>(racls); 483 Collections.sort(racl); 484 return racl.toArray(new String[racl.size()]); 485 } 486 487 /** 488 * Gets all the ids under a given one, recursively. 489 * <p> 490 * Doesn't check transient (assumes save is done). 491 * 492 * @param id the root of the tree (not included in results) 493 * @param proxyTargets returns a map of proxy to target among the documents found 494 * @param targetProxies returns a map of target to proxies among the document found 495 */ 496 protected Set<String> getSubTree(String id, Map<String, String> proxyTargets, Map<String, Object[]> targetProxies) { 497 Set<String> ids = new HashSet<>(); 498 // check repository 499 repository.queryKeyValueArray(KEY_ANCESTOR_IDS, id, ids, proxyTargets, targetProxies); 500 return ids; 501 } 502 503 public List<DBSDocumentState> getKeyValuedStates(String key, Object value) { 504 List<DBSDocumentState> docStates = new LinkedList<>(); 505 Set<String> seen = new HashSet<>(); 506 // check transient state 507 for (DBSDocumentState docState : transientStates.values()) { 508 if (!value.equals(docState.get(key))) { 509 continue; 510 } 511 docStates.add(docState); 512 seen.add(docState.getId()); 513 } 514 // fetch from repository 515 List<State> states = repository.queryKeyValue(key, value, seen); 516 for (State state : states) { 517 docStates.add(newTransientState(state)); 518 } 519 return docStates; 520 } 521 522 public List<DBSDocumentState> getKeyValuedStates(String key1, Object value1, String key2, Object value2) { 523 List<DBSDocumentState> docStates = new LinkedList<>(); 524 Set<String> seen = new HashSet<>(); 525 // check transient state 526 for (DBSDocumentState docState : transientStates.values()) { 527 seen.add(docState.getId()); 528 if (!(value1.equals(docState.get(key1)) && value2.equals(docState.get(key2)))) { 529 continue; 530 } 531 docStates.add(docState); 532 } 533 // fetch from repository 534 List<State> states = repository.queryKeyValue(key1, value1, key2, value2, seen); 535 for (State state : states) { 536 docStates.add(newTransientState(state)); 537 } 538 return docStates; 539 } 540 541 /** 542 * Removes a list of documents. 543 * <p> 544 * Called after a {@link #save} has been done. 545 */ 546 public void removeStates(Set<String> ids) { 547 if (undoLog != null) { 548 for (String id : ids) { 549 if (undoLog.containsKey(id)) { 550 // there's already a create or an update in the undo log 551 State oldUndo = undoLog.get(id); 552 if (oldUndo == null) { 553 // create + delete -> forget 554 undoLog.remove(id); 555 } else { 556 // update + delete -> original old state to re-create 557 oldUndo.put(KEY_UNDOLOG_CREATE, TRUE); 558 } 559 } else { 560 // just delete -> store old state to re-create 561 State oldState = StateHelper.deepCopy(getStateForRead(id)); 562 oldState.put(KEY_UNDOLOG_CREATE, TRUE); 563 undoLog.put(id, oldState); 564 } 565 } 566 } 567 for (String id : ids) { 568 transientStates.remove(id); 569 } 570 repository.deleteStates(ids); 571 } 572 573 public void markUserChange(String id) { 574 userChangeIds.add(id); 575 } 576 577 /** 578 * Writes transient state to database. 579 * <p> 580 * An undo log is kept in order to rollback the transaction later if needed. 581 */ 582 public void save() { 583 updateProxies(); 584 List<Work> works; 585 if (!repository.isFulltextDisabled()) { 586 // TODO getting fulltext already does a getStateChange 587 works = getFulltextWorks(); 588 } else { 589 works = Collections.emptyList(); 590 } 591 List<State> statesToCreate = new ArrayList<>(); 592 for (String id : transientCreated) { // ordered 593 DBSDocumentState docState = transientStates.get(id); 594 docState.setNotDirty(); 595 if (undoLog != null) { 596 undoLog.put(id, null); // marker to denote create 597 } 598 State state = docState.getState(); 599 state.put(KEY_CHANGE_TOKEN, INITIAL_CHANGE_TOKEN); 600 statesToCreate.add(state); 601 } 602 if (!statesToCreate.isEmpty()) { 603 repository.createStates(statesToCreate); 604 } 605 for (DBSDocumentState docState : transientStates.values()) { 606 String id = docState.getId(); 607 if (transientCreated.contains(id)) { 608 continue; // already done 609 } 610 StateDiff diff = docState.getStateChange(); 611 if (diff != null) { 612 if (undoLog != null) { 613 if (!undoLog.containsKey(id)) { 614 undoLog.put(id, StateHelper.deepCopy(docState.getOriginalState())); 615 } 616 // else there's already a create or an update in the undo log so original info is enough 617 } 618 ChangeTokenUpdater changeTokenUpdater; 619 if (session.changeTokenEnabled) { 620 // increment system change token 621 Long base = (Long) docState.get(KEY_SYS_CHANGE_TOKEN); 622 docState.put(KEY_SYS_CHANGE_TOKEN, DeltaLong.valueOf(base, 1)); 623 diff.put(KEY_SYS_CHANGE_TOKEN, DeltaLong.valueOf(base, 1)); 624 // update change token if applicable (user change) 625 if (userChangeIds.contains(id)) { 626 changeTokenUpdater = new ChangeTokenUpdater(docState); 627 } else { 628 changeTokenUpdater = null; 629 } 630 } else { 631 changeTokenUpdater = null; 632 } 633 repository.updateState(id, diff, changeTokenUpdater); 634 } 635 docState.setNotDirty(); 636 } 637 transientCreated.clear(); 638 userChangeIds.clear(); 639 scheduleWork(works); 640 } 641 642 /** 643 * Logic to get the conditions to use to match and update a change token. 644 * <p> 645 * This may be called several times for a single DBS document update, because the low-level storage may need several 646 * database updates for a single high-level update in some cases. 647 * 648 * @since 9.1 649 */ 650 public static class ChangeTokenUpdater { 651 652 protected final DBSDocumentState docState; 653 654 protected Long oldToken; 655 656 public ChangeTokenUpdater(DBSDocumentState docState) { 657 this.docState = docState; 658 oldToken = (Long) docState.getOriginalState().get(KEY_CHANGE_TOKEN); 659 } 660 661 /** 662 * Gets the conditions to use to match a change token. 663 */ 664 public Map<String, Serializable> getConditions() { 665 return Collections.singletonMap(KEY_CHANGE_TOKEN, oldToken); 666 } 667 668 /** 669 * Gets the updates to make to write the updated change token. 670 */ 671 public Map<String, Serializable> getUpdates() { 672 Long newToken; 673 if (oldToken == null) { 674 // document without change token, just created 675 newToken = INITIAL_CHANGE_TOKEN; 676 } else { 677 newToken = BaseDocument.updateChangeToken(oldToken); 678 } 679 // also store the new token in the state (without marking dirty), for the next update 680 docState.getState().put(KEY_CHANGE_TOKEN, newToken); 681 oldToken = newToken; 682 return Collections.singletonMap(KEY_CHANGE_TOKEN, newToken); 683 } 684 } 685 686 protected void applyUndoLog() { 687 Set<String> deletes = new HashSet<>(); 688 for (Entry<String, State> es : undoLog.entrySet()) { 689 String id = es.getKey(); 690 State state = es.getValue(); 691 if (state == null) { 692 deletes.add(id); 693 } else { 694 boolean recreate = state.remove(KEY_UNDOLOG_CREATE) != null; 695 if (recreate) { 696 repository.createState(state); 697 } else { 698 // undo update 699 State currentState = repository.readState(id); 700 if (currentState != null) { 701 StateDiff diff = StateHelper.diff(currentState, state); 702 if (!diff.isEmpty()) { 703 repository.updateState(id, diff, null); 704 } 705 } 706 // else we expected to read a current state but it was concurrently deleted... 707 // in that case leave it deleted 708 } 709 } 710 } 711 if (!deletes.isEmpty()) { 712 repository.deleteStates(deletes); 713 } 714 } 715 716 /** 717 * Checks if the changed documents are proxy targets, and updates the proxies if that's the case. 718 */ 719 protected void updateProxies() { 720 for (String id : transientCreated) { // ordered 721 DBSDocumentState docState = transientStates.get(id); 722 updateProxies(docState); 723 } 724 // copy as we may modify proxies 725 for (String id : transientStates.keySet().toArray(new String[0])) { 726 DBSDocumentState docState = transientStates.get(id); 727 if (transientCreated.contains(id)) { 728 continue; // already done 729 } 730 if (docState.isDirty()) { 731 updateProxies(docState); 732 } 733 } 734 } 735 736 protected void updateProxies(DBSDocumentState target) { 737 Object[] proxyIds = (Object[]) target.get(KEY_PROXY_IDS); 738 if (proxyIds != null) { 739 for (Object proxyId : proxyIds) { 740 try { 741 updateProxy(target, (String) proxyId); 742 } catch (ConcurrentUpdateException e) { 743 e.addInfo("On doc " + target.getId()); 744 log.error(e, e); 745 // do not throw, this avoids crashing the session 746 } 747 } 748 } 749 } 750 751 /** 752 * Updates the state of a proxy based on its target. 753 */ 754 protected void updateProxy(DBSDocumentState target, String proxyId) { 755 DBSDocumentState proxy = getStateForUpdate(proxyId); 756 if (proxy == null) { 757 throw new ConcurrentUpdateException("Proxy " + proxyId + " concurrently deleted"); 758 } 759 SchemaManager schemaManager = Framework.getService(SchemaManager.class); 760 // clear all proxy data 761 for (String key : proxy.getState().keyArray()) { 762 if (!isProxySpecific(key, schemaManager)) { 763 proxy.put(key, null); 764 } 765 } 766 // copy from target 767 for (Entry<String, Serializable> en : target.getState().entrySet()) { 768 String key = en.getKey(); 769 if (!isProxySpecific(key, schemaManager)) { 770 proxy.put(key, StateHelper.deepCopy(en.getValue())); 771 } 772 } 773 } 774 775 /** 776 * Things that we don't touch on a proxy when updating it. 777 */ 778 protected boolean isProxySpecific(String key, SchemaManager schemaManager) { 779 switch (key) { 780 // these are placeful stuff 781 case KEY_ID: 782 case KEY_PARENT_ID: 783 case KEY_ANCESTOR_IDS: 784 case KEY_NAME: 785 case KEY_POS: 786 case KEY_ACP: 787 case KEY_READ_ACL: 788 // these are proxy-specific 789 case KEY_IS_PROXY: 790 case KEY_PROXY_TARGET_ID: 791 case KEY_PROXY_VERSION_SERIES_ID: 792 case KEY_IS_VERSION: 793 case KEY_PROXY_IDS: 794 return true; 795 } 796 int p = key.indexOf(':'); 797 if (p == -1) { 798 // no prefix, assume not proxy-specific 799 return false; 800 } 801 String prefix = key.substring(0, p); 802 Schema schema = schemaManager.getSchemaFromPrefix(prefix); 803 if (schema == null) { 804 schema = schemaManager.getSchema(prefix); 805 if (schema == null) { 806 // unknown prefix, assume not proxy-specific 807 return false; 808 } 809 } 810 return schemaManager.isProxySchema(schema.getName(), null); // type unused 811 } 812 813 /** 814 * Called when created in a transaction. 815 * 816 * @since 7.4 817 */ 818 public void begin() { 819 undoLog = new HashMap<>(); 820 repository.begin(); 821 } 822 823 /** 824 * Saves and flushes to database. 825 */ 826 public void commit() { 827 save(); 828 commitSave(); 829 repository.commit(); 830 } 831 832 /** 833 * Commits the saved state to the database. 834 */ 835 protected void commitSave() { 836 // clear transient, this means that after this references to states will be stale 837 // TODO mark states as invalid 838 clearTransient(); 839 // the transaction ended, the proxied DBSSession will disappear and cannot be reused anyway 840 undoLog = null; 841 } 842 843 /** 844 * Rolls back the save state by applying the undo log. 845 */ 846 public void rollback() { 847 clearTransient(); 848 applyUndoLog(); 849 // the transaction ended, the proxied DBSSession will disappear and cannot be reused anyway 850 undoLog = null; 851 repository.rollback(); 852 } 853 854 protected void clearTransient() { 855 transientStates.clear(); 856 transientCreated.clear(); 857 } 858 859 /** 860 * Gets the fulltext updates to do. Called at save() time. 861 * 862 * @return a list of {@link Work} instances to schedule post-commit. 863 */ 864 protected List<Work> getFulltextWorks() { 865 Set<String> docsWithDirtyStrings = new HashSet<>(); 866 Set<String> docsWithDirtyBinaries = new HashSet<>(); 867 findDirtyDocuments(docsWithDirtyStrings, docsWithDirtyBinaries); 868 if (docsWithDirtyStrings.isEmpty() && docsWithDirtyBinaries.isEmpty()) { 869 return Collections.emptyList(); 870 } 871 List<Work> works = new LinkedList<>(); 872 getFulltextSimpleWorks(works, docsWithDirtyStrings); 873 getFulltextBinariesWorks(works, docsWithDirtyBinaries); 874 return works; 875 } 876 877 /** 878 * Finds the documents having dirty text or dirty binaries that have to be reindexed as fulltext. 879 * 880 * @param docsWithDirtyStrings set of ids, updated by this method 881 * @param docWithDirtyBinaries set of ids, updated by this method 882 */ 883 protected void findDirtyDocuments(Set<String> docsWithDirtyStrings, Set<String> docWithDirtyBinaries) { 884 for (DBSDocumentState docState : transientStates.values()) { 885 State originalState = docState.getOriginalState(); 886 State state = docState.getState(); 887 if (originalState == state) { 888 continue; 889 } 890 StateDiff diff = StateHelper.diff(originalState, state); 891 if (diff.isEmpty()) { 892 continue; 893 } 894 StateDiff rdiff = StateHelper.diff(state, originalState); 895 // we do diffs in both directions to capture removal of complex list elements, 896 // for instance for {foo: [{bar: baz}] -> {foo: []} 897 // diff paths = foo and rdiff paths = foo/*/bar 898 Set<String> paths = new HashSet<>(); 899 DirtyPathsFinder dirtyPathsFinder = new DirtyPathsFinder(paths); 900 dirtyPathsFinder.findDirtyPaths(diff); 901 dirtyPathsFinder.findDirtyPaths(rdiff); 902 FulltextConfiguration fulltextConfiguration = repository.getFulltextConfiguration(); 903 boolean dirtyStrings = false; 904 boolean dirtyBinaries = false; 905 for (String path : paths) { 906 Set<String> indexesSimple = fulltextConfiguration.indexesByPropPathSimple.get(path); 907 if (indexesSimple != null && !indexesSimple.isEmpty()) { 908 dirtyStrings = true; 909 if (dirtyBinaries) { 910 break; 911 } 912 } 913 Set<String> indexesBinary = fulltextConfiguration.indexesByPropPathBinary.get(path); 914 if (indexesBinary != null && !indexesBinary.isEmpty()) { 915 dirtyBinaries = true; 916 if (dirtyStrings) { 917 break; 918 } 919 } 920 } 921 if (dirtyStrings) { 922 docsWithDirtyStrings.add(docState.getId()); 923 } 924 if (dirtyBinaries) { 925 docWithDirtyBinaries.add(docState.getId()); 926 } 927 } 928 } 929 930 /** 931 * Iterates on a state diff to find the paths corresponding to dirty values. 932 * 933 * @since 7.10-HF04, 8.1 934 */ 935 protected static class DirtyPathsFinder { 936 937 protected Set<String> paths; 938 939 public DirtyPathsFinder(Set<String> paths) { 940 this.paths = paths; 941 } 942 943 public void findDirtyPaths(StateDiff value) { 944 findDirtyPaths(value, null); 945 } 946 947 protected void findDirtyPaths(Object value, String path) { 948 if (value instanceof Object[]) { 949 findDirtyPaths((Object[]) value, path); 950 } else if (value instanceof List) { 951 findDirtyPaths((List<?>) value, path); 952 } else if (value instanceof ListDiff) { 953 findDirtyPaths((ListDiff) value, path); 954 } else if (value instanceof State) { 955 findDirtyPaths((State) value, path); 956 } else { 957 paths.add(path); 958 } 959 } 960 961 protected void findDirtyPaths(Object[] value, String path) { 962 String newPath = path + "/*"; 963 for (Object v : value) { 964 findDirtyPaths(v, newPath); 965 } 966 } 967 968 protected void findDirtyPaths(List<?> value, String path) { 969 String newPath = path + "/*"; 970 for (Object v : value) { 971 findDirtyPaths(v, newPath); 972 } 973 } 974 975 protected void findDirtyPaths(ListDiff value, String path) { 976 String newPath = path + "/*"; 977 if (value.diff != null) { 978 findDirtyPaths(value.diff, newPath); 979 } 980 if (value.rpush != null) { 981 findDirtyPaths(value.rpush, newPath); 982 } 983 } 984 985 protected void findDirtyPaths(State value, String path) { 986 for (Entry<String, Serializable> es : value.entrySet()) { 987 String key = es.getKey(); 988 Serializable v = es.getValue(); 989 String newPath = path == null ? key : path + "/" + key; 990 findDirtyPaths(v, newPath); 991 } 992 } 993 } 994 995 protected void getFulltextSimpleWorks(List<Work> works, Set<String> docsWithDirtyStrings) { 996 // TODO XXX make configurable, see also FulltextExtractorWork 997 FulltextParser fulltextParser = new DefaultFulltextParser(); 998 FulltextConfiguration fulltextConfiguration = repository.getFulltextConfiguration(); 999 if (fulltextConfiguration.fulltextSearchDisabled) { 1000 return; 1001 } 1002 // update simpletext on documents with dirty strings 1003 for (String id : docsWithDirtyStrings) { 1004 if (id == null) { 1005 // cannot happen, but has been observed :( 1006 log.error("Got null doc id in fulltext update, cannot happen"); 1007 continue; 1008 } 1009 DBSDocumentState docState = getStateForUpdate(id); 1010 if (docState == null) { 1011 // cannot happen 1012 continue; 1013 } 1014 String documentType = docState.getPrimaryType(); 1015 // Object[] mixinTypes = (Object[]) docState.get(KEY_MIXIN_TYPES); 1016 1017 if (!fulltextConfiguration.isFulltextIndexable(documentType)) { 1018 continue; 1019 } 1020 docState.put(KEY_FULLTEXT_JOBID, docState.getId()); 1021 FulltextFinder fulltextFinder = new FulltextFinder(fulltextParser, docState, session); 1022 List<IndexAndText> indexesAndText = new LinkedList<>(); 1023 for (String indexName : fulltextConfiguration.indexNames) { 1024 // TODO paths from config 1025 String text = fulltextFinder.findFulltext(indexName); 1026 indexesAndText.add(new IndexAndText(indexName, text)); 1027 } 1028 if (!indexesAndText.isEmpty()) { 1029 Work work = new FulltextUpdaterWork(repository.getName(), id, true, false, indexesAndText); 1030 works.add(work); 1031 } 1032 } 1033 } 1034 1035 protected void getFulltextBinariesWorks(List<Work> works, Set<String> docWithDirtyBinaries) { 1036 if (docWithDirtyBinaries.isEmpty()) { 1037 return; 1038 } 1039 1040 FulltextConfiguration fulltextConfiguration = repository.getFulltextConfiguration(); 1041 1042 // mark indexing in progress, so that future copies (including versions) 1043 // will be indexed as well 1044 for (String id : docWithDirtyBinaries) { 1045 DBSDocumentState docState = getStateForUpdate(id); 1046 if (docState == null) { 1047 // cannot happen 1048 continue; 1049 } 1050 if (!fulltextConfiguration.isFulltextIndexable(docState.getPrimaryType())) { 1051 continue; 1052 } 1053 docState.put(KEY_FULLTEXT_JOBID, docState.getId()); 1054 } 1055 1056 // FulltextExtractorWork does fulltext extraction using converters 1057 // and then schedules a FulltextUpdaterWork to write the results 1058 // single-threaded 1059 for (String id : docWithDirtyBinaries) { 1060 // don't exclude proxies 1061 Work work = new DBSFulltextExtractorWork(repository.getName(), id); 1062 works.add(work); 1063 } 1064 } 1065 1066 protected static class FulltextFinder { 1067 1068 protected final FulltextParser fulltextParser; 1069 1070 protected final DBSDocumentState document; 1071 1072 protected final DBSSession session; 1073 1074 protected final String documentType; 1075 1076 protected final Object[] mixinTypes; 1077 1078 /** 1079 * Prepares parsing for one document. 1080 */ 1081 public FulltextFinder(FulltextParser fulltextParser, DBSDocumentState document, DBSSession session) { 1082 this.fulltextParser = fulltextParser; 1083 this.document = document; 1084 this.session = session; 1085 if (document == null) { 1086 documentType = null; 1087 mixinTypes = null; 1088 } else { // null in tests 1089 documentType = document.getPrimaryType(); 1090 mixinTypes = (Object[]) document.get(KEY_MIXIN_TYPES); 1091 } 1092 } 1093 1094 /** 1095 * Parses the document for one index. 1096 */ 1097 public String findFulltext(String indexName) { 1098 // TODO indexName 1099 // TODO paths 1100 List<String> strings = new ArrayList<>(); 1101 findFulltext(indexName, document.getState(), strings); 1102 return StringUtils.join(strings, ' '); 1103 } 1104 1105 protected void findFulltext(String indexName, State state, List<String> strings) { 1106 for (Entry<String, Serializable> en : state.entrySet()) { 1107 String key = en.getKey(); 1108 if (key.startsWith(KEY_PREFIX)) { 1109 switch (key) { 1110 // allow indexing of this: 1111 case DBSDocument.KEY_NAME: 1112 break; 1113 default: 1114 continue; 1115 } 1116 } 1117 Serializable value = en.getValue(); 1118 if (value instanceof State) { 1119 State s = (State) value; 1120 findFulltext(indexName, s, strings); 1121 } else if (value instanceof List) { 1122 @SuppressWarnings("unchecked") 1123 List<State> v = (List<State>) value; 1124 for (State s : v) { 1125 findFulltext(indexName, s, strings); 1126 } 1127 } else if (value instanceof Object[]) { 1128 Object[] ar = (Object[]) value; 1129 for (Object v : ar) { 1130 if (v instanceof String) { 1131 fulltextParser.parse((String) v, null, strings); 1132 } else { 1133 // arrays are homogeneous, no need to continue 1134 break; 1135 } 1136 } 1137 } else { 1138 if (value instanceof String) { 1139 fulltextParser.parse((String) value, null, strings); 1140 } 1141 } 1142 } 1143 } 1144 } 1145 1146 protected void scheduleWork(List<Work> works) { 1147 // do async fulltext indexing only if high-level sessions are available 1148 RepositoryManager repositoryManager = Framework.getLocalService(RepositoryManager.class); 1149 if (repositoryManager != null && !works.isEmpty()) { 1150 WorkManager workManager = Framework.getLocalService(WorkManager.class); 1151 for (Work work : works) { 1152 // schedule work post-commit 1153 // in non-tx mode, this may execute it nearly immediately 1154 workManager.schedule(work, Scheduling.IF_NOT_SCHEDULED, true); 1155 } 1156 } 1157 } 1158 1159}