
001/*- 002 * #%L 003 * HAPI FHIR Storage api 004 * %% 005 * Copyright (C) 2014 - 2025 Smile CDR, Inc. 006 * %% 007 * Licensed under the Apache License, Version 2.0 (the "License"); 008 * you may not use this file except in compliance with the License. 009 * You may obtain a copy of the License at 010 * 011 * http://www.apache.org/licenses/LICENSE-2.0 012 * 013 * Unless required by applicable law or agreed to in writing, software 014 * distributed under the License is distributed on an "AS IS" BASIS, 015 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 016 * See the License for the specific language governing permissions and 017 * limitations under the License. 018 * #L% 019 */ 020package ca.uhn.fhir.jpa.partition; 021 022import ca.uhn.fhir.context.FhirContext; 023import ca.uhn.fhir.context.RuntimeResourceDefinition; 024import ca.uhn.fhir.i18n.Msg; 025import ca.uhn.fhir.interceptor.api.HookParams; 026import ca.uhn.fhir.interceptor.api.IInterceptorBroadcaster; 027import ca.uhn.fhir.interceptor.api.Pointcut; 028import ca.uhn.fhir.interceptor.model.ReadPartitionIdRequestDetails; 029import ca.uhn.fhir.interceptor.model.RequestPartitionId; 030import ca.uhn.fhir.jpa.model.config.PartitionSettings; 031import ca.uhn.fhir.jpa.model.util.JpaConstants; 032import ca.uhn.fhir.rest.api.Constants; 033import ca.uhn.fhir.rest.api.server.RequestDetails; 034import ca.uhn.fhir.rest.api.server.SystemRequestDetails; 035import ca.uhn.fhir.rest.server.exceptions.InternalErrorException; 036import ca.uhn.fhir.rest.server.exceptions.UnprocessableEntityException; 037import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails; 038import ca.uhn.fhir.rest.server.util.CompositeInterceptorBroadcaster; 039import ca.uhn.fhir.util.Logs; 040import jakarta.annotation.Nonnull; 041import jakarta.annotation.Nullable; 042import org.apache.commons.lang3.StringUtils; 043import org.hl7.fhir.instance.model.api.IBaseResource; 044import org.slf4j.Logger; 045import org.springframework.beans.factory.annotation.Autowired; 046 047import java.util.ArrayList; 048import java.util.Arrays; 049import java.util.HashSet; 050import java.util.List; 051import java.util.Set; 052import java.util.stream.Collectors; 053 054public abstract class BaseRequestPartitionHelperSvc implements IRequestPartitionHelperSvc { 055 056 public static final Logger ourLog = Logs.getPartitionTroubleshootingLog(); 057 private final HashSet<Object> myNonPartitionableResourceNames; 058 059 @Autowired 060 protected FhirContext myFhirContext; 061 062 @Autowired 063 private IInterceptorBroadcaster myInterceptorBroadcaster; 064 065 PartitionSettings myPartitionSettings; 066 067 protected BaseRequestPartitionHelperSvc() { 068 myNonPartitionableResourceNames = new HashSet<>(); 069 070 // Infrastructure 071 myNonPartitionableResourceNames.add("SearchParameter"); 072 073 // Validation and Conformance 074 myNonPartitionableResourceNames.add("StructureDefinition"); 075 myNonPartitionableResourceNames.add("Questionnaire"); 076 myNonPartitionableResourceNames.add("CapabilityStatement"); 077 myNonPartitionableResourceNames.add("CompartmentDefinition"); 078 myNonPartitionableResourceNames.add("OperationDefinition"); 079 080 myNonPartitionableResourceNames.add("Library"); 081 082 // Terminology 083 myNonPartitionableResourceNames.add("ConceptMap"); 084 myNonPartitionableResourceNames.add("CodeSystem"); 085 myNonPartitionableResourceNames.add("ValueSet"); 086 myNonPartitionableResourceNames.add("NamingSystem"); 087 myNonPartitionableResourceNames.add("StructureMap"); 088 } 089 090 @Autowired 091 public void setPartitionSettings(PartitionSettings thePartitionSettings) { 092 myPartitionSettings = thePartitionSettings; 093 } 094 095 /** 096 * Invoke the {@link Pointcut#STORAGE_PARTITION_IDENTIFY_READ} interceptor pointcut to determine the tenant for a read request. 097 * <p> 098 * If no interceptors are registered with a hook for {@link Pointcut#STORAGE_PARTITION_IDENTIFY_READ}, return 099 * {@link RequestPartitionId#allPartitions()} instead. 100 */ 101 @Nonnull 102 @Override 103 public RequestPartitionId determineReadPartitionForRequest( 104 @Nullable RequestDetails theRequest, @Nonnull ReadPartitionIdRequestDetails theDetails) { 105 if (!myPartitionSettings.isPartitioningEnabled()) { 106 return RequestPartitionId.allPartitions(); 107 } 108 109 // certain use-cases (e.g. batch2 jobs), only have resource type populated in the ReadPartitionIdRequestDetails 110 // TODO MM: see if we can make RequestDetails consistent 111 String resourceType = theDetails.getResourceType(); 112 113 RequestDetails requestDetails = theRequest; 114 // TODO GGG eventually, theRequest will not be allowed to be null here, and we will pass through 115 // SystemRequestDetails instead. 116 if (requestDetails == null) { 117 requestDetails = new SystemRequestDetails(); 118 logSubstitutingDefaultSystemRequestDetails(); 119 } 120 121 boolean nonPartitionableResource = isResourceNonPartitionable(resourceType); 122 123 RequestPartitionId requestPartitionId = null; 124 125 if (nonPartitionableResource) { 126 requestPartitionId = myPartitionSettings.getDefaultRequestPartitionId(); 127 logRequestDetailsResolution(requestDetails); 128 logNonPartitionableType(resourceType); 129 } else if (requestDetails instanceof SystemRequestDetails systemRequestDetails 130 && systemRequestHasExplicitPartition(systemRequestDetails)) { 131 // !nonPartitionableResource 132 requestPartitionId = getSystemRequestPartitionId(systemRequestDetails, false); 133 logRequestDetailsResolution(systemRequestDetails); 134 135 } else { 136 IInterceptorBroadcaster compositeBroadcaster = 137 CompositeInterceptorBroadcaster.newCompositeBroadcaster(myInterceptorBroadcaster, requestDetails); 138 if (compositeBroadcaster.hasHooks(Pointcut.STORAGE_PARTITION_IDENTIFY_ANY)) { 139 requestPartitionId = callAnyPointcut(compositeBroadcaster, requestDetails); 140 } else if (compositeBroadcaster.hasHooks(Pointcut.STORAGE_PARTITION_IDENTIFY_READ)) { 141 requestPartitionId = callReadPointcut(compositeBroadcaster, requestDetails, theDetails); 142 } 143 } 144 145 validateRequestPartitionNotNull( 146 requestPartitionId, Pointcut.STORAGE_PARTITION_IDENTIFY_ANY, Pointcut.STORAGE_PARTITION_IDENTIFY_READ); 147 148 RequestPartitionId resultRequestPartitionId = 149 validateAndNormalizePartition(requestPartitionId, requestDetails, resourceType); 150 logTroubleshootingResult("read", resourceType, theRequest, resultRequestPartitionId); 151 152 return resultRequestPartitionId; 153 } 154 155 private static RequestPartitionId callAnyPointcut( 156 IInterceptorBroadcaster compositeBroadcaster, RequestDetails requestDetails) { 157 // Interceptor call: STORAGE_PARTITION_IDENTIFY_ANY 158 HookParams params = new HookParams() 159 .add(RequestDetails.class, requestDetails) 160 .addIfMatchesType(ServletRequestDetails.class, requestDetails); 161 162 return callAndLog(compositeBroadcaster, Pointcut.STORAGE_PARTITION_IDENTIFY_ANY, params); 163 } 164 165 private static RequestPartitionId callCreatePointcut( 166 IInterceptorBroadcaster compositeBroadcaster, 167 RequestDetails requestDetails, 168 @Nonnull IBaseResource theResource) { 169 // Interceptor call: STORAGE_PARTITION_IDENTIFY_CREATE 170 HookParams params = new HookParams() 171 .add(IBaseResource.class, theResource) 172 .add(RequestDetails.class, requestDetails) 173 .addIfMatchesType(ServletRequestDetails.class, requestDetails); 174 175 return callAndLog(compositeBroadcaster, Pointcut.STORAGE_PARTITION_IDENTIFY_CREATE, params); 176 } 177 178 private static RequestPartitionId callAndLog( 179 IInterceptorBroadcaster compositeBroadcaster, Pointcut pointcut, HookParams params) { 180 RequestPartitionId result = 181 (RequestPartitionId) compositeBroadcaster.callHooksAndReturnObject(pointcut, params); 182 183 if (ourLog.isTraceEnabled()) { 184 ourLog.trace( 185 "{}: result={} hooks={}", pointcut, result, compositeBroadcaster.getInvokersForPointcut(pointcut)); 186 } 187 return result; 188 } 189 190 private static RequestPartitionId callReadPointcut( 191 IInterceptorBroadcaster compositeBroadcaster, 192 RequestDetails requestDetails, 193 @Nonnull ReadPartitionIdRequestDetails theDetails) { 194 // Interceptor call: STORAGE_PARTITION_IDENTIFY_READ 195 HookParams params = new HookParams() 196 .add(RequestDetails.class, requestDetails) 197 .addIfMatchesType(ServletRequestDetails.class, requestDetails) 198 .add(ReadPartitionIdRequestDetails.class, theDetails); 199 200 return callAndLog(compositeBroadcaster, Pointcut.STORAGE_PARTITION_IDENTIFY_READ, params); 201 } 202 203 private static void logNonPartitionableType(String theResourceType) { 204 ourLog.trace("Partitioning: resource type {} must be on the DEFAULT partition.", theResourceType); 205 } 206 207 @Override 208 public RequestPartitionId determineGenericPartitionForRequest(RequestDetails theRequestDetails) { 209 RequestPartitionId requestPartitionId = null; 210 211 if (!myPartitionSettings.isPartitioningEnabled()) { 212 return RequestPartitionId.allPartitions(); 213 } 214 215 if (theRequestDetails instanceof SystemRequestDetails systemRequestDetails 216 && systemRequestHasExplicitPartition(systemRequestDetails)) { 217 requestPartitionId = getSystemRequestPartitionId(systemRequestDetails); 218 logRequestDetailsResolution(systemRequestDetails); 219 } else { 220 IInterceptorBroadcaster compositeBroadcaster = CompositeInterceptorBroadcaster.newCompositeBroadcaster( 221 myInterceptorBroadcaster, theRequestDetails); 222 if (compositeBroadcaster.hasHooks(Pointcut.STORAGE_PARTITION_IDENTIFY_ANY)) { 223 requestPartitionId = callAnyPointcut(compositeBroadcaster, theRequestDetails); 224 } else if (compositeBroadcaster.hasHooks(Pointcut.STORAGE_PARTITION_IDENTIFY_READ)) { 225 ReadPartitionIdRequestDetails readDetails = ReadPartitionIdRequestDetails.forGeneric(theRequestDetails); 226 requestPartitionId = callReadPointcut(compositeBroadcaster, theRequestDetails, readDetails); 227 } 228 } 229 230 // TODO MM: at the moment it is ok for this method to return null 231 // check if it can be made consistent and it's implications 232 // validateRequestPartitionNotNull(requestPartitionId, Pointcut.STORAGE_PARTITION_IDENTIFY_ANY); 233 234 if (requestPartitionId != null) { 235 requestPartitionId = validateAndNormalizePartition( 236 requestPartitionId, theRequestDetails, theRequestDetails.getResourceName()); 237 } 238 239 logTroubleshootingResult("generic", theRequestDetails.getResourceName(), theRequestDetails, requestPartitionId); 240 241 return requestPartitionId; 242 } 243 244 /** 245 * For system requests, read partition from tenant ID if present, otherwise set to DEFAULT. If the resource they are attempting to partition 246 * is non-partitionable scream in the logs and set the partition to DEFAULT. 247 */ 248 private RequestPartitionId getSystemRequestPartitionId( 249 SystemRequestDetails theRequest, boolean theNonPartitionableResource) { 250 RequestPartitionId requestPartitionId; 251 requestPartitionId = getSystemRequestPartitionId(theRequest); 252 if (theNonPartitionableResource 253 && !requestPartitionId.isPartition(myPartitionSettings.getDefaultPartitionId())) { 254 throw new InternalErrorException(Msg.code(1315) 255 + "System call is attempting to write a non-partitionable resource to a partition! This is a bug!"); 256 } 257 return requestPartitionId; 258 } 259 260 /** 261 * Determine the partition for a System Call (defined by the fact that the request is of type SystemRequestDetails) 262 * <p> 263 * 1. If the tenant ID is set to the constant for all partitions, return all partitions 264 * 2. If there is a tenant ID set in the request, use it. 265 * 3. Otherwise, return the Default Partition. 266 * 267 * @param theRequest The {@link SystemRequestDetails} 268 * @return the {@link RequestPartitionId} to be used for this request. 269 */ 270 @Nonnull 271 private RequestPartitionId getSystemRequestPartitionId(@Nonnull SystemRequestDetails theRequest) { 272 if (theRequest.getRequestPartitionId() != null) { 273 return theRequest.getRequestPartitionId(); 274 } 275 if (theRequest.getTenantId() != null) { 276 // TODO: JA2 we should not be inferring the partition name from the tenant name 277 return RequestPartitionId.fromPartitionName(theRequest.getTenantId()); 278 } else { 279 return RequestPartitionId.defaultPartition(myPartitionSettings); 280 } 281 } 282 283 /** 284 * Invoke the {@link Pointcut#STORAGE_PARTITION_IDENTIFY_CREATE} interceptor pointcut to determine the tenant for a create request. 285 */ 286 @Nonnull 287 @Override 288 public RequestPartitionId determineCreatePartitionForRequest( 289 @Nullable final RequestDetails theRequest, 290 @Nonnull IBaseResource theResource, 291 @Nonnull String theResourceType) { 292 293 if (!myPartitionSettings.isPartitioningEnabled()) { 294 return RequestPartitionId.allPartitions(); 295 } 296 297 RequestDetails requestDetails = theRequest; 298 boolean nonPartitionableResource = isResourceNonPartitionable(theResourceType); 299 300 // TODO GGG eventually, theRequest will not be allowed to be null here, and we will pass through 301 // SystemRequestDetails instead. 302 if (theRequest == null) { 303 requestDetails = new SystemRequestDetails(); 304 logSubstitutingDefaultSystemRequestDetails(); 305 } 306 307 // If we previously fetched a resource from the DB, it already knows what 308 // partition it is from, so we don't need to figure out its partition again 309 // when writing to it 310 RequestPartitionId existingPartitionId = 311 (RequestPartitionId) theResource.getUserData(Constants.RESOURCE_PARTITION_ID); 312 if (existingPartitionId != null) { 313 existingPartitionId = validateAndNormalizePartition(existingPartitionId, theRequest, theResourceType); 314 return existingPartitionId; 315 } 316 317 RequestPartitionId requestPartitionId = null; 318 319 if (theRequest instanceof SystemRequestDetails systemRequestDetails 320 && systemRequestHasExplicitPartition(systemRequestDetails)) { 321 requestPartitionId = getSystemRequestPartitionId(systemRequestDetails, nonPartitionableResource); 322 323 logRequestDetailsResolution(systemRequestDetails); 324 } else { 325 IInterceptorBroadcaster compositeBroadcaster = 326 CompositeInterceptorBroadcaster.newCompositeBroadcaster(myInterceptorBroadcaster, requestDetails); 327 if (compositeBroadcaster.hasHooks(Pointcut.STORAGE_PARTITION_IDENTIFY_ANY)) { 328 requestPartitionId = callAnyPointcut(compositeBroadcaster, requestDetails); 329 } else if (compositeBroadcaster.hasHooks(Pointcut.STORAGE_PARTITION_IDENTIFY_CREATE)) { 330 requestPartitionId = callCreatePointcut(compositeBroadcaster, requestDetails, theResource); 331 } 332 } 333 334 // If the interceptors haven't selected a partition, and its a non-partitionable resource anyhow, send 335 // to DEFAULT 336 if (nonPartitionableResource && requestPartitionId == null) { 337 logNonPartitionableType(theResourceType); 338 requestPartitionId = myPartitionSettings.getDefaultRequestPartitionId(); 339 } 340 341 validateRequestPartitionNotNull( 342 requestPartitionId, 343 Pointcut.STORAGE_PARTITION_IDENTIFY_CREATE, 344 Pointcut.STORAGE_PARTITION_IDENTIFY_ANY); 345 validatePartitionForCreate(requestPartitionId, theResourceType); 346 347 RequestPartitionId resultRequestPartitionId = 348 validateAndNormalizePartition(requestPartitionId, requestDetails, theResourceType); 349 350 logTroubleshootingResult("create", theResourceType, theRequest, resultRequestPartitionId); 351 352 return resultRequestPartitionId; 353 } 354 355 private boolean systemRequestHasExplicitPartition(@Nonnull SystemRequestDetails theRequest) { 356 return theRequest.getRequestPartitionId() != null || theRequest.getTenantId() != null; 357 } 358 359 @Nonnull 360 @Override 361 public Set<Integer> toReadPartitions(@Nonnull RequestPartitionId theRequestPartitionId) { 362 return theRequestPartitionId.getPartitionIds().stream() 363 .map(t -> t == null ? myPartitionSettings.getDefaultPartitionId() : t) 364 .collect(Collectors.toSet()); 365 } 366 367 /** 368 * If the partition only has a name but not an ID, this method resolves the ID. 369 * <p> 370 * If the partition has an ID but not a name, the name is resolved. 371 * <p> 372 * If the partition has both, they are validated to ensure that they correspond. 373 */ 374 @Nonnull 375 private RequestPartitionId validateAndNormalizePartition( 376 @Nonnull RequestPartitionId theRequestPartitionId, 377 RequestDetails theRequest, 378 @Nullable String theResourceType) { 379 RequestPartitionId retVal = theRequestPartitionId; 380 381 if (!myPartitionSettings.isUnnamedPartitionMode()) { 382 if (retVal.getPartitionNames() != null) { 383 retVal = validateAndNormalizePartitionNames(retVal); 384 } else if (retVal.hasPartitionIds()) { 385 retVal = validateAndNormalizePartitionIds(retVal); 386 } 387 } 388 389 // Note: It's still possible that the partition only has a date but no name/id 390 391 if (StringUtils.isNotBlank(theResourceType)) { 392 validateHasPartitionPermissions(theRequest, theResourceType, retVal); 393 } 394 395 // Replace null partition ID with non-null default partition ID if one is being used 396 if (myPartitionSettings.getDefaultPartitionId() != null 397 && retVal.hasPartitionIds() 398 && retVal.hasDefaultPartitionId(null)) { 399 List<Integer> partitionIds = new ArrayList<>(retVal.getPartitionIds()); 400 for (int i = 0; i < partitionIds.size(); i++) { 401 if (partitionIds.get(i) == null) { 402 partitionIds.set(i, myPartitionSettings.getDefaultPartitionId()); 403 } 404 } 405 retVal = RequestPartitionId.fromPartitionIds(partitionIds); 406 } 407 408 ourLog.trace("Partition normalization: {} -> {}", theRequestPartitionId, retVal); 409 410 return retVal; 411 } 412 413 @Override 414 public void validateHasPartitionPermissions( 415 @Nonnull RequestDetails theRequest, String theResourceType, RequestPartitionId theRequestPartitionId) { 416 IInterceptorBroadcaster compositeBroadcaster = 417 CompositeInterceptorBroadcaster.newCompositeBroadcaster(myInterceptorBroadcaster, theRequest); 418 if (compositeBroadcaster.hasHooks(Pointcut.STORAGE_PARTITION_SELECTED)) { 419 RuntimeResourceDefinition runtimeResourceDefinition = null; 420 if (theResourceType != null) { 421 runtimeResourceDefinition = myFhirContext.getResourceDefinition(theResourceType); 422 } 423 HookParams params = new HookParams() 424 .add(RequestPartitionId.class, theRequestPartitionId) 425 .add(RequestDetails.class, theRequest) 426 .addIfMatchesType(ServletRequestDetails.class, theRequest) 427 .add(RuntimeResourceDefinition.class, runtimeResourceDefinition); 428 compositeBroadcaster.callHooks(Pointcut.STORAGE_PARTITION_SELECTED, params); 429 } 430 } 431 432 @Override 433 public boolean isResourcePartitionable(String theResourceType) { 434 return theResourceType != null && !myNonPartitionableResourceNames.contains(theResourceType); 435 } 436 437 @Override 438 @Nullable 439 public Integer getDefaultPartitionId() { 440 return myPartitionSettings.getDefaultPartitionId(); 441 } 442 443 private boolean isResourceNonPartitionable(String theResourceType) { 444 return theResourceType != null && !isResourcePartitionable(theResourceType); 445 } 446 447 private void validatePartitionForCreate(RequestPartitionId theRequestPartitionId, String theResourceName) { 448 if (theRequestPartitionId.hasPartitionIds()) { 449 validateSinglePartitionIdOrName(theRequestPartitionId.getPartitionIds()); 450 } 451 validateSinglePartitionIdOrName(theRequestPartitionId.getPartitionNames()); 452 453 // Make sure we're not using one of the conformance resources in a non-default partition 454 if (isDefaultPartition(theRequestPartitionId) || theRequestPartitionId.isAllPartitions()) { 455 return; 456 } 457 458 // TODO MM: check if we need to validate using the configured value PartitionSettings.defaultPartition 459 // however that is only used for read and not for create at the moment 460 if ((theRequestPartitionId.hasPartitionIds() 461 && !theRequestPartitionId.getPartitionIds().contains(null)) 462 || (theRequestPartitionId.hasPartitionNames() 463 && !theRequestPartitionId.getPartitionNames().contains(JpaConstants.DEFAULT_PARTITION_NAME))) { 464 465 if (isResourceNonPartitionable(theResourceName)) { 466 String msg = myFhirContext 467 .getLocalizer() 468 .getMessageSanitized( 469 BaseRequestPartitionHelperSvc.class, 470 "nonDefaultPartitionSelectedForNonPartitionable", 471 theResourceName); 472 throw new UnprocessableEntityException(Msg.code(1318) + msg); 473 } 474 } 475 } 476 477 private static void validateRequestPartitionNotNull( 478 RequestPartitionId theRequestPartitionId, Pointcut... thePointcuts) { 479 if (theRequestPartitionId == null) { 480 throw new InternalErrorException( 481 Msg.code(1319) + "No interceptor provided a value for pointcuts: " + Arrays.toString(thePointcuts)); 482 } 483 } 484 485 private static void validateSinglePartitionIdOrName(@Nullable List<?> thePartitionIds) { 486 if (thePartitionIds != null && thePartitionIds.size() != 1) { 487 throw new InternalErrorException( 488 Msg.code(1320) + "RequestPartitionId must contain a single partition for create operations, found: " 489 + thePartitionIds); 490 } 491 } 492 493 private static void logTroubleshootingResult( 494 String theAction, 495 String theResourceType, 496 @Nullable RequestDetails theRequest, 497 RequestPartitionId theResult) { 498 String tenantId = theRequest != null ? theRequest.getTenantId() : null; 499 ourLog.debug( 500 "Partitioning: action={} resource type={} with request tenant ID={} routed to RequestPartitionId={}", 501 theAction, 502 theResourceType, 503 tenantId, 504 theResult); 505 } 506 507 private void logRequestDetailsResolution(RequestDetails theRequest) { 508 String logString = "Partitioning: request is a {}, with tenantId {}"; 509 510 String requestClassName = theRequest.getClass().getSimpleName(); 511 String tenantId = theRequest.getTenantId(); 512 RequestPartitionId requestPartitionId = null; 513 514 if (theRequest instanceof SystemRequestDetails systemRequestDetails) { 515 requestPartitionId = systemRequestDetails.getRequestPartitionId(); 516 logString = logString + ", with request partitionId {}"; 517 } 518 519 ourLog.trace(logString, requestClassName, tenantId, requestPartitionId); 520 } 521 522 private static void logSubstitutingDefaultSystemRequestDetails() { 523 ourLog.trace("No RequestDetails present. Using default SystemRequestDetails."); 524 } 525}