masamoriの日誌

書きたい技術。時々SFの読書感想も。

Azureの大規模ネットワーク ~BGPって?

はじめに

masamoriです。
普段私はAzureを使って仕事をしています。アプリを作るにせよLLMを使うにせよ、きちんとした製品レベルのものを作ろうとした場合、ネットワークの構築は不可欠です。
私はネットワークエンジニアではないのですが、Azureのネットワーク設定は非常に簡単で、小規模のものであればルーターなど考えなくてもいけます。裏側でAzureが勝手に設定してくれるので。
しかし、オンプレミス環境との繋ぎや、ある程度規模が大きくなってくるとネットワークの知識が必要になってきます。
幸いにも、まだ大規模ネットワークを構築する場面に遭遇したことがないので、来る日のためにこの本でAzureのネットワークについて勉強しています。

leanpub.com

ワンチャン可愛い!
騙されてはいけません、内容はめちゃくちゃ骨太です。最初の章こそ入門レベルですが、途中から複雑なネットワークの構築のプラクティスが入ってきます。
勉強するのに重要なことは実際に自分で作ってみることです。でも、大規模ネットワークなんて作れません。それこそオンプレミス環境なんて用意できないですしね。(Ciscoのルータなんて持ってない)。
しかし、複雑なネットワーク構成を理解しておくと、普段の業務に必ず役に立ちます。書籍に載ってるような、こんな大規模なネットワークを一人で作るなんてまずないですからね。素振りのつもりで、大規模なネットワークを解き明かしていきます。

さて前述の通り、私はネットワークエンジニアではありません。なので、専門用語でわからない事があったら備忘録として記事を書いていきます。また、具体的にAzureのどのサービスに関わりがあるのかも書きたいと思います。今日はBGP(Border Gateway Protocol)。

使用するAzure サービス

learn.microsoft.com

### BGP 経路情報を交換するためのプロトコルですが、その詳細を理解することはネットワーク管理において非常に重要です。BGPは「経路(ルート)」そのものではなく、「経路情報を広告(Advertise)・交換・学習するためのプロトコル」なのです。

learn.microsoft.com

オンプレミスとの繋ぎが主なユースケースです。前述の本でもそうです。

BGPと経路(Route)の違い

  • BGP:経路情報をピア(相手のルーター)に送受信するプロトコル
  • 経路(Route):「10.0.0.0/16 はネクストホップ●●で届く」という情報そのもの

Advertise(広告)とは?

  • 自分が到達できるプレフィックス(IPネットワーク)を「このネットワークは自分経由で到達可能だよ」とピアに通知する行為
  • 逆に「もうその経路はダメだ」というときはWithdraw(撤回)も行う

BGPの大まかな流れ

1. BGPセッション確立(TCP 179)
2. OPEN → KEEPALIVEでお互いのAS(Access Point)番号や能力交換
3. 自分が持っている経路をUPDATEメッセージでAdvertise
4. 相手も同様にUPDATEで自分の経路を送ってくる
5. 受け取った経路情報は「BGPテーブル(RIB)」に保存
6. 最適経路選択(属性比較)したうえで「ルーティングテーブル」に反映
7. 経路に変更があれば再度UPDATE(Advertise/Withdraw)

BGPの機能

BGPはAdvertiseだけでなく、以下の機能も持っています:

  • セッション管理
    BGPセッションを確立し、維持します。

  • 属性交換
    AS Path、Next Hop、MED、LocalPrefなどの属性を交換します。

  • 経路の優劣選定
    獲得した経路情報を比較して最適な経路を選びます。

  • Withdraw(経路撤回)
    使用できない経路情報を撤回します。

結論

  • BGP=経路そのもの ではなく、「経路情報をお互いに通知し、最適経路を選ぶ仕組み」です。
  • 経路(Route)=「到達可能なネットワークとネクストホップ等の情報」です。
  • Advertise=「経路を相手に知らせる」アクション、Withdraw=「経路を取り下げる」アクション。

このように、「BGPは経路を広告(Advertise)する仕組み」であり、広告された情報をもとにルーター内部で経路を構築・更新しています。

ちなみに、BGPについてのFQはこちら。

learn.microsoft.com

重要なのは、BGPはプロトコルであるという点だと思います。
セッションを張るので、トンネルのようにも見えますが、その目的はあくまで つ以上のネットワーク間でルーティングと到達可能性の情報を交換するためのものです。

BGPのイメージ図はこちら.

[オンプレ AS65001]                      [Azure AS65515]
      ↓ BGP広告                            ↓ BGP広告
  10.11.11.0/24 ─────────────→    10.0.0.0/16, 10.3.0.0/16
      ↑ ←────ルート学習────────────↑

Function Buildar その先に

はじめに

masamoriです。
前回、こういう記事を書きました。

masamori0083.hatenablog.com

この後実際にissueを出して聞いてみたところ、ソッコーで回答が返ってきました。こういうフォローを惜しみなくしてくれると、ライブラリ使いたくなりますよね。ユーザーに対しての誠実さが伝わってきます。そのissueと回答はこちら

github.com

Pythonのワーカー呼び出しのタイミングでbuildされるようです。なので、FunctionのPython Libraryのリポジトリではなくこのリポジトリに答えが書いてありました。

github.com

今回は、このworkerを中心にソースコードを追っていきたいと思います。
ちなみにissueでの回答にもあるように、pythonワーカーとC#で書かれたAzure Functionホスト部の呼び出し関係のシーケンス図はちょっと古いらしく、更新しとくねと言ってくれました。最高。

Python worker

そもそもPythonワーカーとはなんでしょうか?
一言で言うと.

「Pythonコードを実行するためのワーカープロセス」.

と言えると思います。
Azure Functionの実態はC#で書かれたホスト部に存在しており、そこからの要求を受け取ってレスポンスを返すという役割を果たします。ワーカーの主な仕事は

  • Pythonの関数を読み込む
  • 関数を実行する
  • 結果をHostに返す.

です。なので、ホスト部から見るとワーカーはクライアントなんですね。Pythonのコードはこのワーカーを通じて実行されるので、前回紹介したAzure Function Pythonライブラリはワーカーを定義したモジュールにインポートされて、実行されます。
と言うわけで、buildを実行している(実際にFunctionが生成される)部分にフォーカスしてソースコードを読みます。
コードは、azure-function-python-workerリポジトリのlorder.pyです。

@attach_message_to_exception(
      expt_type=ImportError,
      message='Cannot find module. Please check the requirements.txt '
              'file for the missing module. For more info, '
              'please refer the troubleshooting '
              f'guide: {MODULE_NOT_FOUND_TS_URL}. '
              f'Current sys.path: {sys.path}',
      debug_logs='Error in index_function_app. '
                 f'Sys Path: {sys.path}, Sys Module: {sys.modules},'
                 'python-packages Path exists: '
                 f'{os.path.exists(CUSTOMER_PACKAGES_PATH)}')
  def index_function_app(function_path: str):
      module_name = pathlib.Path(function_path).stem
      imported_module = importlib.import_module(module_name)
  
      from azure.functions import FunctionRegister # ここでbuildが定義されているPython Libraryがimport
      app: Optional[FunctionRegister] = None
      for i in imported_module.__dir__():
          if isinstance(getattr(imported_module, i, None), FunctionRegister):
              if not app:
                  app = getattr(imported_module, i, None)
              else:
                  raise ValueError(
                      f"More than one {app.__class__.__name__} or other top "
                      f"level function app instances are defined.")
  
      if not app:
          script_file_name = get_app_setting(
              setting=PYTHON_SCRIPT_FILE_NAME,
              default_value=f'{PYTHON_SCRIPT_FILE_NAME_DEFAULT}')
          raise ValueError("Could not find top level function app instances in "
                           f"{script_file_name}.")
  
      return app.get_functions()

では、index_function_appが呼ばれる場所は?

def index_functions(self, function_path: str, function_dir: str):
          indexed_functions = loader.index_function_app(function_path) ## ここで呼ばれる
          logger.info(
              "Indexed function app and found %s functions",
              len(indexed_functions)
          )
  
          if indexed_functions:
              fx_metadata_results, fx_bindings_logs = (
                  loader.process_indexed_function(
                      self._functions,
                      indexed_functions,
                      function_dir))
  
              indexed_function_logs: List[str] = []
              indexed_function_bindings_logs = []
              for func in indexed_functions:
                  func_binding_logs = fx_bindings_logs.get(func)
                  for binding in func.get_bindings():
                      deferred_binding_info = func_binding_logs.get(
                          binding.name)\
                          if func_binding_logs.get(binding.name) else ""
                      indexed_function_bindings_logs.append((
                          binding.type, binding.name, deferred_binding_info))
  
                  function_log = "Function Name: {}, Function Binding: {}" \
                      .format(func.get_function_name(),
                              indexed_function_bindings_logs)
                  indexed_function_logs.append(function_log)
  
              logger.info(
                  'Successfully processed FunctionMetadataRequest for '
                  'functions: %s. Deferred bindings enabled: %s.', " ".join(
                      indexed_function_logs),
                  self._functions.deferred_bindings_enabled())
  
              return fx_metadata_results

メタデータとして付与されて、結果を返します。このメタデータの中に、buildが定義されているFuncttionRegisterインスタンスも含まれています。
このメタデータを呼び出し定義は以下。

def load_function_metadata(self, function_app_directory, caller_info):
          """
          This method is called to index the functions in the function app
          directory and save the results in function_metadata_result or
          function_metadata_exception in case of an exception.
          """
          script_file_name = get_app_setting(
              setting=PYTHON_SCRIPT_FILE_NAME,
              default_value=f'{PYTHON_SCRIPT_FILE_NAME_DEFAULT}')
  
          logger.debug(
              'Received load metadata request from %s, request ID %s, '
              'script_file_name: %s',
              caller_info, self.request_id, script_file_name)
  
          validate_script_file_name(script_file_name)
          function_path = os.path.join(function_app_directory,
                                       script_file_name)
  
          # For V1, the function path will not exist and
          # return None.
          self._function_metadata_result = (
              self.index_functions(function_path, function_app_directory)) \ # ここでindex_functionを含んだメタデータ定義
              if os.path.exists(function_path) else None

では最後です。grpcクライアント(後述)が受け取った、ホストサーバーからのリクエストを処理するハンドラーの中でload_function_metadataが実行されます。

async def _handle__function_load_request(self, request):
          func_request = request.function_load_request
          function_id = func_request.function_id
          function_metadata = func_request.metadata
          function_name = function_metadata.name
          function_app_directory = function_metadata.directory
  
          logger.info(
              'Received WorkerLoadRequest, request ID %s, function_id: %s,'
              'function_name: %s, function_app_directory : %s',
              self.request_id, function_id, function_name,
              function_app_directory)
  
          programming_model = "V2"
          try:
              if not self._functions.get_function(function_id):
  
                  if function_metadata.properties.get(
                          METADATA_PROPERTIES_WORKER_INDEXED, False):
                      # This is for the second worker and above where the worker
                      # indexing is enabled and load request is called without
                      # calling the metadata request. In this case we index the
                      # function and update the workers registry
  
                      try:
                          self.load_function_metadata( #ここでメタデータ呼び出し。結果としてbuildが走る
                              function_app_directory,
                              caller_info="functions_load_request")
                      except Exception as ex:
                          self._function_metadata_exception = ex
  
                      # For the second worker, if there was an exception in
                      # indexing, we raise it here
                      if self._function_metadata_exception:
                          raise Exception(self._function_metadata_exception)
  
                  ...(略)

ソースコードは以上です。

全体の流れ

コードだけ読んでいても全体の流れは分かりにくいので、シーケンス図を書いてみました。
.

Fucntions HostとPython Workerにだけ絞って流れを書くとこんな感じ?

grpc

grpcとは「Google Remote Procedure Call」の略で、Googleが開発したサービス間通信を行うフレームワークです。バイナリベースで通信を行うため、RESTよりも速い通信が可能です。また、今回のケースで言うとホストが起点となるクライアントへの通信が発生するので、Web Socket的な通信が必要です。grpcはHTTP/2プロトコルで通信を行うのでその問題も解決。
この辺はもう少し深掘りをしてみたいところです。

Python? Or C#?

と言うわけで、buildしてる場所も分かり、PythonでなぜAzure Functionが動くのか多少理解ができました。少し気になったのは、結局ホストがC#で書かれているのでそいつを直接呼び出しちゃえば、通信のオーバーヘッドがなくなるような気がするんですよね。まぁ、Pythonで書いていて今のところ通信の遅延で困ったことはありませんが、より厳密な通信速度が必要な場合、C#で書く方がいいのかなと予想できます。それとも、C#でもクライアントとホスト分けてるのかな?その辺は分からないです。
とにかく一番感動したのは、アーキテクチャよりも、ただの一ユーザーに対して質問のissueに真面目に答えてくれた開発者の心意気です。
理解が深まりました。ありがとうございます。自分もそういう開発者でありたい。

Azure Function Python実装から分かるBuildarパターン

はじめに

masamoriです。
何かとお世話になることが多いAzure Function。簡単なAPIバックエンドだったら、もうこれだけでいいでしょみたいな雰囲気があります。
私はPythonしか使えないので、実装は全てPythonで行なっています。実装の面では、ドキュメントを読めば特に困ることはありませんが、深く知りたい場合はソースコードを読む必要があります。深く知りたくなくても、ソースコードを読むと勉強になることが多々あります。
Azure FunctionのPythonライブラリはオープンソースになっているので、誰でも読めますし、issueも出せます。

github.com

今日はソースコードを読んで勉強になったことや気づいたことを書きます。

処理の流れとコードの確認

お馴染みのHTTPトリガーは以下の通り

app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS)
@app.route(route="http_trigger")

FunctionAppをインスタンス化して、デコレータとしてhttpトリガーを指定します。
たったこれだけでhttpトリガーを実現できてしまうわけなんですが、後ろ側で動いているコードの流れを追うと以下の通りです。FunctionAppから追いましょう。

class FunctionApp(FunctionRegister, TriggerApi, BindingApi, SettingsApi):
  ...(中略)
# TriggerApiの呼び出し
  →
  class TriggerApi(DecoratorApi, ABC):
      """Interface to extend for using existing trigger decorator functions."""
 
      # routeがhttpトリガー
      def route(self,
                route: Optional[str] = None,
                trigger_arg_name: str = 'req',
                binding_arg_name: str = '$return',
                methods: Optional[
                    Union[Iterable[str], Iterable[HttpMethod]]] = None,
                auth_level: Optional[Union[AuthLevel, str]] = None,
                trigger_extra_fields: Optional[Dict[str, Any]] = None,
                binding_extra_fields: Optional[Dict[str, Any]] = None
                ) -> Callable[..., Any]:
  ...(中略)
         # デコレータでFunctionBuilderをインスタンス化する
         # 以下のデコレータはDecoratorApiに実装してある
    @self._configure_function_builder
           def wrap(fb):
               def decorator():
                   fb.add_trigger(trigger=HttpTrigger(
                       name=trigger_arg_name,
                       methods=parse_iterable_param_to_enums(methods, HttpMethod),
                       auth_level=parse_singular_param_to_enum(auth_level,
                                                               AuthLevel),
                       route=route, **trigger_extra_fields))
                   fb.add_binding(binding=HttpOutput(
                       name=binding_arg_name, **binding_extra_fields))
                   return fb
   
               return decorator()
           return wrap
    ...(中略)
    ⇨
    class DecoratorApi(ABC):
      """Interface which contains essential decorator function building blocks
      to extend for creating new function app or blueprint classes.
      """
  
      def __init__(self, *args, **kwargs):
          self._function_builders: List[FunctionBuilder] = []
          self._app_script_file: str = SCRIPT_FILE_NAME
  ...
  
    def _validate_type(self,
                          func: Union[Callable[..., Any], FunctionBuilder]) \
               -> FunctionBuilder:
           """Validate the type of the function object and return the created
           :class:`FunctionBuilder` object.
           :param func: Function object passed to
            :meth:`_configure_function_builder`
           :raises ValueError: Raise error when func param is neither
            :class:`Callable` nor :class:`FunctionBuilder`.
           :return: :class:`FunctionBuilder` object.
           """
           if isinstance(func, FunctionBuilder):
               fb = self._function_builders.pop()
           elif callable(func):
               fb = FunctionBuilder(func, self._app_script_file)
           else:
               raise ValueError(
                   "Unsupported type for function app decorator found.")
           return fb
  
       # デコレータの定義 こいつが呼びだされてバリデーションされたものがFunctionBuilderオブジェクトとしてリストに追加される
       # _validate_typeでFunctionBuildarをインスタンス化
       def _configure_function_builder(self, wrap) -> Callable[..., Any]:
           """Decorator function on user defined function to create and return
            :class:`FunctionBuilder` object from :class:`Callable` func.
           """
   
           def decorator(func):
               fb = self._validate_type(func)
               self._function_builders.append(fb)
               return wrap(fb)
   
           return decorator
           
    ...(中略)
    ⇨
    class FunctionBuilder(object):
        def __init__(self, func, function_script_file):
           self._function = Function(func, function_script_file)
         ...(中略)

    # このbuildでFunctionオブジェクトを生成して、ユーザー定義関数がFunctionとして使用可能になる
    def build(self, auth_level: Optional[AuthLevel] = None) -> Function:
           """
           Validates and builds the function object.
   
           :param auth_level: Http auth level that will be set if http
           trigger function auth level is None.
           """
           self._validate_function(auth_level)
           return self._function

ちょっと長くなりましたが、以上のような流れでユーザーが定義した関数をFunctionとして使用することが可能になります。

Buildarパターン

このFunctionクラスとFunctionBuildarの関係ですが、Factoryパターンみたいに一般的な名前がついているらしく、Builderパターンと呼ばれているそうです。

ja.wikipedia.org

このパターンを使う適切な場合は、

  • コンストラクタで設定するパラメータが多い場合
  • 選択肢を多く用意して、ユーザー(開発者)が任意の機能を選びやすいように設計をする
  • 順序が大事な時

だいたいこんな感じだそう。
wikiはjavaのパターンなので、PythonのパターンをGPTに生成してもらいました。

class House:
       """家を表すクラス"""
       def __init__(self):
           self.foundation = None
           self.walls = None
           self.roof = None
           self.windows = []
           self.door = None
           self.has_garage = False
   
   class HouseBuilder:
       """家を建てるためのビルダー"""
       def __init__(self):
           self._house = House()
       
       def build_foundation(self, material):
           self._house.foundation = material
           return self  # 自分自身を返してチェーン可能に
       
       def add_walls(self, material):
           if not self._house.foundation:
               raise ValueError("基礎なしで壁は建てられません!")
           self._house.walls = material
           return self
       
       def add_roof(self, material):
           if not self._house.walls:
               raise ValueError("壁なしで屋根は付けられません!")
           self._house.roof = material
           return self
       
       def build(self):
           # 家が完成しているかチェック
           if not all([
               self._house.foundation,
               self._house.walls,
               self._house.roof
           ]):
               raise ValueError("家が完成していません!")
           return self._house
   
   # 使い方
   def main():
       # 正しい順序で家を建てる
       house = (HouseBuilder()
                .build_foundation("concrete")
                .add_walls("brick")
                .add_roof("tile")
                .build())
       
       try:
           # 間違った順序で家を建てようとする
           wrong_house = (HouseBuilder()
                         .add_roof("tile")  # 基礎なしで屋根を付けようとする
                         .build())
       except ValueError as e:
           print(f"エラー: {e}")

家を建てるロジックにBuilderパターンを適用していました。
私はこのパターンを使ったことがないので、メリットを実感したことはありませんが想像するに、

  • 実態と、構築を分けることで管理がしやすく、コードの責務が細かくなる
  • buildで最終的に構築されるので、順序が増えたり減ったりしても、builderのメソッドチェーンを減らせばいいだけなので、割と変更しやすい。

以上が嬉しい部分かなぁと思います。Builderクラスの方に構築に必要な手順を記載できるので、手順が増えたり減ったりしてもBuilderクラスを変更するだけで良い点が嬉しいですかね。また、最終的にbuild()で実行できるので、バリデーションも一箇所にまとめられてスッキリします。

Azure Functionは様々なAzure サービスと接続することができるので、手順も多いし、多機能なのでこのBuilderパターンを採用したのかなと想像できます。(?)

buildはどこにいった?

なるほど、Builderパターンとは最終的にbuildを呼び出さないと適切なメタ情報を含んだ、元のインスタンスが生成されなさそうです。さて、上記で見たAzure Functionのソースコードの中にbuildしている部分はあったでしょうか? 実は、FunctionAppクラスが継承しているFunctionRegisterクラスの中でbuildされています。

class FunctionRegister(DecoratorApi, HttpFunctionsAuthLevelMixin, ABC):
       def __init__(self, auth_level: Union[AuthLevel, str], *args, **kwargs):
           """Interface for declaring top level function app class which will
           be directly indexed by Python Function runtime.
   
           :param auth_level: Determines what keys, if any, need to be present
           on the request in order to invoke the function.
           :param args: Variable length argument list.
           :param kwargs: Arbitrary keyword arguments.
           """
           DecoratorApi.__init__(self, *args, **kwargs)
           HttpFunctionsAuthLevelMixin.__init__(self, auth_level, *args, **kwargs)
           self._require_auth_level: Optional[bool] = None
           self.functions_bindings: Optional[Dict[Any, Any]] = None
   
       def get_functions(self) -> List[Function]:
           """Get the function objects in the function app.
   
           :return: List of functions in the function app.
           """
           functions = [function_builder.build(self.auth_level) # ここでbuildを呼び出している!!!
                        for function_builder in self._function_builders]
   
           if not self._require_auth_level:
               self._require_auth_level = any(
                   function.is_http_function() for function in functions)
   
           if not self._require_auth_level:
               logging.warning(
                   'Auth level is not applied to non http '
                   'function app. Ref: '
                   'https://docs.microsoft.com/azure/azure-functions/functions'
                   '-bindings-http-webhook-trigger?tabs=in-process'
                   '%2Cfunctionsv2&pivots=programming-language-python#http-auth')
   
           self.validate_function_names(functions=functions)
   
           return functions

なるほど。このget_functionsを呼び出せばbuildしてくれるんだなと。そして、Functionインスタンスが生成されてAzure Functionの機能が使えるようになるんだと。
いや、ちょっと待ってください。get_functionsってどっかで呼び出してましたっけ?この関数は、自動で呼び出されないはず。。うーん。
どこでget_functionsが呼ばれているんだろう?または、他でbuildしている場所があるのでしょうか?
これはまだ解決していないです。

issueで聞いてみよう

というわけで、issueとして発行して聞いてみたいと思います。お返事が来たら嬉しいなぁ。
何とも中途半端な記事かもしれませんが、ソースコードを読むことで様々な発見があるので、時間のある時に眺めてみることをお勧めします。一流の人たちが書いているコードですからね。それを読んで勉強できるなんて素晴らしいです。

Azure Function Service Bus トリガーのPython実装について

はじめに

masamoriです。
前回、Azure FunctionのPythonライブラリの実装を少しだけ見てみました。

masamori0083.hatenablog.com

さて、Azure のキューサービスにはAzure Service Bus というものがあります。

learn.microsoft.com

こちらのサービスは実務でも使っていて、Azure Functionにバインドしています。
今回はSservice BusキューをトリガーにしたAzure Functionの実装について紹介します。

Service Bus キュートリガーの基本

例によってVSCodeのAzure 拡張機能からFunctionを作成することができます。Functionテンプレートの中に、Service Bus Queueのテンプレートがあるのでそちらを使います

import logging
   
import azure.functions as func
   
app = func.FunctionApp()
   
logger = logging.getLogger()
   
@app.service_bus_queue_trigger(arg_name="azservicebus", queue_name="mysbqueue_add_params",
                                  connection="testbusqueue2_SERVICEBUS") 
def servicebus_queue_trigger(azservicebus: func.ServiceBusMessage):
       data = azservicebus.get_body().decode('utf-8')
       logger.info(f'Python ServiceBus Queue trigger processed a message: {data}')

若干テンプレートとは変わっている部分もありますが、単純にdata変数を使用しているだけなので挙動はテンプレートのものと全く変わりません。
挙動としては、バインドしているキューにメッセージが送信されたらそれを検知してメッセージの中身を読む、というものです。とてもシンプルですね。
パラメータとしてはqueue_nameがバインドしているキューの名前で、connectionが接続文字列です。(ただし、これはセキュリティ的には非推奨。).

機能追加.

さて、メッセージキューにはSKU Standard以上でいくつかの機能を追加することができます。例えば、メッセージセッション・重複検出・自動転送などです。
代表してメッセージセッションについて紹介します。
この機能は、指定したセッションに対してFIFO処理を作成できるというもので、例えば複数のデバイスからメッセージが集められ、そのメッセージを複数のクライアントに対して送信したい時、クライアント毎にFIFO処理を作成することができます。

learn.microsoft.com

メッセージに生成されたセッションIDを付与してキューに送る必要があります。
このセッション機能を有効にしたままFunctionを使うには、追加でパラメータが必要になります。と言っても追加の方法はめっちゃ楽です。

@app.service_bus_queue_trigger(arg_name="azservicebus", queue_name="mysbqueue_add_params",is_sessions_enabled=True,
                                   connection="testbusqueue2_SERVICEBUS") 
def servicebus_queue_trigger(azservicebus: func.ServiceBusMessage):
       data = azservicebus.get_body().decode('utf-8')
       logger.info(f'Python ServiceBus Queue trigger processed a message: {data}')

is_sessions_enabled=Trueこれを追加するだけです。簡単ですね。
ちなみに、servicebus_queue_triggerのソースコードは以下です.

def service_bus_queue_trigger(
                self,
                arg_name: str,
                connection: str,
                queue_name: str,
                data_type: Optional[Union[DataType, str]] = None,
                access_rights: Optional[Union[AccessRights, str]] = None,
                is_sessions_enabled: Optional[bool] = None,
                cardinality: Optional[Union[Cardinality, str]] = None,
                **kwargs: Any) -> Callable[..., Any]:
            """The on_service_bus_queue_change decorator adds
            :class:`ServiceBusQueueTrigger` to the :class:`FunctionBuilder` object
            for building :class:`Function` object used in worker function
            indexing model. This is equivalent to defining ServiceBusQueueTrigger
            in the function.json which enables your function be triggered when
            new message(s) are sent to the service bus queue.
            All optional fields will be given default value by function host when
            they are parsed by function host.
    
            Ref: https://aka.ms/azure-function-binding-service-bus
    
            :param arg_name: The name of the variable that represents the
            :class:`ServiceBusMessage` object in function code.
            :param connection: The name of an app setting or setting collection
            that specifies how to connect to Service Bus.
            :param queue_name: Name of the queue to monitor.
            :param data_type: Defines how Functions runtime should treat the
            parameter value.
            :param access_rights: Access rights for the connection string.
            :param is_sessions_enabled: True if connecting to a session-aware
            queue or subscription.
            :param cardinality: Set to many in order to enable batching.
            :return: Decorator function.
            """
    
            @self._configure_function_builder
            def wrap(fb):
                def decorator():
                    fb.add_trigger(
                        trigger=ServiceBusQueueTrigger(
                            name=arg_name,
                            connection=connection,
                            queue_name=queue_name,
                            data_type=parse_singular_param_to_enum(data_type,
                                                                   DataType),
                            access_rights=parse_singular_param_to_enum(
                                access_rights,
                                AccessRights),
                            is_sessions_enabled=is_sessions_enabled,
                            cardinality=parse_singular_param_to_enum(cardinality,
                                                                     Cardinality),
                            **kwargs))
                    return fb
    
                return decorator()
    
            return wrap

前回紹介したhttpトリガーとほぼ一緒のコードです。デコレータでパラメータを指定して、FunctionBuilderインスタンスにトリガーを追加しているだけです。

@app.function_nameについて

VSCodeで作成するテンプレートと、ドキュメントのコードには若干の違いがあります。
ドキュメントで紹介されているservice_bus_queue_triggerは以下の通りです。

import logging
import azure.functions as func
    
app = func.FunctionApp()
    
@app.function_name(name="ServiceBusQueueTrigger1")
@app.service_bus_queue_trigger(arg_name="msg", 
                                   queue_name="<QUEUE_NAME>", 
                                   connection="<CONNECTION_SETTING>")
def test_function(msg: func.ServiceBusMessage):
    logging.info('Python ServiceBus queue trigger processed message: %s',
                 msg.get_body().decode('utf-8'))

@app.function_name(name="ServiceBusQueueTrigger1")の部分が追加されています。

learn.microsoft.com

この部分に該当するソースコードは以下です

def function_name(self, name: str,
                      setting_extra_fields: Optional[Dict[str, Any]] = None,
                      ) -> Callable[..., Any]:
        """Optional: Sets name of the :class:`Function` object. If not set,
        it will default to the name of the method name.
    
        :param name: Name of the function.
        :param setting_extra_fields: Keyword arguments for specifying
        additional setting fields
        :return: Decorator function.
        """
        if setting_extra_fields is None:
            setting_extra_fields = {}

        @self._configure_function_builder
        def wrap(fb):
            def decorator():
                fb.add_setting(setting=FunctionName(
                    function_name=name,
                    **setting_extra_fields))
                return fb

            return decorator()

        return wrap

コメントにも書いてある通り、これはオプショナルな設定です。このデコレータを指定しない場合、自分で定義した関数の名前がデフォルトで使用されます。ドキュメントの例で言えば、test_functionがAzureで認識されるFunction名になります。VSCodeで作成するテンプレートは同じ名前の関数がデフォルトで生成されるので、自分で定義する関数名を変えるか、デコレータでFunctionの名前を指定しないと後で混乱する、あるいは同じFunctionのリソースを使用する場合名前の衝突が起きる可能性があります。

Azure Function HttpトリガーのPython実装に迫る

はじめに

masamoriです。
僕は普段クラウドサービスはAzureを使っています。中でもよく使うサービスはApp service系のPassなのです。Azure Functionもよく使っているのですが、よく考えたら実装を覗いたことないなー、と思いAzure FunctionのPython Libraryのコードを読んでみました。

github.com

Httpトリガー

Functionは様々なAzureサービスをトリガーにして実行することができますが、一番オーソドックスなHttpトリガーについてコードを読みたいと思います。
まず、VSCodeのFunctionツールから作成できるHttpトリガーのコードはこちらです。

import azure.functions as func
  import logging
  
  app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS)
  
  @app.route(route="http_trigger")
  def http_trigger(req: func.HttpRequest) -> func.HttpResponse:
      logging.info('Python HTTP trigger function processed a request.')
  
      name = req.params.get('name')
      if not name:
          try:
              req_body = req.get_json()
          except ValueError:
              pass
          else:
              name = req_body.get('name')
  
      if name:
          return func.HttpResponse(f"Hello, {name}. This HTTP triggered function executed successfully.")
      else:
          return func.HttpResponse(
               "This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response.",
               status_code=200
          )

appデコレータで関数をトリガー化しているので、FunctionAppを探します。
FunctionAppはazure/functions/init.pyの中に定義されていて、fucntions配下のdecorators/function_app.pyの中に定義されています。

class FunctionApp(FunctionRegister, TriggerApi, BindingApi, SettingsApi):
      """FunctionApp object used by worker function indexing model captures
      user defined functions and metadata.
  
      Ref: https://aka.ms/azure-function-ref
      """
  
      def __init__(self,
                   http_auth_level: Union[AuthLevel, str] = AuthLevel.FUNCTION):
          """Constructor of :class:`FunctionApp` object.
  
          :param http_auth_level: Determines what keys, if any, need to be
          present
          on the request in order to invoke the function.
          """
          super().__init__(auth_level=http_auth_level)

インスタンス化する際に必要な引数としてhttp_auth_levelがあります。デフォルトの設定ではFUNCTIONとなっており、特定の関数エンドポイントへのアクセスのみを許可するキーの設定が必要となります。今回の記事ではANONYMOUSにしているので、キーの設定は必要ありません。localで試したい場合はANONYMOUSで十分だと思います。
FunctionAppはTriggerApi, BindingApi, SettingsApiを継承しています。では、TriggerApiを見ましょう。

class TriggerApi(DecoratorApi, ABC):
      """Interface to extend for using existing trigger decorator functions."""
  
      def route(self,
                route: Optional[str] = None,
                trigger_arg_name: str = 'req',
                binding_arg_name: str = '$return',
                methods: Optional[
                    Union[Iterable[str], Iterable[HttpMethod]]] = None,
                auth_level: Optional[Union[AuthLevel, str]] = None,
                trigger_extra_fields: Optional[Dict[str, Any]] = None,
                binding_extra_fields: Optional[Dict[str, Any]] = None
                ) -> Callable[..., Any]:
          """The route decorator adds :class:`HttpTrigger` and
          :class:`HttpOutput` binding to the :class:`FunctionBuilder` object
          for building :class:`Function` object used in worker function
          indexing model. This is equivalent to defining HttpTrigger
          and HttpOutput binding in the function.json which enables your
          function be triggered when http requests hit the specified route.
          All optional fields will be given default value by function host when
          they are parsed by function host.
  
          Ref: https://aka.ms/azure-function-binding-http
  
          :param route: Route for the http endpoint, if None, it will be set
          to function name if present or user defined python function name.
          :param trigger_arg_name: Argument name for :class:`HttpRequest`,
          defaults to 'req'.
          :param binding_arg_name: Argument name for :class:`HttpResponse`,
          defaults to '$return'.
          :param methods: A tuple of the HTTP methods to which the function
          responds.
          :param auth_level: Determines what keys, if any, need to be present
          on the request in order to invoke the function.
          :return: Decorator function.
          :param trigger_extra_fields: Additional fields to include in trigger
          json. For example,
          >>> data_type='STRING' # 'dataType': 'STRING' in trigger json
          :param binding_extra_fields: Additional fields to include in binding
          json. For example,
          >>> data_type='STRING' # 'dataType': 'STRING' in binding json
          """
          if trigger_extra_fields is None:
              trigger_extra_fields = {}
          if binding_extra_fields is None:
              binding_extra_fields = {}
  
          @self._configure_function_builder
          def wrap(fb):
              def decorator():
                  fb.add_trigger(trigger=HttpTrigger(
                      name=trigger_arg_name,
                      methods=parse_iterable_param_to_enums(methods, HttpMethod),
                      auth_level=parse_singular_param_to_enum(auth_level,
                                                              AuthLevel),
                      route=route, **trigger_extra_fields))
                  fb.add_binding(binding=HttpOutput(
                      name=binding_arg_name, **binding_extra_fields))
                  return fb
  
              return decorator()
  
          return wrap
  
     ...以下略

routeメソッドのroute引数はエンドポイントです。また、Httpリクエストとレスポンスを定義できて、methodも定義できます。今回のケースではapp.route"を使用しているので、httpトリガーが呼び出されます。
また、ここではデコレーターが定義されていますが、Functionがどのように機能しているかに焦点を当てると、@self._configure_function_builderが重要になってきます。このデコレータでFunctionBuilderが生成され、自分で定義した関数に@appとしてデコレータを付与すると、その関数はトリガーとして機能するようになります。
では、TriggerAPIには'DecoratorApi'が継承されているので、見ていきましょう。

class DecoratorApi(ABC):
      """Interface which contains essential decorator function building blocks
      to extend for creating new function app or blueprint classes.
      """
  
      def __init__(self, *args, **kwargs):
          self._function_builders: List[FunctionBuilder] = []
          self._app_script_file: str = SCRIPT_FILE_NAME
  
      ... 略

      def _validate_type(self,
                       func: Union[Callable[..., Any], FunctionBuilder]) \
            -> FunctionBuilder:
        """Validate the type of the function object and return the created
        :class:`FunctionBuilder` object.


        :param func: Function object passed to
         :meth:`_configure_function_builder`
        :raises ValueError: Raise error when func param is neither
         :class:`Callable` nor :class:`FunctionBuilder`.
        :return: :class:`FunctionBuilder` object.
        """
        if isinstance(func, FunctionBuilder):
            fb = self._function_builders.pop()
        elif callable(func):
            fb = FunctionBuilder(func, self._app_script_file)
        else:
            raise ValueError(
                "Unsupported type for function app decorator found.")
        return fb
      
      def _configure_function_builder(self, wrap) -> Callable[..., Any]:
              """Decorator function on user defined function to create and return
               :class:`FunctionBuilder` object from :class:`Callable` func.
              """
      
              def decorator(func):
                  fb = self._validate_type(func)
                  self._function_builders.append(fb)
                  return wrap(fb)
      
              return decorator

_configure_function_builderというデコレータを作成し、そのメソッドを通してFunctionBuilderをラップしています。ここでFunctionBuilderのバリデーションをして、FunctionBuilderオブジェクトをリストに追加しています。そしてDecoratorApiを継承したTriggerApiの中で、_configure_function_builderがデコレータとして使用され、FunctionBuilderオブジェクトにトリガーが追加されます。(TriggerApiでHttpTriggerとなっていますね)。これはroute(Httpトリガー)を@app.routeで呼び出しているからです。
例えば、timer_trigerを呼び出したかったら、routeの代わりに.

def timer_trigger(self,
                      arg_name: str,
                      schedule: str,
                      run_on_startup: Optional[bool] = None,
                      use_monitor: Optional[bool] = None,
                      data_type: Optional[Union[DataType, str]] = None,
                      **kwargs: Any) -> Callable[..., Any]:
        """The schedule or timer decorator adds :class:`TimerTrigger` to the
        :class:`FunctionBuilder` object
        for building :class:`Function` object used in worker function
        indexing model. This is equivalent to defining TimerTrigger
        in the function.json which enables your function be triggered on the
        specified schedule.
        All optional fields will be given default value by function host when
        they are parsed by function host.

        Ref: https://aka.ms/azure-function-binding-timer

        :param arg_name: The name of the variable that represents the
        :class:`TimerRequest` object in function code.
        :param schedule: A string representing a CRON expression that will
        be used to schedule a function to run.
        :param run_on_startup: If true, the function is invoked when the
        runtime starts.
        :param use_monitor: Set to true or false to indicate whether the
        schedule should be monitored.
        :param data_type: Defines how Functions runtime should treat the
        parameter value.
        :return: Decorator function.
        """

        @self._configure_function_builder
        def wrap(fb):
            def decorator():
                fb.add_trigger(
                    trigger=TimerTrigger(
                        name=arg_name,
                        schedule=schedule,
                        run_on_startup=run_on_startup,
                        use_monitor=use_monitor,
                        data_type=parse_singular_param_to_enum(data_type,
                                                               DataType),
                        **kwargs))
                return fb

            return decorator()

        return wrap

    schedule = timer_trigger

こいつを@app.timer_triggerデコレーターとして呼び出せばいいです。TriggerApiの中に、他サービスで使えるトリガーの定義が書いてあるので、自分の使いたいトリガーの挙動が意図しないものであった場合、このTriggerApiクラスを見ることをお勧めします。どんなパラメーターが使用できるのか確認もできます。

まとめ

流れとして、

  1. FunctionApp: Azure Functions アプリケーション全体を管理し、ユーザー定義の関数とトリガーの結びつきを定義。
  2. TriggerApi: HTTP トリガーや他のトリガーを関数に紐付け、どのリクエストやイベントで関数が実行されるかを定義。
  3. DecoratorApi: デコレータを通じて、関数に FunctionBuilder を適用し、トリガーやバインディングを設定するための基盤を提供。
  4. FunctionBuilder: 関数のトリガーやバインディングを設定し、関数を構築します。最終的に、Azure Functions で動作するための関数が完成。

このような感じでしょうか。

似たような実装部分が多く、読みにくさも感じないコードなので、また機会があったらソースコードを読んでみたいと思います。

Python リストの作成方法で占有するメモリサイズが異なる その3(終)

はじめに

masamoriです。
このシリーズ最後です。

masamori0083.hatenablog.com

今回は、[0 for _ in range(3)]です。コードを追いましょう。

改めて確認

import sys
    
list = [0 for _ in range(3)]
  
print("sizeof list: ", sys.getsizeof(list))  #88

[0 for _ in range(3)]の場合、メモリの使用量は上記のようになります。

disモジュールを使って計算ロジックを確認しましょう。

import dis
    
print("disassemble [0 for _ in range(3)]")
dis.dis("[0 for i in range(3)]")
    
    ## 以下結果 ##
   disassemble [0 for _ in range(3)]
  0           0 RESUME                   0

  1           2 PUSH_NULL
              4 LOAD_NAME                0 (range)
              6 LOAD_CONST               0 (3)
              8 CALL                     1
             16 GET_ITER
             18 LOAD_FAST_AND_CLEAR      0 (i)
             20 SWAP                     2
             22 BUILD_LIST               0
             24 SWAP                     2
        >>   26 FOR_ITER                 4 (to 38)
             30 STORE_FAST               0 (i)
             32 LOAD_CONST               1 (0)
             34 LIST_APPEND              2
             36 JUMP_BACKWARD            6 (to 26)
        >>   38 END_FOR
             40 SWAP                     2
             42 STORE_FAST               0 (i)
             44 RETURN_VALUE
        >>   46 SWAP                     2
             48 POP_TOP
             50 SWAP                     2
             52 STORE_FAST               0 (i)
             54 RERAISE                  0
ExceptionTable:
  22 to 38 -> 46 [2]
   

こうなります。長いですね。
しかし、大事な部分はメモリ割り当ての部分LIST_APPENDです。

CPython

では、該当部分PyList_Appendから見ていきます。

int
   PyList_Append(PyObject *op, PyObject *newitem)
   {
       if (PyList_Check(op) && (newitem != NULL)) {
           int ret;
           Py_BEGIN_CRITICAL_SECTION(op);
           ret = _PyList_AppendTakeRef((PyListObject *)op, Py_NewRef(newitem));
           Py_END_CRITICAL_SECTION();
           return ret;
       }
       PyErr_BadInternalCall();
       return

_PyList_AppendTakeRefを確認します。

_PyList_AppendTakeRef(PyListObject *self, PyObject *newitem)
   {
       assert(self != NULL && newitem != NULL);
       assert(PyList_Check(self));
       Py_ssize_t len = Py_SIZE(self);
       Py_ssize_t allocated = self->allocated;
       assert((size_t)len + 1 < PY_SSIZE_T_MAX);
       if (allocated > len) {
   #ifdef Py_GIL_DISABLED
           _Py_atomic_store_ptr_release(&self->ob_item[len], newitem);
   #else
           PyList_SET_ITEM(self, len, newitem);
   #endif
           Py_SET_SIZE(self, len + 1);
           return 0;
       }
       return _PyList_AppendTakeRefListResize(self, newitem);
   }

リストに対して十分なメモリが割り当てられている場合はreturn 0で処理を終えます。
しかし、十分なメモリが割り当てられていない場合は_PyList_AppendTakeRefListResizeを呼び出します。
最初の呼び出しではメモリはまだ割り当てられていないはずなので、_PyList_AppendTakeRefListResizeが呼び出されるはずです。

_PyList_AppendTakeRefListResize(PyListObject *self, PyObject *newitem)
   {
       Py_ssize_t len = Py_SIZE(self);
       assert(self->allocated == -1 || self->allocated == len);
       if (list_resize(self, len + 1) < 0) {
           Py_DECREF(newitem);
           return -1;
       }
       FT_ATOMIC_STORE_PTR_RELEASE(self->ob_item[len], newitem);
       return 0;
   }

現在のリストのサイズを取得し、len + 1に拡張します。この時点で割り当てが少ない場合、list_resizeが呼び出されます。

static int
   list_resize(PyListObject *self, Py_ssize_t newsize)
   {
       size_t new_allocated, target_bytes;
       Py_ssize_t allocated = self->allocated;
   
       /* Bypass realloc() when a previous overallocation is large enough
          to accommodate the newsize.  If the newsize falls lower than half
          the allocated size, then proceed with the realloc() to shrink the list.
       */
       if (allocated >= newsize && newsize >= (allocated >> 1)) {
           assert(self->ob_item != NULL || newsize == 0);
           Py_SET_SIZE(self, newsize);
           return 0;
       }
   
       /* This over-allocates proportional to the list size, making room
        * for additional growth.  The over-allocation is mild, but is
        * enough to give linear-time amortized behavior over a long
        * sequence of appends() in the presence of a poorly-performing
        * system realloc().
        * Add padding to make the allocated size multiple of 4.
        * The growth pattern is:  0, 4, 8, 16, 24, 32, 40, 52, 64, 76, ...
        * Note: new_allocated won't overflow because the largest possible value
        *       is PY_SSIZE_T_MAX * (9 / 8) + 6 which always fits in a size_t.
        */
       new_allocated = ((size_t)newsize + (newsize >> 3) + 6) & ~(size_t)3;
       /* Do not overallocate if the new size is closer to overallocated size
        * than to the old size.
        */
       if (newsize - Py_SIZE(self) > (Py_ssize_t)(new_allocated - newsize))
           new_allocated = ((size_t)newsize + 3) & ~(size_t)3;
   
       if (newsize == 0)
           new_allocated = 0;
   
   ...略

重要なのはコメントの部分です。実はPythonはリストを拡大するたびにメモリを割り当てているわけではありません。ある程度のかたまりでメモリを増やしていきます。
* Add padding to make the allocated size multiple of 4. * The growth pattern is: 0, 4, 8, 16, 24, 32, 40, 52, 64, 76, ... この部分ですね。
なのでこの関数が最初に呼び出された時、まずは4つ分の長さのメモリが割り当てられます。 メモリの割り当てが終わり、要素が増加する分だけバイトコードLIST_APPENDが呼び出されます。しかし、list_resizeの最初の呼び出しでリストの要素4つ分メモリがすでに割り当てられているので、list_resizeは今回のケースではもう呼び出されません。

というわけで[0 for _ in range(3)]に割り当てられるメモリ量は

56(リストオブジェクトのbyteサイズ) + 4(要素数) * 8(byte) = 88(byte).

ということになるのですね。

確認

ということは、要素の数がひとつ増えたからといって、即座にメモリは再割り当てされないケースもあるということです。コメントから読み取れる情報から推測すると、[0 for _ in range(2)] と [0 for _ in range(3)]、[0 for _ in range(5)] と [0 for _ in range(7)] はメモリのサイズが一緒のはずです。
確認してみましょう。

import sys
    
list1 = [0 for _ in range(2)]
list2 = [0 for _ in range(3)]
list3 = [0 for _ in range(5)]
list4 = [0 for _ in range(7)]
  
print("sizeof list1: ", sys.getsizeof(list1)) # 88
print("sizeof list2: ", sys.getsizeof(list2)) # 88
print("sizeof list3: ", sys.getsizeof(list3)) # 120
print("sizeof list4: ", sys.getsizeof(list4)) # 120

はい、同じでした。いちいちメモリを再割り当てしていると計算効率が下がってしまうのですのね。ある程度の余裕を持ってあらかじめメモリを割り当てる方が効率が良い、ということなのでしょう。
ちなみに、こちらも元記事とはpython内部の計算のロジックは違いました。(結果は一緒)

元記事はこちら.

Most Developers Failed with this Senior-Level Python Interview Question | by Shuai Li | Aug, 2024 | Programming Domain

Pythonの内部実装を見るって、なかなか面白いです。
また面白そうなネタがあれば書きたいと思います。

Python リストの作成方法で占有するメモリサイズが異なる その2

はじめに

masamoriです。
少々間が空きましたが、この記事の続きです。

masamori0083.hatenablog.com

今回は、Pythonのリスト[0, 0, 0]についてメモリサイズがどのように計算されているか、コードを追いたいと思います。

改めて確認

import sys
    
list2 = [0, 0, 0]
  
print("sizeof list2: ", sys.getsizeof(list2)) #88

[0,0,0]の場合、メモリの使用量は上記のようになります。

disモジュールを使って計算ロジックを確認しましょう。

import dis
    
print("disassemble [0, 0, 0]")
dis.dis("[0, 0, 0]")
    
    ## 以下結果 ##
    disassemble [0, 0, 0]
      0           0 RESUME                   0
    
      1           2 BUILD_LIST               0
                  4 LOAD_CONST               0 ((0, 0, 0))
                  6 LIST_EXTEND              1
                  8 RETURN_VALUE
   

こうなります。
では、該当のCPythonの実装を確認します。

CPython

では、List_EXTENDからいきましょう。

static PyObject *
    list_extend(PyListObject *self, PyObject *iterable)
    /*[clinic end generated code: output=630fb3bca0c8e789 input=979da7597a515791]*/
    {
        if (_list_extend(self, iterable) < 0) {
            return NULL;
        }
        Py_RETURN_NONE;
    }

_list_extendを確認します

static int
    _list_extend(PyListObject *self, PyObject *iterable)
    {
        // Special case:
        // lists and tuples which can use PySequence_Fast ops
        int res = -1;
        if ((PyObject *)self == iterable) {
            Py_BEGIN_CRITICAL_SECTION(self);
            res = list_inplace_repeat_lock_held(self, 2);
            Py_END_CRITICAL_SECTION();
        }
        else if (PyList_CheckExact(iterable)) {
            Py_BEGIN_CRITICAL_SECTION2(self, iterable);
            res = list_extend_lock_held(self, iterable);
            Py_END_CRITICAL_SECTION2();
        }
        .....(省略)
        return res;
    }

さて、ここで条件分岐があります。
結論から言うと、else if (PyList_CheckExact(iterable)に進みます。
disasemble(disモジュールを使った)した結果を見てみましょう。
最初に作成されるリストは空のリストで、次に作成されるiterableは(0,0,0)というタプルになります。そして、このタプルが空のリストに追加されるという流れです。
この時、selfは空のリストでiterableはタプルですから同じものではありません。よって、else if (PyList_CheckExact(iterable)に進みます。
res = list_extend_lock_held(self, iterable);に注目し、list_extend_lock_heldの実装を見ます。

static int
    list_extend_lock_held(PyListObject *self, PyObject *iterable)
    {
        PyObject *seq = PySequence_Fast(iterable, "argument must be iterable");
        if (!seq) {
            return -1;
        }
    
        int res = list_extend_fast(self, seq);
        Py_DECREF(seq);
        return res;
    }

list_extend_fastに進みます

static int
    list_extend_fast(PyListObject *self, PyObject *iterable)
    {
        Py_ssize_t n = PySequence_Fast_GET_SIZE(iterable);
        if (n == 0) {
            /* short circuit when iterable is empty */
            return 0;
        }
    
        Py_ssize_t m = Py_SIZE(self);
        // It should not be possible to allocate a list large enough to cause
        // an overflow on any relevant platform.
        assert(m < PY_SSIZE_T_MAX - n);
        if (self->ob_item == NULL) {
            if (list_preallocate_exact(self, n) < 0) {
                return -1;
            }
            Py_SET_SIZE(self, n);
        }
        else if (list_resize(self, m + n) < 0) {
            return -1;
        }
    
        // note that we may still have self == iterable here for the
        // situation a.extend(a), but the following code works
        // in that case too.  Just make sure to resize self
        // before calling PySequence_Fast_ITEMS.
        //
        // populate the end of self with iterable's items.
        PyObject **src = PySequence_Fast_ITEMS(iterable);
        PyObject **dest = self->ob_item + m;
        for (Py_ssize_t i = 0; i < n; i++) {
            PyObject *o = src[i];
            FT_ATOMIC_STORE_PTR_RELEASE(dest[i], Py_NewRef(o));
        }
        return 0;
    }

self->ob_item == NULLここに注目です。今回の場合、selfは空のリストです。なので、NULL判定になります。
そして、list_preallocate_exactが呼ばれます

static int
    list_preallocate_exact(PyListObject *self, Py_ssize_t size)
    {
        PyObject **items;
        assert(self->ob_item == NULL);
        assert(size > 0);
    
        /* Since the Python memory allocator has granularity of 16 bytes on 64-bit
         * platforms (8 on 32-bit), there is no benefit of allocating space for
         * the odd number of items, and there is no drawback of rounding the
         * allocated size up to the nearest even number.
         */
        size = (size + 1) & ~(size_t)1;
    ....(省略)
    #endif
        FT_ATOMIC_STORE_PTR_RELEASE(self->ob_item, items);
        self->allocated = size;
        return 0;
    }

ようやく辿り着きました。
重要な部分はsize = (size + 1) & ~(size_t)1;です。この実装だと、割り当てる要素の数が偶数になるように調整されています。
今回のケースだと、[0,0,0]の3つの要素でリストが成り立っているので、4つ分の要素のメモリがこのリストに割り当てられます。
最終的にはself->allocated = sizeで計算されたサイズを割り当てます。
というわけで最終的なメモリサイズは.

56(リストオブジェクトのbyteサイズ) + 4(要素数) * 8(byte) = 88(byte).

ということになるのですね。

確認

ということは、[0,0,0,0]と[0,0,0]は同じ量のメモリが割り当てられるのでしょうか?

import sys
    
list1 = [0, 0, 0]
list2 = [0, 0, 0, 0]
  
print("sizeof list1: ", sys.getsizeof(list1)) #88
print("sizeof list1: ", sys.getsizeof(list2)) #88

はい、同じでした。奇数偶数判定でメモリの割当量が決まるとはなんとも単純そうではありますが、元記事のPython3.9よりはメモリ効率が上がっているので、このアルゴリズムを実装するのがそもそも大変だったんじゃないかなと推察されます。

記事で紹介された最後の計算ロジックについてはまた後日書きます。

ちなみに元記事はこちら.

Most Developers Failed with this Senior-Level Python Interview Question | by Shuai Li | Aug, 2024 | Programming Domain