
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.server.RequestDetails; 033import ca.uhn.fhir.rest.api.server.SystemRequestDetails; 034import ca.uhn.fhir.rest.server.exceptions.InternalErrorException; 035import ca.uhn.fhir.rest.server.exceptions.UnprocessableEntityException; 036import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails; 037import ca.uhn.fhir.rest.server.util.CompositeInterceptorBroadcaster; 038import ca.uhn.fhir.util.Logs; 039import jakarta.annotation.Nonnull; 040import jakarta.annotation.Nullable; 041import org.apache.commons.lang3.StringUtils; 042import org.hl7.fhir.instance.model.api.IBaseResource; 043import org.slf4j.Logger; 044import org.springframework.beans.factory.annotation.Autowired; 045 046import java.util.ArrayList; 047import java.util.Arrays; 048import java.util.HashSet; 049import java.util.List; 050import java.util.Set; 051import java.util.stream.Collectors; 052 053public abstract class BaseRequestPartitionHelperSvc implements IRequestPartitionHelperSvc { 054 055 public static final Logger ourLog = Logs.getPartitionTroubleshootingLog(); 056 private final HashSet<Object> myNonPartitionableResourceNames; 057 058 @Autowired 059 protected FhirContext myFhirContext; 060 061 @Autowired 062 private IInterceptorBroadcaster myInterceptorBroadcaster; 063 064 @Autowired 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 /** 091 * Invoke the {@link Pointcut#STORAGE_PARTITION_IDENTIFY_READ} interceptor pointcut to determine the tenant for a read request. 092 * <p> 093 * If no interceptors are registered with a hook for {@link Pointcut#STORAGE_PARTITION_IDENTIFY_READ}, return 094 * {@link RequestPartitionId#allPartitions()} instead. 095 */ 096 @Nonnull 097 @Override 098 public RequestPartitionId determineReadPartitionForRequest( 099 @Nullable RequestDetails theRequest, @Nonnull ReadPartitionIdRequestDetails theDetails) { 100 if (!myPartitionSettings.isPartitioningEnabled()) { 101 return RequestPartitionId.allPartitions(); 102 } 103 104 // certain use-cases (e.g. batch2 jobs), only have resource type populated in the ReadPartitionIdRequestDetails 105 // TODO MM: see if we can make RequestDetails consistent 106 String resourceType = theDetails.getResourceType(); 107 108 RequestDetails requestDetails = theRequest; 109 // TODO GGG eventually, theRequest will not be allowed to be null here, and we will pass through 110 // SystemRequestDetails instead. 111 if (requestDetails == null) { 112 requestDetails = new SystemRequestDetails(); 113 logSubstitutingDefaultSystemRequestDetails(); 114 } 115 116 boolean nonPartitionableResource = isResourceNonPartitionable(resourceType); 117 118 RequestPartitionId requestPartitionId = null; 119 // Handle system requests 120 if (requestDetails instanceof SystemRequestDetails 121 && systemRequestHasExplicitPartition((SystemRequestDetails) requestDetails) 122 && !nonPartitionableResource) { 123 requestPartitionId = getSystemRequestPartitionId((SystemRequestDetails) requestDetails, false); 124 logSystemRequestDetailsResolution((SystemRequestDetails) requestDetails); 125 126 } else if ((requestDetails instanceof SystemRequestDetails) && nonPartitionableResource) { 127 requestPartitionId = RequestPartitionId.fromPartitionId(myPartitionSettings.getDefaultPartitionId()); 128 logSystemRequestDetailsResolution((SystemRequestDetails) requestDetails); 129 logNonPartitionableType(resourceType); 130 } else { 131 // TODO mb: why is this path different than create? 132 // Here, a non-partitionable resource is still delivered to the pointcuts. 133 IInterceptorBroadcaster compositeBroadcaster = 134 CompositeInterceptorBroadcaster.newCompositeBroadcaster(myInterceptorBroadcaster, requestDetails); 135 if (compositeBroadcaster.hasHooks(Pointcut.STORAGE_PARTITION_IDENTIFY_ANY)) { 136 requestPartitionId = callAnyPointcut(compositeBroadcaster, requestDetails); 137 } else if (compositeBroadcaster.hasHooks(Pointcut.STORAGE_PARTITION_IDENTIFY_READ)) { 138 requestPartitionId = callReadPointcut(compositeBroadcaster, requestDetails, theDetails); 139 } 140 } 141 142 validateRequestPartitionNotNull( 143 requestPartitionId, Pointcut.STORAGE_PARTITION_IDENTIFY_ANY, Pointcut.STORAGE_PARTITION_IDENTIFY_READ); 144 145 RequestPartitionId resultRequestPartitionId = 146 validateAndNormalizePartition(requestPartitionId, requestDetails, resourceType); 147 logTroubleshootingResult("read", resourceType, theRequest, resultRequestPartitionId); 148 149 return resultRequestPartitionId; 150 } 151 152 private static RequestPartitionId callAnyPointcut( 153 IInterceptorBroadcaster compositeBroadcaster, RequestDetails requestDetails) { 154 // Interceptor call: STORAGE_PARTITION_IDENTIFY_ANY 155 HookParams params = new HookParams() 156 .add(RequestDetails.class, requestDetails) 157 .addIfMatchesType(ServletRequestDetails.class, requestDetails); 158 159 return callAndLog(compositeBroadcaster, Pointcut.STORAGE_PARTITION_IDENTIFY_ANY, params); 160 } 161 162 private static RequestPartitionId callCreatePointcut( 163 IInterceptorBroadcaster compositeBroadcaster, 164 RequestDetails requestDetails, 165 @Nonnull IBaseResource theResource) { 166 // Interceptor call: STORAGE_PARTITION_IDENTIFY_CREATE 167 HookParams params = new HookParams() 168 .add(IBaseResource.class, theResource) 169 .add(RequestDetails.class, requestDetails) 170 .addIfMatchesType(ServletRequestDetails.class, requestDetails); 171 172 return callAndLog(compositeBroadcaster, Pointcut.STORAGE_PARTITION_IDENTIFY_CREATE, params); 173 } 174 175 private static RequestPartitionId callAndLog( 176 IInterceptorBroadcaster compositeBroadcaster, Pointcut pointcut, HookParams params) { 177 RequestPartitionId result = 178 (RequestPartitionId) compositeBroadcaster.callHooksAndReturnObject(pointcut, params); 179 180 if (ourLog.isTraceEnabled()) { 181 ourLog.trace( 182 "{}: result={} hooks={}", pointcut, result, compositeBroadcaster.getInvokersForPointcut(pointcut)); 183 } 184 return result; 185 } 186 187 private static RequestPartitionId callReadPointcut( 188 IInterceptorBroadcaster compositeBroadcaster, 189 RequestDetails requestDetails, 190 @Nonnull ReadPartitionIdRequestDetails theDetails) { 191 // Interceptor call: STORAGE_PARTITION_IDENTIFY_READ 192 HookParams params = new HookParams() 193 .add(RequestDetails.class, requestDetails) 194 .addIfMatchesType(ServletRequestDetails.class, requestDetails) 195 .add(ReadPartitionIdRequestDetails.class, theDetails); 196 197 return callAndLog(compositeBroadcaster, Pointcut.STORAGE_PARTITION_IDENTIFY_READ, params); 198 } 199 200 private static void logNonPartitionableType(String theResourceType) { 201 ourLog.trace("Partitioning: resource type {} must be on the DEFAULT partition.", theResourceType); 202 } 203 204 @Override 205 public RequestPartitionId determineGenericPartitionForRequest(RequestDetails theRequestDetails) { 206 RequestPartitionId requestPartitionId = null; 207 208 if (!myPartitionSettings.isPartitioningEnabled()) { 209 return RequestPartitionId.allPartitions(); 210 } 211 212 if (theRequestDetails instanceof SystemRequestDetails 213 && systemRequestHasExplicitPartition((SystemRequestDetails) theRequestDetails)) { 214 requestPartitionId = getSystemRequestPartitionId((SystemRequestDetails) theRequestDetails); 215 logSystemRequestDetailsResolution((SystemRequestDetails) theRequestDetails); 216 } else { 217 IInterceptorBroadcaster compositeBroadcaster = CompositeInterceptorBroadcaster.newCompositeBroadcaster( 218 myInterceptorBroadcaster, theRequestDetails); 219 if (compositeBroadcaster.hasHooks(Pointcut.STORAGE_PARTITION_IDENTIFY_ANY)) { 220 requestPartitionId = callAnyPointcut(compositeBroadcaster, theRequestDetails); 221 } 222 } 223 224 // TODO MM: at the moment it is ok for this method to return null 225 // check if it can be made consistent and it's implications 226 // validateRequestPartitionNotNull(requestPartitionId, Pointcut.STORAGE_PARTITION_IDENTIFY_ANY); 227 228 if (requestPartitionId != null) { 229 requestPartitionId = validateAndNormalizePartition( 230 requestPartitionId, theRequestDetails, theRequestDetails.getResourceName()); 231 } 232 233 logTroubleshootingResult("generic", theRequestDetails.getResourceName(), theRequestDetails, requestPartitionId); 234 235 return requestPartitionId; 236 } 237 238 /** 239 * For system requests, read partition from tenant ID if present, otherwise set to DEFAULT. If the resource they are attempting to partition 240 * is non-partitionable scream in the logs and set the partition to DEFAULT. 241 */ 242 private RequestPartitionId getSystemRequestPartitionId( 243 SystemRequestDetails theRequest, boolean theNonPartitionableResource) { 244 RequestPartitionId requestPartitionId; 245 requestPartitionId = getSystemRequestPartitionId(theRequest); 246 if (theNonPartitionableResource && !requestPartitionId.isDefaultPartition()) { 247 throw new InternalErrorException(Msg.code(1315) 248 + "System call is attempting to write a non-partitionable resource to a partition! This is a bug!"); 249 } 250 return requestPartitionId; 251 } 252 253 /** 254 * Determine the partition for a System Call (defined by the fact that the request is of type SystemRequestDetails) 255 * <p> 256 * 1. If the tenant ID is set to the constant for all partitions, return all partitions 257 * 2. If there is a tenant ID set in the request, use it. 258 * 3. Otherwise, return the Default Partition. 259 * 260 * @param theRequest The {@link SystemRequestDetails} 261 * @return the {@link RequestPartitionId} to be used for this request. 262 */ 263 @Nonnull 264 private RequestPartitionId getSystemRequestPartitionId(@Nonnull SystemRequestDetails theRequest) { 265 if (theRequest.getRequestPartitionId() != null) { 266 return theRequest.getRequestPartitionId(); 267 } 268 if (theRequest.getTenantId() != null) { 269 // TODO: JA2 we should not be inferring the partition name from the tenant name 270 return RequestPartitionId.fromPartitionName(theRequest.getTenantId()); 271 } else { 272 return RequestPartitionId.defaultPartition(); 273 } 274 } 275 276 /** 277 * Invoke the {@link Pointcut#STORAGE_PARTITION_IDENTIFY_CREATE} interceptor pointcut to determine the tenant for a create request. 278 */ 279 @Nonnull 280 @Override 281 public RequestPartitionId determineCreatePartitionForRequest( 282 @Nullable final RequestDetails theRequest, 283 @Nonnull IBaseResource theResource, 284 @Nonnull String theResourceType) { 285 286 if (!myPartitionSettings.isPartitioningEnabled()) { 287 return RequestPartitionId.allPartitions(); 288 } 289 290 RequestDetails requestDetails = theRequest; 291 boolean nonPartitionableResource = isResourceNonPartitionable(theResourceType); 292 293 // TODO GGG eventually, theRequest will not be allowed to be null here, and we will pass through 294 // SystemRequestDetails instead. 295 if (theRequest == null) { 296 requestDetails = new SystemRequestDetails(); 297 logSubstitutingDefaultSystemRequestDetails(); 298 } 299 300 RequestPartitionId requestPartitionId = null; 301 if (theRequest instanceof SystemRequestDetails 302 && systemRequestHasExplicitPartition((SystemRequestDetails) theRequest)) { 303 requestPartitionId = 304 getSystemRequestPartitionId((SystemRequestDetails) theRequest, nonPartitionableResource); 305 306 logSystemRequestDetailsResolution((SystemRequestDetails) theRequest); 307 } else { 308 IInterceptorBroadcaster compositeBroadcaster = 309 CompositeInterceptorBroadcaster.newCompositeBroadcaster(myInterceptorBroadcaster, requestDetails); 310 if (compositeBroadcaster.hasHooks(Pointcut.STORAGE_PARTITION_IDENTIFY_ANY)) { 311 requestPartitionId = callAnyPointcut(compositeBroadcaster, requestDetails); 312 } else if (compositeBroadcaster.hasHooks(Pointcut.STORAGE_PARTITION_IDENTIFY_CREATE)) { 313 requestPartitionId = callCreatePointcut(compositeBroadcaster, requestDetails, theResource); 314 } 315 } 316 317 // If the interceptors haven't selected a partition, and its a non-partitionable resource anyhow, send 318 // to DEFAULT 319 if (nonPartitionableResource && requestPartitionId == null) { 320 logNonPartitionableType(theResourceType); 321 requestPartitionId = RequestPartitionId.defaultPartition(); 322 } 323 324 validateRequestPartitionNotNull( 325 requestPartitionId, 326 Pointcut.STORAGE_PARTITION_IDENTIFY_CREATE, 327 Pointcut.STORAGE_PARTITION_IDENTIFY_ANY); 328 validatePartitionForCreate(requestPartitionId, theResourceType); 329 330 RequestPartitionId resultRequestPartitionId = 331 validateAndNormalizePartition(requestPartitionId, requestDetails, theResourceType); 332 333 logTroubleshootingResult("create", theResourceType, theRequest, resultRequestPartitionId); 334 335 return resultRequestPartitionId; 336 } 337 338 private boolean systemRequestHasExplicitPartition(@Nonnull SystemRequestDetails theRequest) { 339 return theRequest.getRequestPartitionId() != null || theRequest.getTenantId() != null; 340 } 341 342 @Nonnull 343 @Override 344 public Set<Integer> toReadPartitions(@Nonnull RequestPartitionId theRequestPartitionId) { 345 return theRequestPartitionId.getPartitionIds().stream() 346 .map(t -> t == null ? myPartitionSettings.getDefaultPartitionId() : t) 347 .collect(Collectors.toSet()); 348 } 349 350 /** 351 * If the partition only has a name but not an ID, this method resolves the ID. 352 * <p> 353 * If the partition has an ID but not a name, the name is resolved. 354 * <p> 355 * If the partition has both, they are validated to ensure that they correspond. 356 */ 357 @Nonnull 358 private RequestPartitionId validateAndNormalizePartition( 359 @Nonnull RequestPartitionId theRequestPartitionId, 360 RequestDetails theRequest, 361 @Nullable String theResourceType) { 362 RequestPartitionId retVal = theRequestPartitionId; 363 364 if (!myPartitionSettings.isUnnamedPartitionMode()) { 365 if (retVal.getPartitionNames() != null) { 366 retVal = validateAndNormalizePartitionNames(retVal); 367 } else if (retVal.hasPartitionIds()) { 368 retVal = validateAndNormalizePartitionIds(retVal); 369 } 370 } 371 372 // Note: It's still possible that the partition only has a date but no name/id 373 374 if (StringUtils.isNotBlank(theResourceType)) { 375 validateHasPartitionPermissions(theRequest, theResourceType, retVal); 376 } 377 378 // Replace null partition ID with non-null default partition ID if one is being used 379 if (myPartitionSettings.getDefaultPartitionId() != null 380 && retVal.hasPartitionIds() 381 && retVal.hasDefaultPartitionId(null)) { 382 List<Integer> partitionIds = new ArrayList<>(retVal.getPartitionIds()); 383 for (int i = 0; i < partitionIds.size(); i++) { 384 if (partitionIds.get(i) == null) { 385 partitionIds.set(i, myPartitionSettings.getDefaultPartitionId()); 386 } 387 } 388 retVal = RequestPartitionId.fromPartitionIds(partitionIds); 389 } 390 391 ourLog.trace("Partition normalization: {} -> {}", theRequestPartitionId, retVal); 392 393 return retVal; 394 } 395 396 @Override 397 public void validateHasPartitionPermissions( 398 @Nonnull RequestDetails theRequest, String theResourceType, RequestPartitionId theRequestPartitionId) { 399 IInterceptorBroadcaster compositeBroadcaster = 400 CompositeInterceptorBroadcaster.newCompositeBroadcaster(myInterceptorBroadcaster, theRequest); 401 if (compositeBroadcaster.hasHooks(Pointcut.STORAGE_PARTITION_SELECTED)) { 402 RuntimeResourceDefinition runtimeResourceDefinition = null; 403 if (theResourceType != null) { 404 runtimeResourceDefinition = myFhirContext.getResourceDefinition(theResourceType); 405 } 406 HookParams params = new HookParams() 407 .add(RequestPartitionId.class, theRequestPartitionId) 408 .add(RequestDetails.class, theRequest) 409 .addIfMatchesType(ServletRequestDetails.class, theRequest) 410 .add(RuntimeResourceDefinition.class, runtimeResourceDefinition); 411 compositeBroadcaster.callHooks(Pointcut.STORAGE_PARTITION_SELECTED, params); 412 } 413 } 414 415 @Override 416 public boolean isResourcePartitionable(String theResourceType) { 417 return theResourceType != null && !myNonPartitionableResourceNames.contains(theResourceType); 418 } 419 420 @Override 421 @Nullable 422 public Integer getDefaultPartitionId() { 423 return myPartitionSettings.getDefaultPartitionId(); 424 } 425 426 private boolean isResourceNonPartitionable(String theResourceType) { 427 return theResourceType != null && !isResourcePartitionable(theResourceType); 428 } 429 430 private void validatePartitionForCreate(RequestPartitionId theRequestPartitionId, String theResourceName) { 431 if (theRequestPartitionId.hasPartitionIds()) { 432 validateSinglePartitionIdOrName(theRequestPartitionId.getPartitionIds()); 433 } 434 validateSinglePartitionIdOrName(theRequestPartitionId.getPartitionNames()); 435 436 // Make sure we're not using one of the conformance resources in a non-default partition 437 if (isDefaultPartition(theRequestPartitionId) || theRequestPartitionId.isAllPartitions()) { 438 return; 439 } 440 441 // TODO MM: check if we need to validate using the configured value PartitionSettings.defaultPartition 442 // however that is only used for read and not for create at the moment 443 if ((theRequestPartitionId.hasPartitionIds() 444 && !theRequestPartitionId.getPartitionIds().contains(null)) 445 || (theRequestPartitionId.hasPartitionNames() 446 && !theRequestPartitionId.getPartitionNames().contains(JpaConstants.DEFAULT_PARTITION_NAME))) { 447 448 if (isResourceNonPartitionable(theResourceName)) { 449 String msg = myFhirContext 450 .getLocalizer() 451 .getMessageSanitized( 452 BaseRequestPartitionHelperSvc.class, 453 "nonDefaultPartitionSelectedForNonPartitionable", 454 theResourceName); 455 throw new UnprocessableEntityException(Msg.code(1318) + msg); 456 } 457 } 458 } 459 460 private static void validateRequestPartitionNotNull( 461 RequestPartitionId theRequestPartitionId, Pointcut... thePointcuts) { 462 if (theRequestPartitionId == null) { 463 throw new InternalErrorException( 464 Msg.code(1319) + "No interceptor provided a value for pointcuts: " + Arrays.toString(thePointcuts)); 465 } 466 } 467 468 private static void validateSinglePartitionIdOrName(@Nullable List<?> thePartitionIds) { 469 if (thePartitionIds != null && thePartitionIds.size() != 1) { 470 throw new InternalErrorException( 471 Msg.code(1320) + "RequestPartitionId must contain a single partition for create operations, found: " 472 + thePartitionIds); 473 } 474 } 475 476 private static void logTroubleshootingResult( 477 String theAction, 478 String theResourceType, 479 @Nullable RequestDetails theRequest, 480 RequestPartitionId theResult) { 481 String tenantId = theRequest != null ? theRequest.getTenantId() : null; 482 ourLog.debug( 483 "Partitioning: action={} resource type={} with request tenant ID={} routed to RequestPartitionId={}", 484 theAction, 485 theResourceType, 486 tenantId, 487 theResult); 488 } 489 490 private void logSystemRequestDetailsResolution(SystemRequestDetails theRequest) { 491 ourLog.trace( 492 "Partitioning: request is a SystemRequestDetails, with RequestPartitionId={}.", 493 theRequest.getRequestPartitionId()); 494 } 495 496 private static void logSubstitutingDefaultSystemRequestDetails() { 497 ourLog.trace("No RequestDetails present. Using default SystemRequestDetails."); 498 } 499}